diff --git a/pkg/controllers/comments.go b/pkg/controllers/comments.go index a2b9c65..49a1746 100644 --- a/pkg/controllers/comments.go +++ b/pkg/controllers/comments.go @@ -9,6 +9,7 @@ import ( "git.kirsle.net/apps/gophertype/pkg/authentication" "git.kirsle.net/apps/gophertype/pkg/glue" + "git.kirsle.net/apps/gophertype/pkg/mail" "git.kirsle.net/apps/gophertype/pkg/models" "git.kirsle.net/apps/gophertype/pkg/responses" "git.kirsle.net/apps/gophertype/pkg/session" @@ -197,6 +198,9 @@ func PostComment(w http.ResponseWriter, r *http.Request) { return } + // Notify site admins and subscribers by email. + go mail.NotifyComment(v.V["Subject"].(string), v.V["OriginURL"].(string), comment) + session.Flash(w, r, "Your comment has been added!") responses.Redirect(w, r, form.Get("origin")) return @@ -211,8 +215,27 @@ func PostComment(w http.ResponseWriter, r *http.Request) { // CommentQuickDelete handles quick-delete links to remove spam comments. func CommentQuickDelete(w http.ResponseWriter, r *http.Request) { - v := responses.NewTemplateVars(w, r) - responses.RenderTemplate(w, r, "_builtin/blog/view-post.gohtml", v) + // Query parameters. + var ( + deleteToken = r.URL.Query().Get("d") + nextURL = r.URL.Query().Get("next") + ) + + // Look up the comment by thread and quick-delete token. + comment, err := models.Comments.LoadByDeleteToken(deleteToken) + if err != nil { + responses.Forbidden(w, r, "Comment by Delete Token not found.") + return + } + + comment.Delete() + + session.Flash(w, r, "Comment has been quick-deleted!") + + if nextURL == "" { + nextURL = "/" + } + responses.Redirect(w, r, nextURL) } // getEditToken gets the edit token from the user's session. diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go new file mode 100644 index 0000000..07cf9e3 --- /dev/null +++ b/pkg/mail/mail.go @@ -0,0 +1,160 @@ +package mail + +import ( + "bytes" + "fmt" + "html/template" + "net/mail" + "net/url" + "strings" + + "git.kirsle.net/apps/gophertype/pkg/console" + "git.kirsle.net/apps/gophertype/pkg/markdown" + "git.kirsle.net/apps/gophertype/pkg/models" + "git.kirsle.net/apps/gophertype/pkg/responses" + "git.kirsle.net/apps/gophertype/pkg/settings" + "github.com/microcosm-cc/bluemonday" + gomail "gopkg.in/gomail.v2" +) + +// Email configuration. +type Email struct { + To string + ReplyTo string + Admin bool /* admin view of the email */ + Subject string + UnsubscribeURL string + Data map[string]interface{} + + Template string +} + +// SendEmail sends an email. +func SendEmail(email Email) { + s := settings.Current + + // Suppress sending any mail when no mail settings are configured, but go + // through the motions -- great for local dev. + var doNotMail bool + if !s.MailEnabled || s.MailHost == "" || s.MailPort == 0 || s.MailSender == "" { + console.Info("Suppressing email: not completely configured") + doNotMail = true + } + + // Resolve the template. + tmpl, err := responses.GetFile(email.Template) + if err != nil { + console.Error("SendEmail: %s", err.Error()) + return + } + + // Render the template to HTML. + var html bytes.Buffer + t := template.New(email.Template) + t, err = t.Parse(string(tmpl)) + if err != nil { + console.Error("SendEmail: template parsing error: %s", err.Error()) + } + + // Execute the template. + err = t.ExecuteTemplate(&html, email.Template, email) + if err != nil { + console.Error("SendEmail: template execution error: %s", err.Error()) + } + + // Condense the body down to plain text, lazily. Who even has a plain text + // email client anymore? + rawLines := strings.Split( + bluemonday.StrictPolicy().Sanitize(html.String()), + "\n", + ) + var lines []string + for _, line := range rawLines { + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + lines = append(lines, line) + } + plaintext := strings.Join(lines, "\n\n") + + // If we're not actually going to send the mail, this is a good place to stop. + if doNotMail { + console.Info("Not going to send an email.") + console.Debug("The message was going to be:\n%s", plaintext) + return + } + + m := gomail.NewMessage() + m.SetHeader("From", fmt.Sprintf("%s <%s>", s.Title, s.MailSender)) + m.SetHeader("To", email.To) + if email.ReplyTo != "" { + m.SetHeader("Reply-To", email.ReplyTo) + } + m.SetHeader("Subject", email.Subject) + m.SetBody("text/plain", plaintext) + m.AddAlternative("text/html", html.String()) + + d := gomail.NewDialer(s.MailHost, s.MailPort, s.MailUsername, s.MailPassword) + + console.Info("SendEmail: %s (%s) to %s", email.Subject, email.Template, email.To) + if err := d.DialAndSend(m); err != nil { + console.Error("SendEmail: %s", err.Error()) + } +} + +// NotifyComment sends notification emails about comments. +func NotifyComment(subject string, originURL string, c models.Comment) { + s := settings.Current + if s.BaseURL == "" { + console.Error("Can't send comment notification because the site URL is not configured") + return + } + + // Prepare the email payload. + email := Email{ + Template: "_builtin/email/comment.gohtml", + Subject: "Comment Added: " + subject, + Data: map[string]interface{}{ + "Name": c.Name, + "Subject": subject, + "Body": template.HTML(markdown.RenderMarkdown(c.Body)), + "URL": strings.Trim(s.BaseURL, "/") + originURL, + "QuickDelete": fmt.Sprintf("%s/comments/quick-delete?d=%s&next=%s", + strings.Trim(s.BaseURL, "/"), + url.QueryEscape(c.DeleteToken), + url.QueryEscape(strings.Trim(s.BaseURL, "/")+originURL), + ), + }, + } + + // Email the site admins. + if adminEmails, err := models.ListAdminEmails(); err == nil { + email.To = strings.Join(adminEmails, ", ") + email.Admin = true + console.Info("Mail site admin '%s' about comment notification on '%s'", email.To, c.Thread) + SendEmail(email) + } + + // // Email the subscribers. + // email.Admin = false + // m := comments.LoadMailingList() + // for _, to := range m.List(c.ThreadID) { + // if to == c.Email { + // continue // don't email yourself + // } + // email.To = to + // email.UnsubscribeURL = fmt.Sprintf("%s/comments/subscription?t=%s&e=%s", + // strings.Trim(s.Site.URL, "/"), + // url.QueryEscape(c.ThreadID), + // url.QueryEscape(to), + // ) + // console.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.ThreadID) + // SendEmail(email) + // } +} + +// ParseAddress parses an email address. +func ParseAddress(addr string) (*mail.Address, error) { + return mail.ParseAddress(addr) +} diff --git a/pkg/models/users.go b/pkg/models/users.go index 08d4bc8..9d981b4 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -64,6 +64,21 @@ func GetUserByEmail(email string) (User, error) { return user, r.Error } +// ListAdminEmails returns the array of email addresses of all admin users. +func ListAdminEmails() ([]string, error) { + var ( + users []User + emails []string + ) + r := DB.Where("is_admin=true AND email IS NOT NULL").Find(&users) + for _, user := range users { + if len(user.Email) > 0 { + emails = append(emails, user.Email) + } + } + return emails, r.Error +} + // SetPassword stores the hashed password for a user. func (u *User) SetPassword(password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), constants.BcryptCost) diff --git a/pvt-www/_builtin/email/comment.gohtml b/pvt-www/_builtin/email/comment.gohtml new file mode 100644 index 0000000..e930651 --- /dev/null +++ b/pvt-www/_builtin/email/comment.gohtml @@ -0,0 +1,61 @@ + + + + + + + + {{ .Subject }} + + + +
+ + + + + + + + + + +
+ + {{ .Subject }} + +
+ + {{ if not .Admin }} + Hello,

+ {{ end }} + + {{ or .Data.Name "Anonymous" }} has left a comment on: {{ .Data.Subject }} +

+ + {{ .Data.Body }} +

+ +
+ + To view this comment, please go to {{ .Data.URL }}. + + {{ if .Admin }} +

+ Was this comment spam? Delete it. + {{ end }} + + {{ if .UnsubscribeURL }} +

+ To unsubscribe from this comment thread, visit {{ .UnsubscribeURL }} + {{ end }} +
+
+ + This e-mail was automatically generated; do not reply to it. + +
+
+ + +