Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrNotConfigured is returned when a send is required but SMTP is not
|
||||
// admin-configured (and no env fallback is available).
|
||||
var ErrNotConfigured = errors.New("mail: SMTP is not configured; set platform mail settings in admin")
|
||||
|
||||
// ResolveFunc loads current SMTP config (typically from platformsettings).
|
||||
// Callers must not log the returned password.
|
||||
type ResolveFunc func() (Config, error)
|
||||
|
||||
// NewDynamic returns a Mailer that resolves SMTP config on each Send/Enabled.
|
||||
// Prefer this over New(cfg) so admin dashboard changes apply without restart.
|
||||
// When disabled or host empty, Send matches the historical no-op (log + nil)
|
||||
// so invite re-issue can still mint tokens; callers that need hard failure
|
||||
// should check Enabled() or use RequireConfigured.
|
||||
func NewDynamic(resolve ResolveFunc) Mailer {
|
||||
if resolve == nil {
|
||||
return &noopMailer{}
|
||||
}
|
||||
return &dynamicMailer{resolve: resolve}
|
||||
}
|
||||
|
||||
type dynamicMailer struct {
|
||||
resolve ResolveFunc
|
||||
}
|
||||
|
||||
func (d *dynamicMailer) load() (Config, error) {
|
||||
cfg, err := d.resolve()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Port) == "" {
|
||||
cfg.Port = "587"
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (d *dynamicMailer) Enabled() bool {
|
||||
cfg, err := d.load()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return cfg.Enabled && strings.TrimSpace(cfg.Host) != ""
|
||||
}
|
||||
|
||||
func (d *dynamicMailer) Send(msg Message) error {
|
||||
cfg, err := d.load()
|
||||
if err != nil {
|
||||
log.Printf("mail: config resolve failed subject=%q", msg.Subject)
|
||||
return fmt.Errorf("%w: %v", ErrNotConfigured, err)
|
||||
}
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.Host) == "" {
|
||||
return (&noopMailer{}).Send(msg)
|
||||
}
|
||||
return (&smtpMailer{cfg: cfg}).Send(msg)
|
||||
}
|
||||
|
||||
// RequireConfigured wraps a Mailer so Send fails clearly when delivery is off.
|
||||
func RequireConfigured(inner Mailer) Mailer {
|
||||
if inner == nil {
|
||||
return &requireConfiguredMailer{inner: &noopMailer{}}
|
||||
}
|
||||
return &requireConfiguredMailer{inner: inner}
|
||||
}
|
||||
|
||||
type requireConfiguredMailer struct {
|
||||
inner Mailer
|
||||
}
|
||||
|
||||
func (r *requireConfiguredMailer) Enabled() bool { return r.inner.Enabled() }
|
||||
|
||||
func (r *requireConfiguredMailer) Send(msg Message) error {
|
||||
if !r.inner.Enabled() {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
return r.inner.Send(msg)
|
||||
}
|
||||
|
||||
// ConfigFromParts builds a Config from discrete fields (platformsettings bridge).
|
||||
func ConfigFromParts(enabled bool, host, port, user, password, from string) Config {
|
||||
if strings.TrimSpace(port) == "" {
|
||||
port = "587"
|
||||
}
|
||||
return Config{
|
||||
Enabled: enabled,
|
||||
Host: strings.TrimSpace(host),
|
||||
Port: strings.TrimSpace(port),
|
||||
User: strings.TrimSpace(user),
|
||||
Password: password,
|
||||
From: strings.TrimSpace(from),
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyDryRun forces Enabled=false when dry-run is on so New/NewDynamic use the
|
||||
// noop path (log subject only). Host/from are preserved for diagnostics.
|
||||
func ApplyDryRun(dryRun bool, cfg Config) Config {
|
||||
if dryRun {
|
||||
cfg.Enabled = false
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewDynamicResolvesOnEachCall(t *testing.T) {
|
||||
calls := 0
|
||||
m := NewDynamic(func() (Config, error) {
|
||||
calls++
|
||||
return Config{
|
||||
Enabled: true,
|
||||
Host: "smtp.example.com",
|
||||
Port: "587",
|
||||
From: "noreply@example.com",
|
||||
}, nil
|
||||
})
|
||||
if !m.Enabled() {
|
||||
t.Fatal("expected enabled")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("calls=%d", calls)
|
||||
}
|
||||
|
||||
prev := smtpSendMail
|
||||
smtpSendMail = func(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { smtpSendMail = prev })
|
||||
|
||||
if err := m.Send(Message{To: "a@example.com", Subject: "hi", Text: "body"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("expected second resolve on Send, calls=%d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDynamicDisabledIsNoop(t *testing.T) {
|
||||
m := NewDynamic(func() (Config, error) {
|
||||
return Config{Enabled: false}, nil
|
||||
})
|
||||
if m.Enabled() {
|
||||
t.Fatal("expected disabled")
|
||||
}
|
||||
if err := m.Send(Message{To: "a@b.c", Subject: "x", Text: "y"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireConfiguredErrorsWhenOff(t *testing.T) {
|
||||
m := RequireConfigured(NewDynamic(func() (Config, error) {
|
||||
return Config{}, nil
|
||||
}))
|
||||
err := m.Send(Message{To: "a@b.c", Subject: "x", Text: "y"})
|
||||
if !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromPartsDefaultPort(t *testing.T) {
|
||||
cfg := ConfigFromParts(true, "h", "", "u", "p", "f@x")
|
||||
if cfg.Port != "587" {
|
||||
t.Fatalf("port=%q", cfg.Port)
|
||||
}
|
||||
if !cfg.Enabled || cfg.Host != "h" || !strings.Contains(cfg.From, "@") {
|
||||
t.Fatalf("%+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDryRunDisablesSend(t *testing.T) {
|
||||
live := ConfigFromParts(true, "smtp.example.com", "587", "u", "p", "from@example.com")
|
||||
dry := ApplyDryRun(true, live)
|
||||
if dry.Enabled {
|
||||
t.Fatal("dry-run must force Enabled=false")
|
||||
}
|
||||
if dry.Host != "smtp.example.com" {
|
||||
t.Fatalf("host should be preserved, got %q", dry.Host)
|
||||
}
|
||||
if ApplyDryRun(false, live).Enabled != true {
|
||||
t.Fatal("dry-run=false must leave Enabled intact")
|
||||
}
|
||||
m := New(dry)
|
||||
if m.Enabled() {
|
||||
t.Fatal("New(ApplyDryRun(...)) must be noop")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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 := strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token
|
||||
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}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
link := strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + 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(
|
||||
`<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}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSMTPMailerSendBuildsHeadersForValidInput(t *testing.T) {
|
||||
mailer := &smtpMailer{cfg: Config{
|
||||
Host: "smtp.example.com",
|
||||
Port: "587",
|
||||
From: "sender@example.com",
|
||||
}}
|
||||
|
||||
var captured string
|
||||
called := false
|
||||
prev := smtpSendMail
|
||||
smtpSendMail = func(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
called = true
|
||||
if addr != "smtp.example.com:587" {
|
||||
t.Fatalf("addr=%q", addr)
|
||||
}
|
||||
if from != "sender@example.com" {
|
||||
t.Fatalf("from=%q", from)
|
||||
}
|
||||
if len(to) != 1 || to[0] != "recipient@example.com" {
|
||||
t.Fatalf("to=%v", to)
|
||||
}
|
||||
captured = string(msg)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { smtpSendMail = prev })
|
||||
|
||||
err := mailer.Send(Message{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Hello there",
|
||||
Text: "plain body",
|
||||
HTML: "<p>html body</p>",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("expected smtpSendMail to be called")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"From: sender@example.com",
|
||||
"To: recipient@example.com",
|
||||
"Subject: Hello there",
|
||||
} {
|
||||
if !strings.Contains(captured, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, captured)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPMailerSendRejectsHeaderInjection(t *testing.T) {
|
||||
cases := []Message{
|
||||
{To: "recipient@example.com", Subject: "ok\r\nBcc:evil@example.com", Text: "body"},
|
||||
{To: "recipient@example.com\r\nBcc:evil@example.com", Subject: "ok", Text: "body"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
mailer := &smtpMailer{cfg: Config{
|
||||
Host: "smtp.example.com",
|
||||
Port: "587",
|
||||
From: "sender@example.com",
|
||||
}}
|
||||
|
||||
called := false
|
||||
prev := smtpSendMail
|
||||
smtpSendMail = func(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
called = true
|
||||
return nil
|
||||
}
|
||||
|
||||
err := mailer.Send(tc)
|
||||
smtpSendMail = prev
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %#v", tc)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("smtpSendMail should not be called for %#v", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNoopWhenDisabledOrHostEmpty(t *testing.T) {
|
||||
if New(Config{Enabled: false, Host: "smtp.example.com"}).Enabled() {
|
||||
t.Fatal("disabled mailer must report Enabled=false")
|
||||
}
|
||||
if New(Config{Enabled: true, Host: ""}).Enabled() {
|
||||
t.Fatal("empty host must be noop")
|
||||
}
|
||||
if !New(Config{Enabled: true, Host: "smtp.example.com", From: "a@b.c"}).Enabled() {
|
||||
t.Fatal("enabled+host must be live SMTP mailer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDynamicResolvesPerCall(t *testing.T) {
|
||||
calls := 0
|
||||
host := "smtp-a.example.com"
|
||||
m := NewDynamic(func() (Config, error) {
|
||||
calls++
|
||||
return ConfigFromParts(true, host, "587", "u", "p", "from@example.com"), nil
|
||||
})
|
||||
if !m.Enabled() {
|
||||
t.Fatal("expected enabled")
|
||||
}
|
||||
host = "smtp-b.example.com"
|
||||
if !m.Enabled() {
|
||||
t.Fatal("expected still enabled after host change")
|
||||
}
|
||||
if calls < 2 {
|
||||
t.Fatalf("expected resolve per Enabled call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordURL(t *testing.T) {
|
||||
got := SetPasswordURL("http://localhost:5174/", "tok123")
|
||||
want := "http://localhost:5174/accept-invite?token=tok123&mode=set-password"
|
||||
if got != want {
|
||||
t.Fatalf("SetPasswordURL=%q want %q", got, want)
|
||||
}
|
||||
msg := SetPasswordMessage("http://localhost:5174", "u@example.com", "tok123")
|
||||
if !strings.Contains(msg.Text, want) && !strings.Contains(msg.Text, "token=tok123&mode=set-password") {
|
||||
t.Fatalf("SetPasswordMessage text missing link: %q", msg.Text)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user