175 lines
5.9 KiB
Go
175 lines
5.9 KiB
Go
package mail
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"log"
|
|
"net"
|
|
"net/smtp"
|
|
"strings"
|
|
)
|
|
|
|
var smtpSendMail = smtp.SendMail
|
|
|
|
// Message is an outbound email. Callers must not log Address or Body (PII).
|
|
type Message struct {
|
|
To string
|
|
Subject string
|
|
Text string
|
|
HTML string
|
|
}
|
|
|
|
type Mailer interface {
|
|
Send(msg Message) error
|
|
Enabled() bool
|
|
}
|
|
|
|
type Config struct {
|
|
Enabled bool
|
|
Host string
|
|
Port string
|
|
User string
|
|
Password string
|
|
From string
|
|
}
|
|
|
|
// New returns an SMTP mailer when enabled and configured; otherwise a no-op that logs event type only.
|
|
func New(cfg Config) Mailer {
|
|
if !cfg.Enabled || strings.TrimSpace(cfg.Host) == "" {
|
|
return &noopMailer{}
|
|
}
|
|
return &smtpMailer{cfg: cfg}
|
|
}
|
|
|
|
type noopMailer struct{}
|
|
|
|
func (n *noopMailer) Enabled() bool { return false }
|
|
|
|
func (n *noopMailer) Send(msg Message) error {
|
|
log.Printf("mail: skipped (SMTP disabled) subject=%q", msg.Subject)
|
|
return nil
|
|
}
|
|
|
|
type smtpMailer struct {
|
|
cfg Config
|
|
}
|
|
|
|
func (s *smtpMailer) Enabled() bool { return true }
|
|
|
|
func (s *smtpMailer) Send(msg Message) error {
|
|
to := strings.TrimSpace(msg.To)
|
|
if to == "" {
|
|
return fmt.Errorf("mail: recipient required")
|
|
}
|
|
if hasHeaderBreak(to) {
|
|
return fmt.Errorf("mail: invalid recipient")
|
|
}
|
|
from := strings.TrimSpace(s.cfg.From)
|
|
if from == "" {
|
|
return fmt.Errorf("mail: from address required")
|
|
}
|
|
if hasHeaderBreak(from) {
|
|
return fmt.Errorf("mail: invalid from address")
|
|
}
|
|
if hasHeaderBreak(msg.Subject) {
|
|
return fmt.Errorf("mail: invalid subject")
|
|
}
|
|
addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
|
|
boundary := "descrybe_boundary_7f3a"
|
|
var body strings.Builder
|
|
body.WriteString(fmt.Sprintf("From: %s\r\n", from))
|
|
body.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
|
body.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
|
body.WriteString("MIME-Version: 1.0\r\n")
|
|
if strings.TrimSpace(msg.HTML) != "" {
|
|
body.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", boundary))
|
|
body.WriteString(fmt.Sprintf("--%s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", boundary, msg.Text))
|
|
body.WriteString(fmt.Sprintf("--%s\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n", boundary, msg.HTML))
|
|
body.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
|
} else {
|
|
body.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
|
body.WriteString(msg.Text)
|
|
}
|
|
|
|
var auth smtp.Auth
|
|
if s.cfg.User != "" {
|
|
auth = smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host)
|
|
}
|
|
if err := smtpSendMail(addr, auth, from, []string{to}, []byte(body.String())); err != nil {
|
|
log.Printf("mail: send failed subject=%q", msg.Subject)
|
|
return fmt.Errorf("mail send failed")
|
|
}
|
|
log.Printf("mail: sent subject=%q", msg.Subject)
|
|
return nil
|
|
}
|
|
|
|
func hasHeaderBreak(v string) bool {
|
|
return strings.ContainsAny(v, "\r\n")
|
|
}
|
|
|
|
func InviteMessage(webOrigin, email, token, companyName string) Message {
|
|
link := AcceptInviteURL(webOrigin, token)
|
|
text := fmt.Sprintf("You have been invited to %s on Descrybe.\n\nAccept: %s\n", companyName, link)
|
|
htmlBody := fmt.Sprintf(
|
|
`<p>You have been invited to <strong>%s</strong> on Descrybe.</p><p><a href="%s">Accept invite</a></p>`,
|
|
html.EscapeString(companyName), html.EscapeString(link),
|
|
)
|
|
return Message{To: email, Subject: "You are invited to Descrybe", Text: text, HTML: htmlBody}
|
|
}
|
|
|
|
// AcceptInviteURL builds the durable invite / set-password accept link (hashed invite tokens).
|
|
// Token is placed in the URL fragment so it is not sent on the page GET (Referer/access logs).
|
|
func AcceptInviteURL(webOrigin, token string) string {
|
|
return strings.TrimRight(webOrigin, "/") + "/accept-invite#token=" + token
|
|
}
|
|
|
|
// SetPasswordURL builds the HMAC set-password accept-invite link.
|
|
// Token is placed in the URL fragment so it is not sent on the page GET (Referer/access logs).
|
|
func SetPasswordURL(webOrigin, token string) string {
|
|
return strings.TrimRight(webOrigin, "/") + "/accept-invite#token=" + token + "&mode=set-password"
|
|
}
|
|
|
|
func SetPasswordMessage(webOrigin, email, token string) Message {
|
|
link := SetPasswordURL(webOrigin, token)
|
|
text := fmt.Sprintf("Set your Descrybe password:\n\n%s\n\nThis link expires in 72 hours.\n", link)
|
|
htmlBody := fmt.Sprintf(
|
|
`<p>Set your Descrybe password:</p><p><a href="%s">Set password</a></p><p>This link expires in 72 hours.</p>`,
|
|
html.EscapeString(link),
|
|
)
|
|
return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: htmlBody}
|
|
}
|
|
|
|
// MigratedSetPasswordMessage uses migrator invite tokens (accept-invite flow).
|
|
func MigratedSetPasswordMessage(webOrigin, email, token string) Message {
|
|
link := AcceptInviteURL(webOrigin, token)
|
|
text := fmt.Sprintf(
|
|
"Your Descrybe account was migrated. Set your password here:\n\n%s\n\nIf you did not expect this email, ignore it.\n",
|
|
link,
|
|
)
|
|
htmlBody := fmt.Sprintf(
|
|
`<p>Your Descrybe account was migrated.</p><p><a href="%s">Set your password</a></p><p>If you did not expect this email, ignore it.</p>`,
|
|
html.EscapeString(link),
|
|
)
|
|
return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: htmlBody}
|
|
}
|
|
|
|
// ResetPasswordURL builds the self-serve forgot-password reset link.
|
|
// Token is placed in the URL fragment so it is not sent on the page GET (Referer/access logs).
|
|
func ResetPasswordURL(webOrigin, token string) string {
|
|
return strings.TrimRight(webOrigin, "/") + "/reset-password#token=" + token
|
|
}
|
|
|
|
// ForgotPasswordMessage is the self-serve reset email (not first-set / accept-invite).
|
|
func ForgotPasswordMessage(webOrigin, email, token string) Message {
|
|
link := ResetPasswordURL(webOrigin, token)
|
|
text := fmt.Sprintf(
|
|
"Reset your Descrybe password:\n\n%s\n\nThis link expires in 1 hour. If you did not request a reset, ignore this email.\n",
|
|
link,
|
|
)
|
|
html := fmt.Sprintf(
|
|
`<p>Reset your Descrybe password:</p><p><a href="%s">Reset password</a></p><p>This link expires in 1 hour. If you did not request a reset, ignore this email.</p>`,
|
|
link,
|
|
)
|
|
return Message{To: email, Subject: "Reset your Descrybe password", Text: text, HTML: html}
|
|
}
|