Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
This commit is contained in:
+107
@@ -0,0 +1,107 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user