feat: mail service
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user