// SPDX-License-Identifier: MPL-2.0 // Package mail provides bounded, single-recipient transactional messages. // Applications own authorization, templates, trusted link origins and consent. package mail import ( "bytes" "context" "errors" "fmt" "mime" "mime/quotedprintable" stdmail "net/mail" "regexp" "strings" "time" "unicode" "unicode/utf8" ) const MaxTextBytes = 64 << 10 var ErrInvalid = errors.New("mail: invalid message or configuration") var messageIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{15,63}$`) // Message has no caller-controlled From, arbitrary headers or recipient list. // ID and CreatedAt stay stable across delivery retries. Treat Text as sensitive: // never log it, and encrypt it when persisted (it may contain a recovery link). type Message struct { ID, To, Subject, Text string CreatedAt time.Time } // String and GoString avoid accidental plaintext recovery-link logging during // debugging. Structured serializers still require the same care as any secret. func (Message) String() string { return "mail.Message{content:redacted}" } func (message Message) GoString() string { return message.String() } type Transport interface { // Send returns nil after the SMTP server accepts DATA, not after inbox delivery. // Implementations must honor context cancellation and deadlines. The outbox // relies on this bound to finish transmission before its worker lease expires. Send(context.Context, Message) error } func (message Message) Validate() error { if !messageIDPattern.MatchString(message.ID) || !validMailbox(message.To) || !headerText(message.Subject, 256) || message.CreatedAt.Year() < 1970 || message.CreatedAt.Year() > 9999 || len(message.Text) == 0 || len(message.Text) > MaxTextBytes || !utf8.ValidString(message.Text) { return ErrInvalid } for _, r := range message.Text { if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' { return ErrInvalid } } return nil } func headerText(value string, maximum int) bool { if strings.TrimSpace(value) == "" || len(value) > maximum || !utf8.ValidString(value) { return false } for _, r := range value { if unicode.IsControl(r) { return false } } return true } // This transport uses ASCII envelope addresses, not SMTPUTF8. Display names and // subjects may be UTF-8; MIME encodes them. The application owns normalization. func validMailbox(value string) bool { if len(value) > 254 || !headerText(value, 254) { return false } for _, r := range value { if r > 127 { return false } } parsed, err := stdmail.ParseAddress(value) return err == nil && parsed.Name == "" && parsed.Address == value && strings.Contains(value, "@") } func encode(message Message, from *stdmail.Address) ([]byte, error) { if err := message.Validate(); err != nil { return nil, err } var buffer bytes.Buffer domain := from.Address[strings.LastIndexByte(from.Address, '@')+1:] fmt.Fprintf(&buffer, "From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%s@%s>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", from.String(), (&stdmail.Address{Address: message.To}).String(), mime.QEncoding.Encode("UTF-8", message.Subject), message.CreatedAt.UTC().Format(time.RFC1123Z), message.ID, domain) body := strings.ReplaceAll(strings.ReplaceAll(message.Text, "\r\n", "\n"), "\r", "\n") writer := quotedprintable.NewWriter(&buffer) if _, err := writer.Write([]byte(body)); err != nil { return nil, err } if err := writer.Close(); err != nil { return nil, err } if !bytes.HasSuffix(buffer.Bytes(), []byte("\r\n")) { buffer.WriteString("\r\n") } return buffer.Bytes(), nil }