228 lines
9.4 KiB
Go
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")
|
|
}
|