// 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 }