package mail import ( "fmt" "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) html := fmt.Sprintf( `

You have been invited to %s on Descrybe.

Accept invite

`, companyName, link, ) return Message{To: email, Subject: "You are invited to Descrybe", Text: text, HTML: html} } // AcceptInviteURL builds the durable invite / set-password accept link (hashed invite tokens). func AcceptInviteURL(webOrigin, token string) string { return strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token } // SetPasswordURL builds the HMAC set-password accept-invite link. 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) html := fmt.Sprintf( `

Set your Descrybe password:

Set password

This link expires in 72 hours.

`, link, ) return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: html} } // 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, ) html := fmt.Sprintf( `

Your Descrybe account was migrated.

Set your password

If you did not expect this email, ignore it.

`, link, ) return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: html} } // 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( `

Reset your Descrybe password:

Reset password

This link expires in 1 hour. If you did not request a reset, ignore this email.

`, link, ) return Message{To: email, Subject: "Reset your Descrybe password", Text: text, HTML: html} }