300 lines
8.7 KiB
Go
300 lines
8.7 KiB
Go
// Package ports defines the stable interfaces that connect Mail Service use
|
|
// cases to external delivery infrastructure.
|
|
package ports
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"galaxy/mail/internal/domain/common"
|
|
deliverydomain "galaxy/mail/internal/domain/delivery"
|
|
)
|
|
|
|
// Provider executes one materialized outbound message against a concrete
|
|
// delivery backend such as SMTP or a deterministic local stub.
|
|
type Provider interface {
|
|
// Send attempts one outbound message delivery and returns a classified
|
|
// provider result when the operation reached a stable backend outcome.
|
|
Send(context.Context, Message) (Result, error)
|
|
|
|
// Close releases provider-owned resources. Implementations must allow
|
|
// repeated calls.
|
|
Close() error
|
|
}
|
|
|
|
// Classification identifies the stable provider-level outcome surface frozen
|
|
// for Stage 10.
|
|
type Classification string
|
|
|
|
const (
|
|
// ClassificationAccepted reports that the provider accepted the SMTP
|
|
// envelope after the final DATA exchange.
|
|
ClassificationAccepted Classification = "accepted"
|
|
|
|
// ClassificationSuppressed reports that delivery was intentionally skipped
|
|
// by provider-local policy.
|
|
ClassificationSuppressed Classification = "suppressed"
|
|
|
|
// ClassificationTransientFailure reports that the provider interaction
|
|
// failed in a retryable way.
|
|
ClassificationTransientFailure Classification = "transient_failure"
|
|
|
|
// ClassificationPermanentFailure reports that the provider interaction
|
|
// failed in a terminal non-retryable way.
|
|
ClassificationPermanentFailure Classification = "permanent_failure"
|
|
)
|
|
|
|
// IsKnown reports whether classification belongs to the frozen provider
|
|
// result surface.
|
|
func (classification Classification) IsKnown() bool {
|
|
switch classification {
|
|
case ClassificationAccepted,
|
|
ClassificationSuppressed,
|
|
ClassificationTransientFailure,
|
|
ClassificationPermanentFailure:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Attachment stores one fully decoded outbound attachment together with the
|
|
// durable metadata that remains in the delivery audit.
|
|
type Attachment struct {
|
|
// Metadata stores the attachment audit fields used by the delivery domain.
|
|
Metadata common.AttachmentMetadata
|
|
|
|
// Content stores the decoded attachment payload bytes used for MIME body
|
|
// construction.
|
|
Content []byte
|
|
}
|
|
|
|
// Validate reports whether attachment contains a consistent decoded outbound
|
|
// payload.
|
|
func (attachment Attachment) Validate() error {
|
|
if err := attachment.Metadata.Validate(); err != nil {
|
|
return fmt.Errorf("attachment metadata: %w", err)
|
|
}
|
|
if int64(len(attachment.Content)) != attachment.Metadata.SizeBytes {
|
|
return fmt.Errorf(
|
|
"attachment content length must match size bytes: got %d, want %d",
|
|
len(attachment.Content),
|
|
attachment.Metadata.SizeBytes,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Message stores one fully materialized outbound message ready for provider
|
|
// handoff.
|
|
type Message struct {
|
|
// Envelope stores the SMTP routing information.
|
|
Envelope deliverydomain.Envelope
|
|
|
|
// Content stores the materialized subject and body parts.
|
|
Content deliverydomain.Content
|
|
|
|
// Attachments stores the decoded outbound attachments.
|
|
Attachments []Attachment
|
|
}
|
|
|
|
// Validate reports whether message is ready for provider execution.
|
|
func (message Message) Validate() error {
|
|
if err := message.Envelope.Validate(); err != nil {
|
|
return fmt.Errorf("message envelope: %w", err)
|
|
}
|
|
if err := message.Content.ValidateMaterialized(); err != nil {
|
|
return fmt.Errorf("message content: %w", err)
|
|
}
|
|
for index, attachment := range message.Attachments {
|
|
if err := attachment.Validate(); err != nil {
|
|
return fmt.Errorf("message attachments[%d]: %w", index, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SummaryFields stores the tokenized safe-summary fields allowed in provider
|
|
// audit strings.
|
|
type SummaryFields struct {
|
|
// Provider stores the provider implementation identifier.
|
|
Provider string
|
|
|
|
// Result stores the stable provider classification.
|
|
Result string
|
|
|
|
// Phase stores the optional backend stage that produced the outcome.
|
|
Phase string
|
|
|
|
// SMTPCode stores the optional SMTP response code.
|
|
SMTPCode string
|
|
|
|
// Script stores the optional stub-script outcome label.
|
|
Script string
|
|
}
|
|
|
|
// BuildSafeSummary renders one stable ASCII summary string for provider audit
|
|
// fields.
|
|
func BuildSafeSummary(fields SummaryFields) (string, error) {
|
|
switch {
|
|
case !isSafeSummaryValue(fields.Provider):
|
|
return "", fmt.Errorf("provider summary field provider must be a non-empty ASCII token")
|
|
case !isSafeSummaryValue(fields.Result):
|
|
return "", fmt.Errorf("provider summary field result must be a non-empty ASCII token")
|
|
case fields.Phase != "" && !isSafeSummaryValue(fields.Phase):
|
|
return "", fmt.Errorf("provider summary field phase must be an ASCII token")
|
|
case fields.SMTPCode != "" && !isSafeSummaryValue(fields.SMTPCode):
|
|
return "", fmt.Errorf("provider summary field smtp_code must be an ASCII token")
|
|
case fields.Script != "" && !isSafeSummaryValue(fields.Script):
|
|
return "", fmt.Errorf("provider summary field script must be an ASCII token")
|
|
}
|
|
|
|
parts := []string{
|
|
"provider=" + fields.Provider,
|
|
"result=" + fields.Result,
|
|
}
|
|
if fields.Phase != "" {
|
|
parts = append(parts, "phase="+fields.Phase)
|
|
}
|
|
if fields.SMTPCode != "" {
|
|
parts = append(parts, "smtp_code="+fields.SMTPCode)
|
|
}
|
|
if fields.Script != "" {
|
|
parts = append(parts, "script="+fields.Script)
|
|
}
|
|
|
|
return strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// Result stores the stable provider-layer outcome together with the redacted
|
|
// summary that can be persisted in delivery audit records.
|
|
type Result struct {
|
|
// Classification stores the stable provider result classification.
|
|
Classification Classification
|
|
|
|
// Summary stores the stable persisted provider summary.
|
|
Summary string
|
|
|
|
// Details stores optional in-memory-only provider details for structured
|
|
// logs and diagnostics. Callers must not persist this map directly.
|
|
Details map[string]string
|
|
}
|
|
|
|
// Validate reports whether result contains a supported provider outcome and a
|
|
// valid safe summary.
|
|
func (result Result) Validate() error {
|
|
if !result.Classification.IsKnown() {
|
|
return fmt.Errorf("provider result classification %q is unsupported", result.Classification)
|
|
}
|
|
if err := validateSafeSummary(result.Summary); err != nil {
|
|
return err
|
|
}
|
|
for key, value := range result.Details {
|
|
if !isSafeSummaryValue(key) {
|
|
return fmt.Errorf("provider result detail key %q must be an ASCII token", key)
|
|
}
|
|
if !isSafeDetailValue(value) {
|
|
return fmt.Errorf("provider result detail value for %q must use printable ASCII without line breaks", key)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CloneDetails returns a detached copy of details suitable for in-memory
|
|
// logging.
|
|
func CloneDetails(details map[string]string) map[string]string {
|
|
if details == nil {
|
|
return nil
|
|
}
|
|
|
|
cloned := make(map[string]string, len(details))
|
|
for key, value := range details {
|
|
cloned[key] = value
|
|
}
|
|
|
|
return cloned
|
|
}
|
|
|
|
func validateSafeSummary(summary string) error {
|
|
if strings.TrimSpace(summary) == "" {
|
|
return fmt.Errorf("provider result summary must not be empty")
|
|
}
|
|
if !utf8.ValidString(summary) {
|
|
return fmt.Errorf("provider result summary must be valid UTF-8")
|
|
}
|
|
|
|
tokens := strings.Split(summary, " ")
|
|
if len(tokens) < 2 {
|
|
return fmt.Errorf("provider result summary must contain provider and result tokens")
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(tokens))
|
|
for _, token := range tokens {
|
|
key, value, ok := strings.Cut(token, "=")
|
|
if !ok {
|
|
return fmt.Errorf("provider result summary token %q must use key=value form", token)
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
return fmt.Errorf("provider result summary token %q must not repeat", key)
|
|
}
|
|
seen[key] = struct{}{}
|
|
|
|
if !slices.Contains([]string{"provider", "result", "phase", "smtp_code", "script"}, key) {
|
|
return fmt.Errorf("provider result summary token %q is unsupported", key)
|
|
}
|
|
if !isSafeSummaryValue(value) {
|
|
return fmt.Errorf("provider result summary token %q must use a non-empty ASCII value", key)
|
|
}
|
|
}
|
|
|
|
if _, ok := seen["provider"]; !ok {
|
|
return fmt.Errorf("provider result summary must include provider token")
|
|
}
|
|
if _, ok := seen["result"]; !ok {
|
|
return fmt.Errorf("provider result summary must include result token")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isSafeSummaryValue(value string) bool {
|
|
if strings.TrimSpace(value) == "" || strings.TrimSpace(value) != value {
|
|
return false
|
|
}
|
|
|
|
for _, r := range value {
|
|
if r > utf8.RuneSelf {
|
|
return false
|
|
}
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
case r >= 'A' && r <= 'Z':
|
|
case r >= '0' && r <= '9':
|
|
case r == '.', r == '_', r == '-':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func isSafeDetailValue(value string) bool {
|
|
if strings.TrimSpace(value) != value {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if r > utf8.RuneSelf || r < 0x20 || r == 0x7f {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|