feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
+299
View File
@@ -0,0 +1,299 @@
// 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
}
+30
View File
@@ -0,0 +1,30 @@
package ports
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestBuildSafeSummaryBuildsStableTokenOrder(t *testing.T) {
t.Parallel()
summary, err := BuildSafeSummary(SummaryFields{
Provider: "smtp",
Result: "transient_failure",
Phase: "data",
SMTPCode: "451",
})
require.NoError(t, err)
require.Equal(t, "provider=smtp result=transient_failure phase=data smtp_code=451", summary)
}
func TestResultValidateRejectsUnsafeSummary(t *testing.T) {
t.Parallel()
result := Result{
Classification: ClassificationAccepted,
Summary: "provider=smtp result=accepted extra=value",
}
require.Error(t, result.Validate())
}