// Copyright (C) 2025 wangyusong // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package channel import ( "context" "fmt" "net" "strconv" "strings" "text/template" "time" "github.com/pkg/errors" "gopkg.in/gomail.v2" "github.com/glidea/zenfeed/pkg/model" "github.com/glidea/zenfeed/pkg/notify/route" "github.com/glidea/zenfeed/pkg/util/buffer" textconvert "github.com/glidea/zenfeed/pkg/util/text_convert" timeutil "github.com/glidea/zenfeed/pkg/util/time" ) type Email struct { SmtpEndpoint string host string port int From string Password string FeedMarkdownTemplate string feedMakrdownTemplate *template.Template FeedHTMLSnippetTemplate string feedHTMLSnippetTemplate *template.Template } func (c *Email) Enabled() bool { return c != nil && c.SmtpEndpoint != "" } func (c *Email) Validate() error { if c.SmtpEndpoint == "" { return errors.New("email.smtp_endpoint is required") } parts := strings.Split(c.SmtpEndpoint, ":") if len(parts) != 2 { return errors.New("email.smtp_endpoint must be in the format host:port") } c.host = parts[0] var err error c.port, err = strconv.Atoi(parts[1]) if err != nil { return errors.Wrap(err, "invalid email.smtp_endpoint") } if c.From == "" { return errors.New("email.from is required") } if c.FeedMarkdownTemplate == "" { c.FeedMarkdownTemplate = fmt.Sprintf("{{.%s}}", model.LabelContent) } t, err := template.New("").Parse(c.FeedMarkdownTemplate) if err != nil { return errors.Wrap(err, "parse feed markdown template") } c.feedMakrdownTemplate = t if c.FeedHTMLSnippetTemplate != "" { t, err := template.New("").Parse(c.FeedHTMLSnippetTemplate) if err != nil { return errors.Wrap(err, "parse feed html snippet template") } c.feedHTMLSnippetTemplate = t } return nil } func newEmail(c *Email, dependencies Dependencies) (sender, error) { host, portStr, err := net.SplitHostPort(c.SmtpEndpoint) if err != nil { return nil, errors.Wrap(err, "split host port") } port, err := strconv.Atoi(portStr) if err != nil { return nil, errors.Wrap(err, "convert port to int") } return &email{ config: c, dependencies: dependencies, dialer: gomail.NewDialer(host, port, c.From, c.Password), }, nil } type email struct { config *Email dependencies Dependencies dialer *gomail.Dialer } func (e *email) Send(ctx context.Context, receiver Receiver, group *route.FeedGroup) error { email, err := e.buildEmail(receiver, group) if err != nil { return errors.Wrap(err, "build email") } if err := e.dialer.DialAndSend(email); err != nil { return errors.Wrap(err, "send email") } return nil } func (e *email) buildEmail(receiver Receiver, group *route.FeedGroup) (*gomail.Message, error) { m := gomail.NewMessage() m.SetHeader("From", e.config.From) m.SetHeader("To", receiver.Email) m.SetHeader("Subject", group.Name) body, err := e.buildBodyHTML(group) if err != nil { return nil, errors.Wrap(err, "build email body HTML") } m.SetBody("text/html", body) return m, nil } func (e *email) buildBodyHTML(group *route.FeedGroup) (string, error) { bodyBuf := buffer.Get() defer buffer.Put(bodyBuf) // Write HTML header. if err := e.writeHTMLHeader(bodyBuf); err != nil { return "", errors.Wrap(err, "write HTML header") } // Write summary. if err := e.writeSummary(bodyBuf, group.Summary); err != nil { return "", errors.Wrap(err, "write summary") } // Write each feed content. if _, err := bodyBuf.WriteString(`

Feeds

`); err != nil { return "", errors.Wrap(err, "write feeds header") } for i, feed := range group.Feeds { if err := e.writeFeedContent(bodyBuf, feed); err != nil { return "", errors.Wrap(err, "write feed content") } // Add separator (except the last feed). if i < len(group.Feeds)-1 { if err := e.writeSeparator(bodyBuf); err != nil { return "", errors.Wrap(err, "write separator") } } } // Write disclaimer and HTML footer. if err := e.writeDisclaimer(bodyBuf); err != nil { return "", errors.Wrap(err, "write disclaimer") } if err := e.writeHTMLFooter(bodyBuf); err != nil { return "", errors.Wrap(err, "write HTML footer") } return bodyBuf.String(), nil } func (e *email) writeHTMLHeader(buf *buffer.Bytes) error { _, err := buf.WriteString(` Summary
`) return err } func (e *email) writeSummary(buf *buffer.Bytes, summary string) error { if summary == "" { return nil } if _, err := buf.WriteString(`

Summary

