Release v0.1.0-preview.28: transactional account email
verify / verify (push) Successful in 4m29s

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:
2026-09-11 04:12:17 -04:00
parent ebcbbf06f5
commit 494b72fa3b
18 changed files with 3175 additions and 47 deletions
+107
View File
@@ -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
}