155 lines
5.4 KiB
Go
155 lines
5.4 KiB
Go
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)
|
|
}
|
|
|
|
if ok, _ := client.Extension("STARTTLS"); 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
|
|
}
|
|
|
|
// 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()
|
|
}
|