feat: mail service
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// Package common defines shared value objects used across the Mail Service
|
||||
// domain model.
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// DeliveryID identifies one logical mail delivery accepted by Mail Service.
|
||||
type DeliveryID string
|
||||
|
||||
// String returns DeliveryID as its stored identifier string.
|
||||
func (id DeliveryID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether DeliveryID does not contain a usable value.
|
||||
func (id DeliveryID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether DeliveryID is non-empty and already normalized for
|
||||
// domain use.
|
||||
func (id DeliveryID) Validate() error {
|
||||
return validateToken("delivery id", string(id))
|
||||
}
|
||||
|
||||
// TemplateID identifies one template family owned by the filesystem-backed
|
||||
// Mail Service template catalog.
|
||||
type TemplateID string
|
||||
|
||||
// String returns TemplateID as its stored identifier string.
|
||||
func (id TemplateID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether TemplateID does not contain a usable value.
|
||||
func (id TemplateID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether TemplateID is non-empty and already normalized for
|
||||
// domain use.
|
||||
func (id TemplateID) Validate() error {
|
||||
return validateToken("template id", string(id))
|
||||
}
|
||||
|
||||
// IdempotencyKey stores the caller-owned key used to deduplicate accepted
|
||||
// delivery commands.
|
||||
type IdempotencyKey string
|
||||
|
||||
// String returns IdempotencyKey as its stored string.
|
||||
func (key IdempotencyKey) String() string {
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// IsZero reports whether IdempotencyKey does not contain a usable value.
|
||||
func (key IdempotencyKey) IsZero() bool {
|
||||
return strings.TrimSpace(string(key)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether IdempotencyKey is non-empty and already normalized
|
||||
// for domain use.
|
||||
func (key IdempotencyKey) Validate() error {
|
||||
return validateToken("idempotency key", string(key))
|
||||
}
|
||||
|
||||
// Email stores one normalized recipient or reply-to address.
|
||||
type Email string
|
||||
|
||||
// String returns Email as its stored canonical string.
|
||||
func (email Email) String() string {
|
||||
return string(email)
|
||||
}
|
||||
|
||||
// IsZero reports whether Email does not contain a usable address.
|
||||
func (email Email) IsZero() bool {
|
||||
return strings.TrimSpace(string(email)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Email is non-empty, trimmed, and matches the same
|
||||
// single-address syntax expected by the trusted Mail Service contracts.
|
||||
func (email Email) Validate() error {
|
||||
raw := string(email)
|
||||
if err := validateToken("email", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAddress, err := mail.ParseAddress(raw)
|
||||
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
|
||||
return fmt.Errorf("email %q must be a single valid email address", raw)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Locale stores one canonical BCP 47 language tag used by template selection
|
||||
// and rendering.
|
||||
type Locale string
|
||||
|
||||
// ParseLocale validates value as a BCP 47 language tag and returns the
|
||||
// canonical stored representation used by the Mail Service domain model.
|
||||
func ParseLocale(value string) (Locale, error) {
|
||||
if err := validateToken("locale", value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tag, err := language.Parse(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locale %q must be a valid BCP 47 language tag: %w", value, err)
|
||||
}
|
||||
|
||||
return Locale(tag.String()), nil
|
||||
}
|
||||
|
||||
// String returns Locale as its stored canonical string.
|
||||
func (locale Locale) String() string {
|
||||
return string(locale)
|
||||
}
|
||||
|
||||
// IsZero reports whether Locale does not contain a usable value.
|
||||
func (locale Locale) IsZero() bool {
|
||||
return strings.TrimSpace(string(locale)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Locale stores a canonical BCP 47 language tag.
|
||||
func (locale Locale) Validate() error {
|
||||
raw := string(locale)
|
||||
if err := validateToken("locale", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tag, err := language.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("locale %q must be a valid BCP 47 language tag: %w", raw, err)
|
||||
}
|
||||
|
||||
canonical := tag.String()
|
||||
if raw != canonical {
|
||||
return fmt.Errorf("locale %q must use canonical BCP 47 form %q", raw, canonical)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachmentMetadata stores only the durable audit metadata kept for one
|
||||
// accepted attachment. Raw bytes remain outside the long-lived domain model.
|
||||
type AttachmentMetadata struct {
|
||||
// Filename stores the user-facing attachment filename.
|
||||
Filename string
|
||||
|
||||
// ContentType stores the MIME media type used for SMTP body construction.
|
||||
ContentType string
|
||||
|
||||
// SizeBytes stores the decoded payload size in bytes.
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// Validate reports whether AttachmentMetadata contains a complete attachment
|
||||
// audit entry.
|
||||
func (metadata AttachmentMetadata) Validate() error {
|
||||
if err := validateToken("attachment filename", metadata.Filename); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateToken("attachment content type", metadata.ContentType); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := mime.ParseMediaType(metadata.ContentType); err != nil {
|
||||
return fmt.Errorf("attachment content type %q must be a valid MIME media type: %w", metadata.ContentType, err)
|
||||
}
|
||||
if metadata.SizeBytes < 0 {
|
||||
return fmt.Errorf("attachment size bytes must not be negative")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTimestamp reports whether value is present.
|
||||
func ValidateTimestamp(name string, value time.Time) error {
|
||||
if value.IsZero() {
|
||||
return fmt.Errorf("%s must not be zero", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIdentifierValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid delivery id",
|
||||
run: func() error {
|
||||
return DeliveryID("delivery-123").Validate()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid template id",
|
||||
run: func() error {
|
||||
return TemplateID("auth.login_code").Validate()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid idempotency key",
|
||||
run: func() error {
|
||||
return IdempotencyKey("notification:delivery-123").Validate()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty delivery id",
|
||||
run: func() error {
|
||||
return DeliveryID("").Validate()
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "template id with whitespace",
|
||||
run: func() error {
|
||||
return TemplateID(" auth.login_code ").Validate()
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.run()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Email
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: Email("pilot@example.com")},
|
||||
{name: "empty", value: Email(""), wantErr: true},
|
||||
{name: "display name forbidden", value: Email("Pilot <pilot@example.com>"), wantErr: true},
|
||||
{name: "whitespace forbidden", value: Email(" pilot@example.com "), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
value, err := ParseLocale("fr-fr")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Locale("fr-FR"), value)
|
||||
require.NoError(t, value.Validate())
|
||||
}
|
||||
|
||||
func TestLocaleValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Locale
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "canonical language", value: Locale("en")},
|
||||
{name: "canonical regional", value: Locale("fr-FR")},
|
||||
{name: "non canonical", value: Locale("fr-fr"), wantErr: true},
|
||||
{name: "invalid syntax", value: Locale("not a locale"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentMetadataValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value AttachmentMetadata
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
value: AttachmentMetadata{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
SizeBytes: 512,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid content type",
|
||||
value: AttachmentMetadata{
|
||||
Filename: "report.txt",
|
||||
ContentType: "plain text",
|
||||
SizeBytes: 512,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
value: AttachmentMetadata{
|
||||
Filename: "report.txt",
|
||||
ContentType: "text/plain",
|
||||
SizeBytes: -1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user