2026-08-09 22:47:43 +02:00
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 {
2026-08-17 00:39:25 +02:00
link := AcceptInviteURL ( webOrigin , token )
2026-08-09 22:47:43 +02:00
text := fmt . Sprintf ( "You have been invited to %s on Descrybe.\n\nAccept: %s\n" , companyName , link )
html := fmt . Sprintf (
`<p>You have been invited to <strong>%s</strong> on Descrybe.</p><p><a href="%s">Accept invite</a></p>` ,
companyName , link ,
)
return Message { To : email , Subject : "You are invited to Descrybe" , Text : text , HTML : html }
}
2026-08-17 00:39:25 +02:00
// 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
}
2026-08-09 22:47:43 +02:00
// 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 (
`<p>Set your Descrybe password:</p><p><a href="%s">Set password</a></p><p>This link expires in 72 hours.</p>` ,
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 {
2026-08-17 00:39:25 +02:00
link := AcceptInviteURL ( webOrigin , token )
2026-08-09 22:47:43 +02:00
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 (
`<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>` ,
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 (
`<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 }
}