`); err != nil { return errors.Wrap(err, "write summary header") } contentHTML, err := textconvert.MarkdownToHTML([]byte(summary)) if err != nil { return errors.Wrap(err, "markdown to HTML") } contentHTMLWithStyle := fmt.Sprintf(`
%s
`, contentHTML) if _, err := buf.WriteString(contentHTMLWithStyle); err != nil { return errors.Wrap(err, "write summary") } return nil } const timeLayout = "01-02 15:04" func (e *email) writeFeedContent(buf *buffer.Bytes, feed *route.Feed) error { // Write title and source information. if err := e.writeFeedHeader(buf, feed); err != nil { return errors.Wrap(err, "write feed header") } // Write content. if err := e.writeFeedBody(buf, feed); err != nil { return errors.Wrap(err, "write feed body") } // Write related articles. if len(feed.Related) > 0 { if err := e.writeRelateds(buf, feed.Related); err != nil { return errors.Wrap(err, "write relateds") } } if _, err := buf.WriteString(`
`); err != nil { return errors.Wrap(err, "write feed footer") } return nil } func (e *email) writeFeedHeader(buf *buffer.Bytes, feed *route.Feed) error { typ := feed.Labels.Get(model.LabelType) source := feed.Labels.Get(model.LabelSource) title := feed.Labels.Get(model.LabelTitle) link := feed.Labels.Get(model.LabelLink) pubTimeI, _ := timeutil.Parse(feed.Labels.Get(model.LabelPubTime)) pubTime := pubTimeI.In(time.Local).Format(timeLayout) scrapeTime := feed.Time.In(time.Local).Format(timeLayout) if _, err := fmt.Fprintf(buf, `

%s

Source: %s/%s

Published: %s | Scraped: %s

`, title, link, typ, source, pubTime, scrapeTime); err != nil { return errors.Wrap(err, "write feed header") } return nil } func (e *email) writeFeedBody(buf *buffer.Bytes, feed *route.Feed) error { if _, err := buf.WriteString(`
`); err != nil { return errors.Wrap(err, "write feed body header") } if _, err := e.renderFeedContent(buf, feed); err != nil { return errors.Wrap(err, "render feed content") } if _, err := buf.WriteString(`
`); err != nil { return errors.Wrap(err, "write feed body footer") } return nil } func (e *email) renderFeedContent(buf *buffer.Bytes, feed *route.Feed) (n int, err error) { if e.config.feedHTMLSnippetTemplate != nil { n, err = e.renderHTMLContent(buf, feed) if err == nil && n > 0 { return } } // Fallback to markdown. return e.renderMarkdownContent(buf, feed) } func (e *email) renderHTMLContent(buf *buffer.Bytes, feed *route.Feed) (n int, err error) { oldN := buf.Len() if err := e.config.feedHTMLSnippetTemplate.Execute(buf, feed.Labels.Map()); err != nil { return 0, errors.Wrap(err, "execute feed HTML template") } return buf.Len() - oldN, nil } func (e *email) renderMarkdownContent(buf *buffer.Bytes, feed *route.Feed) (n int, err error) { oldN := buf.Len() tempBuf := buffer.Get() defer buffer.Put(tempBuf) if err := e.config.feedMakrdownTemplate.Execute(tempBuf, feed.Labels.Map()); err != nil { return 0, errors.Wrap(err, "execute feed markdown template") } contentMarkdown := tempBuf.Bytes() contentHTML, err := textconvert.MarkdownToHTML(contentMarkdown) if err != nil { return 0, errors.Wrap(err, "markdown to HTML") } contentHTMLWithStyle := fmt.Sprintf(`
%s
`, contentHTML) if _, err := buf.WriteString(contentHTMLWithStyle); err != nil { return 0, errors.Wrap(err, "write content HTML") } return buf.Len() - oldN, nil } func (e *email) writeRelateds(buf *buffer.Bytes, related []*route.Feed) error { if _, err := buf.WriteString(`

Related:

`); err != nil { return errors.Wrapf(err, "write relateds header") } for _, f := range related { relTyp := f.Labels.Get(model.LabelType) relSource := f.Labels.Get(model.LabelSource) relTitle := f.Labels.Get(model.LabelTitle) relLink := f.Labels.Get(model.LabelLink) if _, err := fmt.Fprintf(buf, ` `, relLink, relTyp, relSource, relTitle); err != nil { return errors.Wrapf(err, "write relateds item") } } if _, err := buf.WriteString(`
`); err != nil { return errors.Wrapf(err, "write relateds footer") } return nil } func (e *email) writeSeparator(buf *buffer.Bytes) error { _, err := buf.WriteString(`
`) return err } func (e *email) writeDisclaimer(buf *buffer.Bytes) error { _, err := buf.WriteString(`

免责声明 / Disclaimer
本邮件内容仅用于个人概括性学习和理解,版权归原作者所有。 This email content is for personal learning and understanding purposes only. All rights reserved to the original author.

严禁二次分发或传播!!!
NO redistribution or sharing!!!

如有侵权,请联系 / For copyright issues, please contact:
ysking7402@gmail.com

`) return err } func (e *email) writeHTMLFooter(buf *buffer.Bytes) error { _, err := buf.WriteString(`
`) return err }