Replace strings.Split(RemoteAddr, ":") with net.SplitHostPort for correct IPv6 address extraction in vote deduplication and comment IP tracking. Harden image proxy: add SSRF-safe transport blocking private/reserved IPs at connection time with DNS rebinding protection, sanitize error messages to prevent information leakage, add response size limit via io.LimitReader. Fix shadowed error variables in BlockedUsers, SetTitle, and Delete methods. Exclude gosec taint analysis false positives at linter config level.
286 lines
8.4 KiB
Go
286 lines
8.4 KiB
Go
package notify
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"text/template"
|
|
"time"
|
|
|
|
log "github.com/go-pkgz/lgr"
|
|
ntf "github.com/go-pkgz/notify"
|
|
"github.com/go-pkgz/repeater/v2"
|
|
"github.com/hashicorp/go-multierror"
|
|
|
|
"github.com/umputun/remark42/backend/app/templates"
|
|
)
|
|
|
|
// EmailParams contain settings for email notifications
|
|
type EmailParams struct {
|
|
From string // from email address
|
|
AdminEmails []string // administrator emails to send copy of comment notification to
|
|
MsgTemplatePath string // path to request message template
|
|
VerificationSubject string // verification message sub
|
|
VerificationTemplatePath string // path to verification template
|
|
SubscribeURL string // full subscribe handler URL
|
|
UnsubscribeURL string // full unsubscribe handler URL
|
|
|
|
TokenGenFn func(userID, email, site string) (string, error) // unsubscribe token generation function
|
|
}
|
|
|
|
// Email implements notify.Destination for email
|
|
type Email struct {
|
|
*ntf.Email
|
|
|
|
EmailParams
|
|
msgTmpl *template.Template // parsed request message template
|
|
verifyTmpl *template.Template // parsed verification message template
|
|
}
|
|
|
|
// msgTmplData store data for message from request template execution
|
|
type msgTmplData struct {
|
|
UserName string
|
|
UserPicture string
|
|
CommentText string
|
|
CommentLink string
|
|
CommentDate time.Time
|
|
ParentUserName string
|
|
ParentUserPicture string
|
|
ParentCommentText string
|
|
ParentCommentLink string
|
|
ParentCommentDate time.Time
|
|
PostTitle string
|
|
Email string
|
|
UnsubscribeLink string
|
|
ForAdmin bool
|
|
}
|
|
|
|
// verifyTmplData store data for verification message template execution
|
|
type verifyTmplData struct {
|
|
User string
|
|
Token string
|
|
Email string
|
|
Site string
|
|
SubscribeURL string
|
|
}
|
|
|
|
const (
|
|
defaultVerificationSubject = "Email verification"
|
|
defaultEmailTimeout = 10 * time.Second
|
|
defaultEmailTemplatePath = "email_reply.html.tmpl"
|
|
defaultEmailVerificationTemplatePath = "email_confirmation_subscription.html.tmpl"
|
|
)
|
|
|
|
// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error
|
|
func NewEmail(emailParams EmailParams, smtpParams ntf.SMTPParams) (*Email, error) {
|
|
// set up Email emailParams
|
|
if smtpParams.TimeOut <= 0 {
|
|
smtpParams.TimeOut = defaultEmailTimeout
|
|
}
|
|
|
|
res := Email{Email: ntf.NewEmail(smtpParams), EmailParams: emailParams}
|
|
|
|
if res.VerificationSubject == "" {
|
|
res.VerificationSubject = defaultVerificationSubject
|
|
}
|
|
|
|
// initialize templates
|
|
err := res.setTemplates()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("can't set templates: %w", err)
|
|
}
|
|
|
|
log.Printf("[DEBUG] Create new email notifier for server %s with user %s, timeout=%s",
|
|
res.Host, res.Username, res.TimeOut)
|
|
|
|
return &res, nil
|
|
}
|
|
|
|
func (e *Email) setTemplates() error {
|
|
var err error
|
|
var msgTmplFile, verifyTmplFile []byte
|
|
|
|
if e.VerificationTemplatePath == "" {
|
|
e.VerificationTemplatePath = defaultEmailVerificationTemplatePath
|
|
}
|
|
|
|
if e.MsgTemplatePath == "" {
|
|
e.MsgTemplatePath = defaultEmailTemplatePath
|
|
}
|
|
|
|
if msgTmplFile, err = templates.Read(e.MsgTemplatePath); err != nil {
|
|
return fmt.Errorf("can't read message template: %w", err)
|
|
}
|
|
if verifyTmplFile, err = templates.Read(e.VerificationTemplatePath); err != nil {
|
|
return fmt.Errorf("can't read verification template: %w", err)
|
|
}
|
|
if e.msgTmpl, err = template.New("msgTmpl").Parse(string(msgTmplFile)); err != nil {
|
|
return fmt.Errorf("can't parse message template: %w", err)
|
|
}
|
|
if e.verifyTmpl, err = template.New("verifyTmpl").Parse(string(verifyTmplFile)); err != nil {
|
|
return fmt.Errorf("can't parse verification template: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Send email about comment reply to Request.Emails and Email.AdminEmails
|
|
// if they're set.
|
|
// Thread safe
|
|
func (e *Email) Send(ctx context.Context, req Request) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("sending email messages about comment %q aborted due to canceled context", req.Comment.ID)
|
|
default:
|
|
}
|
|
|
|
result := new(multierror.Error)
|
|
|
|
for _, email := range req.Emails {
|
|
err := e.buildAndSendMessage(ctx, req, email, false)
|
|
if err != nil {
|
|
result = multierror.Append(fmt.Errorf("problem sending user email notification to %q: %w", email, err))
|
|
}
|
|
}
|
|
|
|
for _, email := range e.AdminEmails {
|
|
err := e.buildAndSendMessage(ctx, req, email, true)
|
|
if err != nil {
|
|
result = multierror.Append(fmt.Errorf("problem sending admin email notification to %q: %w", email, err))
|
|
}
|
|
}
|
|
|
|
return result.ErrorOrNil()
|
|
}
|
|
|
|
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
|
|
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
|
|
msg, err := e.buildMessageFromRequest(req, email, forAdmin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return repeater.NewFixed(5, time.Millisecond*250).Do(
|
|
ctx,
|
|
func() error {
|
|
return e.Email.Send(
|
|
ctx,
|
|
fmt.Sprintf("mailto:%s?from=%s&unsubscribeLink=%s&subject=%s",
|
|
email,
|
|
url.QueryEscape(e.From),
|
|
url.QueryEscape(msg.unsubscribeLink),
|
|
url.QueryEscape(msg.subject),
|
|
),
|
|
msg.body,
|
|
)
|
|
})
|
|
}
|
|
|
|
// SendVerification email verification VerificationRequest.Email if it's set.
|
|
// Thread safe
|
|
func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) error {
|
|
if req.Email == "" {
|
|
// this means we can't send this request via Email
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("sending message to %q aborted due to canceled context", req.User)
|
|
default:
|
|
}
|
|
|
|
log.Printf("[DEBUG] send verification via %s, user %s", e, req.User)
|
|
msg, err := e.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return repeater.NewFixed(5, time.Millisecond*250).Do(
|
|
ctx,
|
|
func() error {
|
|
return e.Email.Send(
|
|
ctx,
|
|
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
|
|
req.Email,
|
|
url.QueryEscape(e.From),
|
|
url.QueryEscape(e.VerificationSubject),
|
|
),
|
|
msg,
|
|
)
|
|
})
|
|
}
|
|
|
|
// buildVerificationMessage generates verification email message based on given input
|
|
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
|
|
msg := bytes.Buffer{}
|
|
err := e.verifyTmpl.Execute(&msg, verifyTmplData{
|
|
User: user,
|
|
Token: token,
|
|
Email: email,
|
|
Site: site,
|
|
SubscribeURL: e.SubscribeURL,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("error executing template to build verification message: %w", err)
|
|
}
|
|
return msg.String(), nil
|
|
}
|
|
|
|
type commentMessage struct {
|
|
subject string
|
|
body string
|
|
unsubscribeLink string
|
|
}
|
|
|
|
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
|
|
func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool) (commentMessage, error) {
|
|
subject := "New reply to your comment"
|
|
if forAdmin {
|
|
subject = "New comment to your site"
|
|
}
|
|
if req.Comment.PostTitle != "" {
|
|
subject += fmt.Sprintf(" for %q", req.Comment.PostTitle)
|
|
}
|
|
|
|
token, err := e.TokenGenFn(req.parent.User.ID, email, req.Comment.Locator.SiteID)
|
|
if err != nil {
|
|
return commentMessage{}, fmt.Errorf("error creating token for unsubscribe link: %w", err)
|
|
}
|
|
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
|
|
if forAdmin {
|
|
unsubscribeLink = ""
|
|
}
|
|
|
|
commentURLPrefix := req.Comment.Locator.URL + uiNav
|
|
msg := bytes.Buffer{}
|
|
tmplData := msgTmplData{
|
|
UserName: req.Comment.User.Name,
|
|
UserPicture: req.Comment.User.Picture,
|
|
CommentText: req.Comment.Text,
|
|
CommentLink: commentURLPrefix + req.Comment.ID,
|
|
CommentDate: req.Comment.Timestamp,
|
|
PostTitle: req.Comment.PostTitle,
|
|
Email: email,
|
|
UnsubscribeLink: unsubscribeLink,
|
|
ForAdmin: forAdmin,
|
|
}
|
|
// in case of message to admin, parent message might be empty
|
|
if req.Comment.ParentID != "" {
|
|
tmplData.ParentUserName = req.parent.User.Name
|
|
tmplData.ParentUserPicture = req.parent.User.Picture
|
|
tmplData.ParentCommentText = req.parent.Text
|
|
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
|
|
tmplData.ParentCommentDate = req.parent.Timestamp
|
|
}
|
|
err = e.msgTmpl.Execute(&msg, tmplData)
|
|
if err != nil {
|
|
return commentMessage{}, fmt.Errorf("error executing template to build comment reply message: %w", err)
|
|
}
|
|
return commentMessage{
|
|
subject: subject,
|
|
body: msg.String(),
|
|
unsubscribeLink: unsubscribeLink,
|
|
}, err
|
|
}
|