diff --git a/email.go b/email.go index da4590e..a44b253 100644 --- a/email.go +++ b/email.go @@ -1,462 +1,472 @@ /* * Copyright © 2019-2021 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package writefreely import ( "database/sql" "encoding/json" "fmt" + "github.com/writefreely/writefreely/mailer" "html/template" "net/http" "strings" "time" "github.com/aymerick/douceur/inliner" "github.com/gorilla/mux" "github.com/mailgun/mailgun-go" stripmd "github.com/writeas/go-strip-markdown/v2" "github.com/writeas/impart" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" "github.com/writefreely/writefreely/key" "github.com/writefreely/writefreely/spam" ) const ( emailSendDelay = 15 ) type ( SubmittedSubscription struct { CollAlias string UserID int64 Email string `schema:"email" json:"email"` Web bool `schema:"web" json:"web"` Slug string `schema:"slug" json:"slug"` From string `schema:"from" json:"from"` } EmailSubscriber struct { ID string CollID int64 UserID sql.NullInt64 Email sql.NullString Subscribed time.Time Token string Confirmed bool AllowExport bool acctEmail sql.NullString } ) func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string { if !es.UserID.Valid || es.Email.Valid { return es.Email.String } decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String)) if err != nil { log.Error("Error decrypting user email: %v", err) return "" } return string(decEmail) } func (es *EmailSubscriber) SubscribedFriendly() string { return es.Subscribed.Format("January 2, 2006") } func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { reqJSON := IsJSON(r) vars := mux.Vars(r) var err error ss := SubmittedSubscription{ CollAlias: vars["alias"], } u := getUserSession(app, r) if u != nil { ss.UserID = u.ID } if reqJSON { // Decode JSON request decoder := json.NewDecoder(r.Body) err = decoder.Decode(&ss) if err != nil { log.Error("Couldn't parse new subscription JSON request: %v\n", err) return ErrBadJSON } } else { err = r.ParseForm() if err != nil { log.Error("Couldn't parse new subscription form request: %v\n", err) return ErrBadFormData } err = app.formDecoder.Decode(&ss, r.PostForm) if err != nil { log.Error("Continuing, but error decoding new subscription form request: %v\n", err) //return ErrBadFormData } } c, err := app.db.GetCollection(ss.CollAlias) if err != nil { log.Error("getCollection: %s", err) return err } c.hostName = app.cfg.App.Host from := c.CanonicalURL() isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID) if isAuthorBanned { log.Info("Author is silenced, so subscription is blocked.") return impart.HTTPError{http.StatusFound, from} } if ss.Web { if u != nil && u.ID == c.OwnerID { from = "/" + c.Alias + "/" } from += ss.Slug } if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" { log.Info("Honeypot field was filled out! Not subscribing.") return impart.HTTPError{http.StatusFound, from} } if ss.Email == "" && ss.UserID < 1 { log.Info("No subscriber data. Not subscribing.") return impart.HTTPError{http.StatusFound, from} } confirmed := app.db.IsSubscriberConfirmed(ss.Email) es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed) if err != nil { log.Error("addEmailSubscription: %s", err) return err } // Send confirmation email if needed if !confirmed { err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token) if err != nil { log.Error("Failed to send subscription confirmation email: %s", err) return err } } if ss.Web { session, err := app.sessionStore.Get(r, userEmailCookieName) if err != nil { // The cookie should still save, even if there's an error. // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144 log.Error("Getting user email cookie: %v; ignoring", err) } if confirmed { addSessionFlash(app, w, r, "Subscribed. You'll now receive future blog posts via email.", nil) } else { addSessionFlash(app, w, r, "Please check your email and click the confirmation link to subscribe.", nil) } session.Values[userEmailCookieVal] = ss.Email err = session.Save(r, w) if err != nil { log.Error("save email cookie: %s", err) return err } return impart.HTTPError{http.StatusFound, from} } return impart.WriteSuccess(w, "", http.StatusAccepted) } func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { alias := collectionAliasFromReq(r) vars := mux.Vars(r) subID := vars["subscriber"] email := r.FormValue("email") token := r.FormValue("t") slug := r.FormValue("slug") isWeb := r.Method == "GET" // Display collection if this is a collection var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { log.Error("Get collection: %s", err) return err } from := c.CanonicalURL() if subID != "" { // User unsubscribing via email, so assume action is taken by either current // user or not current user, and only use the request's information to // satisfy this unsubscribe, i.e. subscriberID and token. err = app.db.DeleteEmailSubscriber(subID, token) } else { // User unsubscribing through the web app, so assume action is taken by // currently-auth'd user. var userID int64 u := getUserSession(app, r) if u != nil { // User is logged in userID = u.ID if userID == c.OwnerID { from = "/" + c.Alias + "/" } } if email == "" && userID <= 0 { // Get email address from saved cookie session, err := app.sessionStore.Get(r, userEmailCookieName) if err != nil { log.Error("Unable to get email cookie: %s", err) } else { email = session.Values[userEmailCookieVal].(string) } } if email == "" && userID <= 0 { err = fmt.Errorf("No subscriber given.") log.Error("Not deleting subscription: %s", err) return err } err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID) } if err != nil { log.Error("Unable to delete subscriber: %v", err) return err } if isWeb { from += slug addSessionFlash(app, w, r, "Unsubscribed. You will no longer receive these blog posts via email.", nil) return impart.HTTPError{http.StatusFound, from} } return impart.WriteSuccess(w, "", http.StatusAccepted) } func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { alias := collectionAliasFromReq(r) subID := mux.Vars(r)["subscriber"] token := r.FormValue("t") var c *Collection var err error if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) } if err != nil { log.Error("Get collection: %s", err) return err } from := c.CanonicalURL() err = app.db.UpdateSubscriberConfirmed(subID, token) if err != nil { addSessionFlash(app, w, r, err.Error(), nil) return impart.HTTPError{http.StatusFound, from} } addSessionFlash(app, w, r, "Confirmed! Thanks. Now you'll receive future blog posts via email.", nil) return impart.HTTPError{http.StatusFound, from} } func emailPost(app *App, p *PublicPost, collID int64) error { p.augmentContent() // Do some shortcode replacement. // Since the user is receiving this email, we can assume they're subscribed via email. p.Content = strings.Replace(p.Content, "", `

You're subscribed to email updates.

`, -1) if p.HTMLContent == template.HTML("") { p.formatContent(app.cfg, false, false) } p.augmentReadingDestination() title := p.Title.String if title != "" { title = p.Title.String + "\n\n" } plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content) plainMsg += ` --------------------------------------------------------------------------------- Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to. Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg) replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) if replyTo != "" { m.SetReplyTo(replyTo) } subs, err := app.db.GetEmailSubscribers(collID, true) if err != nil { log.Error("Unable to get email subscribers: %v", err) return err } if len(subs) == 0 { return nil } if title != "" { title = string(`

` + p.FormattedDisplayTitle() + `

`) } m.AddTag("New post") fontFam := "Lora, Palatino, Baskerville, serif" if p.IsSans() { fontFam = `"Open Sans", Tahoma, Arial, sans-serif` } else if p.IsMonospace() { fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace` } // TODO: move this to a templated file and LESS-generated stylesheet fullHTML := `
` + title + `

From ` + p.DisplayCanonicalURL() + `

` + string(p.HTMLContent) + `

` // inline CSS html, err := inliner.Inline(fullHTML) if err != nil { log.Error("Unable to inline email HTML: %v", err) return err } m.SetHtml(html) log.Info("[email] Adding %d recipient(s)", len(subs)) for _, s := range subs { e := s.FinalEmail(app.keys) log.Info("[email] Adding %s", e) err = m.AddRecipientAndVariables(e, map[string]interface{}{ "id": s.ID, "to": e, "token": s.Token, }) if err != nil { log.Error("Unable to add receipient %s: %s", e, err) } } res, _, err := gun.Send(m) log.Info("[email] Send result: %s", res) if err != nil { log.Error("Unable to send post email: %v", err) return err } return nil } func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error { if email == "" { return fmt.Errorf("You must supply an email to verify.") } // Send email - gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + mlr, err := mailer.New(app.cfg.Email) + if err != nil { + return err + } plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser): ` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + ` If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.` - m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) + m, err := mlr.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) + if err != nil { + return err + } m.AddTag("Email Verification") - m.SetHtml(` + m.SetHTML(`

Confirm your subscription to ` + c.DisplayTitle() + ` to start receiving future posts:

Subscribe to ` + c.DisplayTitle() + `

If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.

`) - gun.Send(m) + err = mlr.Send(m) + if err != nil { + return err + } return nil } diff --git a/mailer/mailer.go b/mailer/mailer.go index fbc4dd6..1f9f86e 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -1,98 +1,105 @@ /* * Copyright © 2024 Musing Studio LLC. * * This file is part of WriteFreely. * * WriteFreely is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, included * in the LICENSE file in this source code package. */ package mailer import ( "fmt" "github.com/mailgun/mailgun-go" "github.com/writefreely/writefreely/config" mail "github.com/xhit/go-simple-mail/v2" ) type ( // Mailer holds configurations for the preferred mailing provider. Mailer struct { smtp *mail.SMTPServer mailGun *mailgun.MailgunImpl } // Message holds the email contents and metadata for the preferred mailing provider. Message struct { mgMsg *mailgun.Message smtpMsg *mail.Email } ) // New creates a new Mailer from the instance's config.EmailCfg, returning an error if not properly configured. -func New(eCfg *config.EmailCfg) (*Mailer, error) { +func New(eCfg config.EmailCfg) (*Mailer, error) { m := &Mailer{} if eCfg.Domain != "" && eCfg.MailgunPrivate != "" { m.mailGun = mailgun.NewMailgun(eCfg.Domain, eCfg.MailgunPrivate) } else if eCfg.Username != "" && eCfg.Password != "" && eCfg.Host != "" && eCfg.Port > 0 { m.smtp = mail.NewSMTPClient() m.smtp.Host = eCfg.Host m.smtp.Port = eCfg.Port m.smtp.Username = eCfg.Username m.smtp.Password = eCfg.Password if eCfg.EnableStartTLS { m.smtp.Encryption = mail.EncryptionSTARTTLS } } else { return nil, fmt.Errorf("no email provider is configured") } return m, nil } // NewMessage creates a new Message from the given parameters. func (m *Mailer) NewMessage(from, subject, text string, to ...string) (*Message, error) { msg := &Message{} if m.mailGun != nil { msg.mgMsg = m.mailGun.NewMessage(from, subject, text, to...) } else if m.smtp != nil { msg.smtpMsg = mail.NewMSG() msg.smtpMsg.SetFrom(from) msg.smtpMsg.AddTo(to...) msg.smtpMsg.SetSubject(subject) msg.smtpMsg.AddAlternative(mail.TextPlain, text) if msg.smtpMsg.Error != nil { return nil, msg.smtpMsg.Error } } return msg, nil } // SetHTML sets the body of the message. func (m *Message) SetHTML(html string) { if m.smtpMsg != nil { m.smtpMsg.SetBody(mail.TextHTML, html) } else if m.mgMsg != nil { m.mgMsg.SetHtml(html) } } +// AddTag attaches a tag to the Message for providers that support it. +func (m *Message) AddTag(tag string) { + if m.mgMsg != nil { + m.mgMsg.AddTag(tag) + } +} + // Send sends the given message via the preferred provider. func (m *Mailer) Send(msg *Message) error { if m.smtp != nil { client, err := m.smtp.Connect() if err != nil { return err } return msg.smtpMsg.Send(client) } else if m.mailGun != nil { _, _, err := m.mailGun.Send(msg.mgMsg) if err != nil { return err } } return nil }