Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/smtp.go
T

228 lines
9.4 KiB
Go

package main
// How mail_send sends, once the operator approved it (novox/hq ADR 0361 §1): through the account's outgoing
// server, implicit TLS from the first byte on 465 or upgraded with STARTTLS on 587, as the account's outgoing
// `security` says — the same two the module already reads for an account's incoming server. The password is the
// own secret mail-credential-<account>, the one IMAP already signs in with; it is read once, used once, and never
// logged, and neither is the body.
import (
"crypto/tls"
"errors"
"fmt"
"mime"
"net/mail"
"net/smtp"
"net/textproto"
"strings"
"time"
"unicode"
)
// SMTPDialer opens a connection to an outgoing server, signed in over nothing: implicit TLS on the first byte, or
// plain and then upgraded with STARTTLS, as the server's security says. A test gives a fake dialing a server of its
// own; DialSMTP is the mesh's.
type SMTPDialer func(server Server) (*smtp.Client, error)
// DialSMTP is the dialer for the mesh (novox/hq ADR 0361 §1).
func DialSMTP(server Server) (*smtp.Client, error) {
switch server.Security {
case "tls":
conn, err := tls.Dial("tcp", server.Address(), &tls.Config{ServerName: server.Host, MinVersion: tls.VersionTLS12})
if err != nil {
return nil, fmt.Errorf("the outgoing server %s cannot be reached over TLS: %w", server.Address(), err)
}
c, err := smtp.NewClient(conn, server.Host)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("the outgoing server %s did not answer as SMTP: %w", server.Address(), err)
}
return c, nil
case "starttls":
c, err := smtp.Dial(server.Address())
if err != nil {
return nil, fmt.Errorf("the outgoing server %s cannot be reached: %w", server.Address(), err)
}
if ok, _ := c.Extension("STARTTLS"); !ok {
_ = c.Close()
return nil, fmt.Errorf("the outgoing server %s does not offer STARTTLS", server.Address())
}
if err := c.StartTLS(&tls.Config{ServerName: server.Host, MinVersion: tls.VersionTLS12}); err != nil {
_ = c.Close()
return nil, fmt.Errorf("STARTTLS to %s failed: %w", server.Address(), err)
}
return c, nil
default:
return nil, fmt.Errorf("the outgoing security %q sends over neither tls nor starttls", server.Security)
}
}
// SendOutcome classifies how a send attempt ended (novox/hq ADR 0436 clause 10), so the sender knows
// whether a retry is safe, certainly wrong, or neither:
//
// - NotTaken: no connection, the sign-in refused, or a 4xx reply to MAIL, RCPT or DATA, or to the end of
// data — the server provably never took the mail; retrying is safe.
// - Refused: a 5xx reply before or at the end of data — the server provably refused the mail; never
// retried.
// - NotProven: the connection was lost after the data was written, with no reply at all — the mail may or
// may not have gone out; never retried automatically either way.
type SendOutcome int
const (
NotTaken SendOutcome = iota
Refused
NotProven
)
func (o SendOutcome) String() string {
switch o {
case Refused:
return "refused"
case NotProven:
return "not proven"
default:
return "not taken"
}
}
// SendError classifies a SendMail failure (novox/hq ADR 0436 clause 10): Outcome says which, and the error
// itself (Unwrap) carries the server's own words where there were any, never the password or the body.
type SendError struct {
Outcome SendOutcome
Err error
}
func (e *SendError) Error() string { return e.Err.Error() }
func (e *SendError) Unwrap() error { return e.Err }
// classifyReply is NotTaken for a 4xx reply, Refused for a 5xx, and NotTaken for anything else (no
// connection, or the connection lost before a reply was read at all) — the safe default everywhere but the
// one case clause 10 names its own outcome for: the connection lost after the data was written, which the
// caller classifies as NotProven itself, never through this.
func classifyReply(err error) SendOutcome {
var proto *textproto.Error
if errors.As(err, &proto) && proto.Code >= 500 {
return Refused
}
return NotTaken
}
// SendMail sends one message through the account's outgoing server (novox/hq ADR 0361 §1, §2; ADR 0436
// clause 10): signs in with the password given, states every recipient, and writes the message once, its
// Message-ID naming sendID so the mail is never sent twice within the day (clause 10's own guarantee, kept
// by the sender's compare-and-set claim and the bus's duplicate window on the id alone — this is the
// mail's own proof of it). Never logs the password or the body; an error names the server and what it
// said, never either, and is always a *SendError (classifyReply, or NotProven below).
func SendMail(dial SMTPDialer, a Account, password string, to []string, subject, body string, now time.Time, sendID string) error {
if a.Outgoing == nil {
return fmt.Errorf("the account %s has no outgoing server", a.Name)
}
// Rcpt and the To header take the parsed address, never the raw string a caller gave: the address alone for
// Rcpt, the formatted address for the header (novox/hq issue 790, review round 1; no header injection).
rcpts := make([]string, 0, len(to))
headers := make([]string, 0, len(to))
for _, raw := range to {
addr, err := mail.ParseAddress(raw)
if err != nil {
return fmt.Errorf("the recipient %q is not a mail address: %w", raw, err)
}
rcpts = append(rcpts, addr.Address)
headers = append(headers, addr.String())
}
client, err := dial(*a.Outgoing)
if err != nil {
return &SendError{Outcome: NotTaken, Err: err}
}
defer client.Close()
from := a.SignInAs()
auth := smtp.PlainAuth("", from, password, a.Outgoing.Host)
if err := client.Auth(auth); err != nil {
// A refused sign-in is its own case, never classified by a reply code: the server never took the
// mail either way (novox/hq ADR 0436 clause 10).
return &SendError{Outcome: NotTaken, Err: fmt.Errorf("signing in to %s failed: %w", a.Outgoing.Address(), err)}
}
if err := client.Mail(from); err != nil {
return &SendError{Outcome: classifyReply(err), Err: fmt.Errorf("the outgoing server refused the sender: %w", err)}
}
for _, rcpt := range rcpts {
if err := client.Rcpt(rcpt); err != nil {
return &SendError{Outcome: classifyReply(err), Err: fmt.Errorf("the outgoing server refused the recipient %s: %w", rcpt, err)}
}
}
w, err := client.Data()
if err != nil {
return &SendError{Outcome: classifyReply(err), Err: fmt.Errorf("the outgoing server refused the message: %w", err)}
}
if _, err := w.Write(composeMessage(from, headers, subject, body, now, sendID)); err != nil {
// The data was not written whole: never "after the data was written" (clause 10's not-proven case),
// so this is not taken, not not-proven — nothing coherent reached the server.
return &SendError{Outcome: NotTaken, Err: fmt.Errorf("the message could not be written: %w", err)}
}
if err := w.Close(); err != nil {
// Close is what sends the terminating "." and reads the server's one reply to the whole message
// (SMTP's "end of data", clause 10): a real reply here classifies exactly as MAIL or RCPT's does; no
// reply at all (the connection lost after the data was written) is not proven — never retried
// automatically either way.
outcome := NotProven
var proto *textproto.Error
if errors.As(err, &proto) {
outcome = classifyReply(err)
}
return &SendError{Outcome: outcome, Err: fmt.Errorf("the outgoing server did not accept the message: %w", err)}
}
// The message is already accepted (Close's 250 reply); Quit is a courtesy goodbye, and its own failure
// here never turns an accepted send into a failed one.
_ = client.Quit()
return nil
}
// composeMessage is a plain-text message, its lines CRLF-terminated as SMTP takes them. to is the recipients
// already formatted (mail.Address.String()), the same ones Rcpt was given, never the raw strings a caller gave.
// Its Message-ID names sendID (novox/hq ADR 0436 clause 10), so the mail carries the proof of which send it
// is, at <sendID>@<the account's own domain, from the From address>.
func composeMessage(from string, to []string, subject, body string, now time.Time, sendID string) []byte {
var b strings.Builder
header := func(k, v string) { b.WriteString(k + ": " + v + "\r\n") }
header("From", from)
header("To", strings.Join(to, ", "))
header("Subject", encodeHeader(subject))
header("Date", now.Format(time.RFC1123Z))
header("Message-Id", "<"+sendID+"@"+messageIDDomain(from)+">")
header("MIME-Version", "1.0")
header("Content-Type", "text/plain; charset=utf-8")
header("Content-Transfer-Encoding", "8bit")
b.WriteString("\r\n")
b.WriteString(crlf(body))
return []byte(b.String())
}
// messageIDDomain is the domain a Message-ID is minted under: the part of from after its @, as a bare
// address or parsed out of "Name <addr>"; "localhost" when from carries none (never empty, which is not a
// valid Message-ID).
func messageIDDomain(from string) string {
addr := from
if parsed, err := mail.ParseAddress(from); err == nil {
addr = parsed.Address
}
if _, domain, found := strings.Cut(addr, "@"); found && domain != "" {
return domain
}
return "localhost"
}
// encodeHeader word-encodes a header's value when it holds more than ASCII; a header a phone never needs to show.
func encodeHeader(s string) string {
for _, r := range s {
if r > unicode.MaxASCII {
return mime.QEncoding.Encode("UTF-8", s)
}
}
return s
}
// crlf gives every line break in s a CR, once, the ending SMTP's DATA takes.
func crlf(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.ReplaceAll(s, "\n", "\r\n")
}