package utils import ( "crypto/tls" "fmt" "net/mail" "net/smtp" "strings" "time" "nearle/config" ) // Sending mail. // // ── Why the standard library and not a client ─────────────────────────────── // // `net/smtp` is enough for what this sends: a handful of invitations a day, one // at a time, to addresses a person typed. Every transactional provider speaks // SMTP — SES, SendGrid, Resend, a company relay — so the choice of provider is // a host and a password rather than a dependency and a rewrite. Adding an SDK // would buy templating and analytics that nothing here wants yet, in exchange // for a supply chain. // // ── Nil is a configuration, not a failure ─────────────────────────────────── // // `NewMailer` returns nil when no host is set, matching `NewChat` and // `NewEmbedder`. A deployment without mail still boots, still onboards tenants, // and records the invitation as unsent. The alternative — refusing to start — // would make mail a hard dependency of creating a merchant, which it is not. // Mailer sends one message. // // One method, on purpose. Everything this server sends is a short transactional // note to one recipient; a richer interface would be describing a mail product // nobody asked for. type Mailer interface { Send(to, subject, body string) error } // mailTimeout bounds a send. // // Onboarding waits on this, so it cannot be generous. A relay that has not // answered in ten seconds is not going to, and the invitation is better // recorded as unsent — and resent — than holding a tenant creation open. const mailTimeout = 10 * time.Second type smtpMailer struct { cfg config.MailConfig } // NewMailer builds a sender, or nil when none is configured. func NewMailer(cfg config.MailConfig) (Mailer, error) { if !cfg.Enabled() { return nil, nil } if _, err := mail.ParseAddress(cfg.FromAddress); err != nil { // Caught here rather than at the first send, because a malformed // sender fails every message and should stop the deployment being // described as able to send. return nil, fmt.Errorf("MAIL_FROM is not a valid address: %w", err) } return &smtpMailer{cfg: cfg}, nil } func (m *smtpMailer) Send(to, subject, body string) error { recipient, err := mail.ParseAddress(strings.TrimSpace(to)) if err != nil { // A merchant's primary email is typed by whoever onboarded them, so a // typo here is ordinary. Named clearly, because the fix is to correct // the tenant's record and resend. return fmt.Errorf("%q is not a valid email address", to) } from := mail.Address{Name: m.cfg.FromName, Address: m.cfg.FromAddress} message := buildMessage(from, *recipient, subject, body) client, err := m.dial() if err != nil { return err } defer client.Close() if m.cfg.Username != "" { auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) if err := client.Auth(auth); err != nil { return fmt.Errorf("the mail server refused our credentials: %w", err) } } if err := client.Mail(m.cfg.FromAddress); err != nil { return fmt.Errorf("the mail server refused the sender: %w", err) } if err := client.Rcpt(recipient.Address); err != nil { return fmt.Errorf("the mail server refused %s: %w", recipient.Address, err) } writer, err := client.Data() if err != nil { return err } if _, err := writer.Write([]byte(message)); err != nil { return err } if err := writer.Close(); err != nil { return err } return client.Quit() } // dial opens a connection, upgrading to TLS where the server offers it. // // STARTTLS rather than implicit TLS, because 587 is the submission port every // provider documents and it begins in the clear. The upgrade is attempted // whenever the server advertises it and the connection is abandoned if it fails // — an invitation is a credential, and sending one over plaintext to a server // that offered encryption would be choosing not to use it. func (m *smtpMailer) dial() (*smtp.Client, error) { client, err := smtp.Dial(m.cfg.Address()) if err != nil { return nil, fmt.Errorf("could not reach the mail server at %s: %w", m.cfg.Address(), err) } ok, _ := client.Extension("STARTTLS") if ok { if err := client.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil { client.Close() return nil, fmt.Errorf("the mail server offered TLS and then refused it: %w", err) } return client, nil } // No TLS on offer, and we are about to send a password. // // Refused rather than continued. `smtp.PlainAuth` would decline to hand over // credentials on a plaintext connection anyway — so nothing leaks either way // — but it reports that as the server refusing our credentials, which sends // somebody to check the password when the problem is the connection. // // It also closes a downgrade: an attacker between us and the relay can strip // the STARTTLS advertisement from the greeting, and "carry on unencrypted" // is the wrong answer to that. // // A relay that authenticates by network rather than by credentials has no // username set, and is left alone: those are usually a local MTA on the same // host, where there is no wire to protect. if m.cfg.Username != "" { client.Close() return nil, fmt.Errorf( "%s does not offer TLS, and MAIL_PASSWORD would have to cross the wire in clear", m.cfg.Address()) } return client, nil } // buildMessage assembles the wire format. // // Headers then a blank line then the body, with CRLF line endings — SMTP is // specified in terms of CRLF and some servers reject bare newlines, which // presents as mail that works locally and vanishes in production. func buildMessage(from, to mail.Address, subject, body string) string { var b strings.Builder b.WriteString("From: " + from.String() + "\r\n") b.WriteString("To: " + to.String() + "\r\n") // Folded and encoded by `mail.Address`'s rules for the addresses; the // subject is plain ASCII by construction in this codebase, so it needs no // MIME word encoding. If a subject ever carries a merchant's name, that // changes and this needs `mime.QEncoding`. b.WriteString("Subject: " + strings.ReplaceAll(subject, "\n", " ") + "\r\n") b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") b.WriteString("\r\n") b.WriteString(strings.ReplaceAll(body, "\n", "\r\n")) return b.String() }