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
+697
View File
@@ -0,0 +1,697 @@
package redisstate
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/domain/malformedcommand"
"galaxy/mail/internal/service/acceptgenericdelivery"
)
type deliveryRecord struct {
DeliveryID string `json:"delivery_id"`
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
Source deliverydomain.Source `json:"source"`
PayloadMode deliverydomain.PayloadMode `json:"payload_mode"`
TemplateID string `json:"template_id,omitempty"`
TemplateVariables *map[string]any `json:"template_variables,omitempty"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
ReplyTo []string `json:"reply_to"`
Subject string `json:"subject,omitempty"`
TextBody string `json:"text_body,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
Attachments []attachmentRecord `json:"attachments"`
Locale string `json:"locale,omitempty"`
LocaleFallbackUsed bool `json:"locale_fallback_used"`
IdempotencyKey string `json:"idempotency_key"`
Status deliverydomain.Status `json:"status"`
AttemptCount int `json:"attempt_count"`
LastAttemptStatus attempt.Status `json:"last_attempt_status,omitempty"`
ProviderSummary string `json:"provider_summary,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
}
type attemptRecord struct {
DeliveryID string `json:"delivery_id"`
AttemptNo int `json:"attempt_no"`
ScheduledForMS int64 `json:"scheduled_for_ms"`
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
Status attempt.Status `json:"status"`
ProviderClassification string `json:"provider_classification,omitempty"`
ProviderSummary string `json:"provider_summary,omitempty"`
}
type idempotencyRecord struct {
Source deliverydomain.Source `json:"source"`
IdempotencyKey string `json:"idempotency_key"`
DeliveryID string `json:"delivery_id"`
RequestFingerprint string `json:"request_fingerprint"`
CreatedAtMS int64 `json:"created_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
}
type deadLetterRecord struct {
DeliveryID string `json:"delivery_id"`
FinalAttemptNo int `json:"final_attempt_no"`
FailureClassification string `json:"failure_classification"`
ProviderSummary string `json:"provider_summary,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
RecoveryHint string `json:"recovery_hint,omitempty"`
}
type deliveryPayloadRecord struct {
DeliveryID string `json:"delivery_id"`
Attachments []deliveryPayloadAttachmentRecord `json:"attachments"`
}
type deliveryPayloadAttachmentRecord struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ContentBase64 string `json:"content_base64"`
SizeBytes int64 `json:"size_bytes"`
}
type malformedCommandRecord struct {
StreamEntryID string `json:"stream_entry_id"`
DeliveryID string `json:"delivery_id,omitempty"`
Source string `json:"source,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
FailureCode malformedcommand.FailureCode `json:"failure_code"`
FailureMessage string `json:"failure_message"`
RawFieldsJSON map[string]any `json:"raw_fields_json"`
RecordedAtMS int64 `json:"recorded_at_ms"`
}
type streamOffsetRecord struct {
Stream string `json:"stream"`
LastProcessedEntryID string `json:"last_processed_entry_id"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// StreamOffset stores the persisted progress of one plain-XREAD consumer.
type StreamOffset struct {
// Stream stores the Redis Stream name.
Stream string
// LastProcessedEntryID stores the last durably processed entry id.
LastProcessedEntryID string
// UpdatedAt stores when the offset was updated.
UpdatedAt time.Time
}
// Validate reports whether offset contains a complete persisted progress
// record.
func (offset StreamOffset) Validate() error {
if strings.TrimSpace(offset.Stream) == "" {
return fmt.Errorf("stream offset stream must not be empty")
}
if strings.TrimSpace(offset.LastProcessedEntryID) == "" {
return fmt.Errorf("stream offset last processed entry id must not be empty")
}
if err := common.ValidateTimestamp("stream offset updated at", offset.UpdatedAt); err != nil {
return err
}
return nil
}
type attachmentRecord struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
// MarshalDelivery encodes record into the strict Redis JSON shape used for
// mail_delivery records.
func MarshalDelivery(record deliverydomain.Delivery) ([]byte, error) {
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis delivery record: %w", err)
}
stored := deliveryRecord{
DeliveryID: record.DeliveryID.String(),
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
Source: record.Source,
PayloadMode: record.PayloadMode,
TemplateID: record.TemplateID.String(),
TemplateVariables: optionalJSONObject(record.TemplateVariables),
To: cloneEmailStrings(record.Envelope.To),
Cc: cloneEmailStrings(record.Envelope.Cc),
Bcc: cloneEmailStrings(record.Envelope.Bcc),
ReplyTo: cloneEmailStrings(record.Envelope.ReplyTo),
Subject: record.Content.Subject,
TextBody: record.Content.TextBody,
HTMLBody: record.Content.HTMLBody,
Attachments: cloneAttachments(record.Attachments),
Locale: record.Locale.String(),
LocaleFallbackUsed: record.LocaleFallbackUsed,
IdempotencyKey: record.IdempotencyKey.String(),
Status: record.Status,
AttemptCount: record.AttemptCount,
LastAttemptStatus: record.LastAttemptStatus,
ProviderSummary: record.ProviderSummary,
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
SentAtMS: optionalUnixMilli(record.SentAt),
SuppressedAtMS: optionalUnixMilli(record.SuppressedAt),
FailedAtMS: optionalUnixMilli(record.FailedAt),
DeadLetteredAtMS: optionalUnixMilli(record.DeadLetteredAt),
}
payload, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis delivery record: %w", err)
}
return payload, nil
}
// UnmarshalDelivery decodes payload from the strict Redis JSON shape used for
// mail_delivery records.
func UnmarshalDelivery(payload []byte) (deliverydomain.Delivery, error) {
var stored deliveryRecord
if err := decodeStrictJSON("decode redis delivery record", payload, &stored); err != nil {
return deliverydomain.Delivery{}, err
}
record := deliverydomain.Delivery{
DeliveryID: common.DeliveryID(stored.DeliveryID),
ResendParentDeliveryID: common.DeliveryID(stored.ResendParentDeliveryID),
Source: stored.Source,
PayloadMode: stored.PayloadMode,
TemplateID: common.TemplateID(stored.TemplateID),
TemplateVariables: cloneJSONObjectPtr(stored.TemplateVariables),
Envelope: deliverydomain.Envelope{
To: cloneEmails(stored.To),
Cc: cloneEmails(stored.Cc),
Bcc: cloneEmails(stored.Bcc),
ReplyTo: cloneEmails(stored.ReplyTo),
},
Content: deliverydomain.Content{
Subject: stored.Subject,
TextBody: stored.TextBody,
HTMLBody: stored.HTMLBody,
},
Attachments: inflateAttachments(stored.Attachments),
Locale: common.Locale(stored.Locale),
LocaleFallbackUsed: stored.LocaleFallbackUsed,
IdempotencyKey: common.IdempotencyKey(stored.IdempotencyKey),
Status: stored.Status,
AttemptCount: stored.AttemptCount,
LastAttemptStatus: stored.LastAttemptStatus,
ProviderSummary: stored.ProviderSummary,
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
SentAt: inflateOptionalTime(stored.SentAtMS),
SuppressedAt: inflateOptionalTime(stored.SuppressedAtMS),
FailedAt: inflateOptionalTime(stored.FailedAtMS),
DeadLetteredAt: inflateOptionalTime(stored.DeadLetteredAtMS),
}
if err := record.Validate(); err != nil {
return deliverydomain.Delivery{}, fmt.Errorf("decode redis delivery record: %w", err)
}
return record, nil
}
// MarshalAttempt encodes record into the strict Redis JSON shape used for
// mail_attempt records.
func MarshalAttempt(record attempt.Attempt) ([]byte, error) {
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis attempt record: %w", err)
}
stored := attemptRecord{
DeliveryID: record.DeliveryID.String(),
AttemptNo: record.AttemptNo,
ScheduledForMS: record.ScheduledFor.UTC().UnixMilli(),
StartedAtMS: optionalUnixMilli(record.StartedAt),
FinishedAtMS: optionalUnixMilli(record.FinishedAt),
Status: record.Status,
ProviderClassification: record.ProviderClassification,
ProviderSummary: record.ProviderSummary,
}
payload, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis attempt record: %w", err)
}
return payload, nil
}
// UnmarshalAttempt decodes payload from the strict Redis JSON shape used for
// mail_attempt records.
func UnmarshalAttempt(payload []byte) (attempt.Attempt, error) {
var stored attemptRecord
if err := decodeStrictJSON("decode redis attempt record", payload, &stored); err != nil {
return attempt.Attempt{}, err
}
record := attempt.Attempt{
DeliveryID: common.DeliveryID(stored.DeliveryID),
AttemptNo: stored.AttemptNo,
ScheduledFor: time.UnixMilli(stored.ScheduledForMS).UTC(),
StartedAt: inflateOptionalTime(stored.StartedAtMS),
FinishedAt: inflateOptionalTime(stored.FinishedAtMS),
Status: stored.Status,
ProviderClassification: stored.ProviderClassification,
ProviderSummary: stored.ProviderSummary,
}
if err := record.Validate(); err != nil {
return attempt.Attempt{}, fmt.Errorf("decode redis attempt record: %w", err)
}
return record, nil
}
// MarshalIdempotency encodes record into the strict Redis JSON shape used for
// mail_idempotency_record values.
func MarshalIdempotency(record idempotency.Record) ([]byte, error) {
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis idempotency record: %w", err)
}
stored := idempotencyRecord{
Source: record.Source,
IdempotencyKey: record.IdempotencyKey.String(),
DeliveryID: record.DeliveryID.String(),
RequestFingerprint: record.RequestFingerprint,
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
ExpiresAtMS: record.ExpiresAt.UTC().UnixMilli(),
}
payload, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis idempotency record: %w", err)
}
return payload, nil
}
// UnmarshalIdempotency decodes payload from the strict Redis JSON shape used
// for mail_idempotency_record values.
func UnmarshalIdempotency(payload []byte) (idempotency.Record, error) {
var stored idempotencyRecord
if err := decodeStrictJSON("decode redis idempotency record", payload, &stored); err != nil {
return idempotency.Record{}, err
}
record := idempotency.Record{
Source: stored.Source,
IdempotencyKey: common.IdempotencyKey(stored.IdempotencyKey),
DeliveryID: common.DeliveryID(stored.DeliveryID),
RequestFingerprint: stored.RequestFingerprint,
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
ExpiresAt: time.UnixMilli(stored.ExpiresAtMS).UTC(),
}
if err := record.Validate(); err != nil {
return idempotency.Record{}, fmt.Errorf("decode redis idempotency record: %w", err)
}
return record, nil
}
// MarshalDeadLetter encodes entry into the strict Redis JSON shape used for
// mail_dead_letter_entry values.
func MarshalDeadLetter(entry deliverydomain.DeadLetterEntry) ([]byte, error) {
if err := entry.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis dead-letter record: %w", err)
}
stored := deadLetterRecord{
DeliveryID: entry.DeliveryID.String(),
FinalAttemptNo: entry.FinalAttemptNo,
FailureClassification: entry.FailureClassification,
ProviderSummary: entry.ProviderSummary,
CreatedAtMS: entry.CreatedAt.UTC().UnixMilli(),
RecoveryHint: entry.RecoveryHint,
}
payload, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis dead-letter record: %w", err)
}
return payload, nil
}
// UnmarshalDeadLetter decodes payload from the strict Redis JSON shape used
// for mail_dead_letter_entry values.
func UnmarshalDeadLetter(payload []byte) (deliverydomain.DeadLetterEntry, error) {
var stored deadLetterRecord
if err := decodeStrictJSON("decode redis dead-letter record", payload, &stored); err != nil {
return deliverydomain.DeadLetterEntry{}, err
}
entry := deliverydomain.DeadLetterEntry{
DeliveryID: common.DeliveryID(stored.DeliveryID),
FinalAttemptNo: stored.FinalAttemptNo,
FailureClassification: stored.FailureClassification,
ProviderSummary: stored.ProviderSummary,
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
RecoveryHint: stored.RecoveryHint,
}
if err := entry.Validate(); err != nil {
return deliverydomain.DeadLetterEntry{}, fmt.Errorf("decode redis dead-letter record: %w", err)
}
return entry, nil
}
// MarshalDeliveryPayload encodes payload into the strict Redis JSON shape used
// for raw generic-delivery attachment bundles.
func MarshalDeliveryPayload(payload acceptgenericdelivery.DeliveryPayload) ([]byte, error) {
if err := payload.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis delivery payload record: %w", err)
}
stored := deliveryPayloadRecord{
DeliveryID: payload.DeliveryID.String(),
Attachments: cloneDeliveryPayloadAttachments(payload.Attachments),
}
encoded, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis delivery payload record: %w", err)
}
return encoded, nil
}
// UnmarshalDeliveryPayload decodes payload from the strict Redis JSON shape
// used for raw generic-delivery attachment bundles.
func UnmarshalDeliveryPayload(payload []byte) (acceptgenericdelivery.DeliveryPayload, error) {
var stored deliveryPayloadRecord
if err := decodeStrictJSON("decode redis delivery payload record", payload, &stored); err != nil {
return acceptgenericdelivery.DeliveryPayload{}, err
}
record := acceptgenericdelivery.DeliveryPayload{
DeliveryID: common.DeliveryID(stored.DeliveryID),
Attachments: inflateDeliveryPayloadAttachments(stored.Attachments),
}
if err := record.Validate(); err != nil {
return acceptgenericdelivery.DeliveryPayload{}, fmt.Errorf("decode redis delivery payload record: %w", err)
}
return record, nil
}
// MarshalMalformedCommand encodes entry into the strict Redis JSON shape used
// for operator-visible malformed async command records.
func MarshalMalformedCommand(entry malformedcommand.Entry) ([]byte, error) {
if err := entry.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis malformed command record: %w", err)
}
stored := malformedCommandRecord{
StreamEntryID: entry.StreamEntryID,
DeliveryID: entry.DeliveryID,
Source: entry.Source,
IdempotencyKey: entry.IdempotencyKey,
FailureCode: entry.FailureCode,
FailureMessage: entry.FailureMessage,
RawFieldsJSON: cloneJSONObject(entry.RawFields),
RecordedAtMS: entry.RecordedAt.UTC().UnixMilli(),
}
encoded, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis malformed command record: %w", err)
}
return encoded, nil
}
// UnmarshalMalformedCommand decodes payload from the strict Redis JSON shape
// used for operator-visible malformed async command records.
func UnmarshalMalformedCommand(payload []byte) (malformedcommand.Entry, error) {
var stored malformedCommandRecord
if err := decodeStrictJSON("decode redis malformed command record", payload, &stored); err != nil {
return malformedcommand.Entry{}, err
}
entry := malformedcommand.Entry{
StreamEntryID: stored.StreamEntryID,
DeliveryID: stored.DeliveryID,
Source: stored.Source,
IdempotencyKey: stored.IdempotencyKey,
FailureCode: stored.FailureCode,
FailureMessage: stored.FailureMessage,
RawFields: cloneJSONObject(stored.RawFieldsJSON),
RecordedAt: time.UnixMilli(stored.RecordedAtMS).UTC(),
}
if err := entry.Validate(); err != nil {
return malformedcommand.Entry{}, fmt.Errorf("decode redis malformed command record: %w", err)
}
return entry, nil
}
// MarshalStreamOffset encodes offset into the strict Redis JSON shape used for
// persisted consumer progress.
func MarshalStreamOffset(offset StreamOffset) ([]byte, error) {
if err := offset.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis stream offset record: %w", err)
}
stored := streamOffsetRecord{
Stream: offset.Stream,
LastProcessedEntryID: offset.LastProcessedEntryID,
UpdatedAtMS: offset.UpdatedAt.UTC().UnixMilli(),
}
encoded, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis stream offset record: %w", err)
}
return encoded, nil
}
// UnmarshalStreamOffset decodes payload from the strict Redis JSON shape used
// for persisted consumer progress.
func UnmarshalStreamOffset(payload []byte) (StreamOffset, error) {
var stored streamOffsetRecord
if err := decodeStrictJSON("decode redis stream offset record", payload, &stored); err != nil {
return StreamOffset{}, err
}
offset := StreamOffset{
Stream: stored.Stream,
LastProcessedEntryID: stored.LastProcessedEntryID,
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
}
if err := offset.Validate(); err != nil {
return StreamOffset{}, fmt.Errorf("decode redis stream offset record: %w", err)
}
return offset, nil
}
func decodeStrictJSON(operation string, payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("%s: unexpected trailing JSON input", operation)
}
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
func cloneEmailStrings(values []common.Email) []string {
if values == nil {
return nil
}
cloned := make([]string, len(values))
for index, value := range values {
cloned[index] = value.String()
}
return cloned
}
func cloneEmails(values []string) []common.Email {
if values == nil {
return nil
}
cloned := make([]common.Email, len(values))
for index, value := range values {
cloned[index] = common.Email(value)
}
return cloned
}
func cloneAttachments(values []common.AttachmentMetadata) []attachmentRecord {
if values == nil {
return nil
}
cloned := make([]attachmentRecord, len(values))
for index, value := range values {
cloned[index] = attachmentRecord{
Filename: value.Filename,
ContentType: value.ContentType,
SizeBytes: value.SizeBytes,
}
}
return cloned
}
func inflateAttachments(values []attachmentRecord) []common.AttachmentMetadata {
if values == nil {
return nil
}
cloned := make([]common.AttachmentMetadata, len(values))
for index, value := range values {
cloned[index] = common.AttachmentMetadata{
Filename: value.Filename,
ContentType: value.ContentType,
SizeBytes: value.SizeBytes,
}
}
return cloned
}
func optionalJSONObject(value map[string]any) *map[string]any {
if value == nil {
return nil
}
cloned := make(map[string]any, len(value))
for key, item := range value {
cloned[key] = cloneJSONValue(item)
}
return &cloned
}
func cloneJSONObjectPtr(value *map[string]any) map[string]any {
if value == nil {
return nil
}
cloned := make(map[string]any, len(*value))
for key, item := range *value {
cloned[key] = cloneJSONValue(item)
}
return cloned
}
func cloneJSONObject(value map[string]any) map[string]any {
if value == nil {
return nil
}
cloned := make(map[string]any, len(value))
for key, item := range value {
cloned[key] = cloneJSONValue(item)
}
return cloned
}
func cloneJSONValue(value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for key, item := range typed {
cloned[key] = cloneJSONValue(item)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = cloneJSONValue(item)
}
return cloned
default:
return typed
}
}
func cloneDeliveryPayloadAttachments(values []acceptgenericdelivery.AttachmentPayload) []deliveryPayloadAttachmentRecord {
if values == nil {
return nil
}
cloned := make([]deliveryPayloadAttachmentRecord, len(values))
for index, value := range values {
cloned[index] = deliveryPayloadAttachmentRecord{
Filename: value.Filename,
ContentType: value.ContentType,
ContentBase64: value.ContentBase64,
SizeBytes: value.SizeBytes,
}
}
return cloned
}
func inflateDeliveryPayloadAttachments(values []deliveryPayloadAttachmentRecord) []acceptgenericdelivery.AttachmentPayload {
if values == nil {
return nil
}
cloned := make([]acceptgenericdelivery.AttachmentPayload, len(values))
for index, value := range values {
cloned[index] = acceptgenericdelivery.AttachmentPayload{
Filename: value.Filename,
ContentType: value.ContentType,
ContentBase64: value.ContentBase64,
SizeBytes: value.SizeBytes,
}
}
return cloned
}
func optionalUnixMilli(value *time.Time) *int64 {
if value == nil {
return nil
}
milliseconds := value.UTC().UnixMilli()
return &milliseconds
}
func inflateOptionalTime(value *int64) *time.Time {
if value == nil {
return nil
}
converted := time.UnixMilli(*value).UTC()
return &converted
}