307 lines
11 KiB
Go
307 lines
11 KiB
Go
package mailstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
pgtable "galaxy/mail/internal/adapters/postgres/jet/mail/table"
|
|
"galaxy/mail/internal/domain/attempt"
|
|
"galaxy/mail/internal/domain/common"
|
|
deliverydomain "galaxy/mail/internal/domain/delivery"
|
|
"galaxy/mail/internal/domain/idempotency"
|
|
"galaxy/mail/internal/service/acceptgenericdelivery"
|
|
"galaxy/mail/internal/service/listdeliveries"
|
|
"galaxy/mail/internal/service/resenddelivery"
|
|
|
|
pg "github.com/go-jet/jet/v2/postgres"
|
|
)
|
|
|
|
// resendIdempotencyExpiry stores the synthetic idempotency_expires_at value
|
|
// applied to resend deliveries. Resend rows do not carry a caller-supplied
|
|
// idempotency reservation; the fingerprint is stored as the empty string and
|
|
// the loadIdempotencyByScope helper treats those rows as non-idempotent —
|
|
// the expiry is therefore irrelevant in practice but must satisfy the
|
|
// `NOT NULL > created_at` invariant used by the deliveries column.
|
|
const resendIdempotencyExpiry = 100 * 365 * 24 * time.Hour
|
|
|
|
// maxIdempotencyExpiry is the fallback expiry duration used when no caller-
|
|
// supplied idempotency.Record reservation accompanies the write.
|
|
var maxIdempotencyExpiry = resendIdempotencyExpiry
|
|
|
|
// GetIdempotency loads the idempotency reservation for one (source, key)
|
|
// scope. It is shared by the auth-acceptance and generic-acceptance flows.
|
|
func (store *Store) GetIdempotency(ctx context.Context, source deliverydomain.Source, key common.IdempotencyKey) (idempotency.Record, bool, error) {
|
|
if store == nil {
|
|
return idempotency.Record{}, false, errors.New("get idempotency: nil store")
|
|
}
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get idempotency")
|
|
if err != nil {
|
|
return idempotency.Record{}, false, err
|
|
}
|
|
defer cancel()
|
|
|
|
record, ok, err := loadIdempotencyByScope(operationCtx, store.db, source, key)
|
|
if err != nil {
|
|
return idempotency.Record{}, false, fmt.Errorf("get idempotency: %w", err)
|
|
}
|
|
return record, ok, nil
|
|
}
|
|
|
|
// GetDeadLetter loads the dead_letters row for deliveryID when one exists.
|
|
func (store *Store) GetDeadLetter(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.DeadLetterEntry, bool, error) {
|
|
if store == nil {
|
|
return deliverydomain.DeadLetterEntry{}, false, errors.New("get dead-letter: nil store")
|
|
}
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get dead-letter")
|
|
if err != nil {
|
|
return deliverydomain.DeadLetterEntry{}, false, err
|
|
}
|
|
defer cancel()
|
|
|
|
entry, ok, err := loadDeadLetter(operationCtx, store.db, deliveryID)
|
|
if err != nil {
|
|
return deliverydomain.DeadLetterEntry{}, false, fmt.Errorf("get dead-letter: %w", err)
|
|
}
|
|
return entry, ok, nil
|
|
}
|
|
|
|
// GetDeliveryPayload returns the raw attachment payload bundle for deliveryID
|
|
// when one exists.
|
|
func (store *Store) GetDeliveryPayload(ctx context.Context, deliveryID common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
|
|
if store == nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get delivery payload: nil store")
|
|
}
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get delivery payload")
|
|
if err != nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, err
|
|
}
|
|
defer cancel()
|
|
|
|
encoded, ok, err := loadDeliveryPayload(operationCtx, store.db, deliveryID)
|
|
if err != nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get delivery payload: %w", err)
|
|
}
|
|
if !ok {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, nil
|
|
}
|
|
payload, err := unmarshalDeliveryPayload(deliveryID, encoded)
|
|
if err != nil {
|
|
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get delivery payload: %w", err)
|
|
}
|
|
return payload, true, nil
|
|
}
|
|
|
|
// ListAttempts loads exactly expectedCount attempts in attempt_no ASC order
|
|
// for deliveryID. A gap in the stored sequence surfaces as an error so
|
|
// operator reads fail closed on durable-state corruption.
|
|
func (store *Store) ListAttempts(ctx context.Context, deliveryID common.DeliveryID, expectedCount int) ([]attempt.Attempt, error) {
|
|
if store == nil {
|
|
return nil, errors.New("list attempts: nil store")
|
|
}
|
|
if expectedCount < 0 {
|
|
return nil, errors.New("list attempts: negative expected count")
|
|
}
|
|
if expectedCount == 0 {
|
|
return []attempt.Attempt{}, nil
|
|
}
|
|
if err := deliveryID.Validate(); err != nil {
|
|
return nil, fmt.Errorf("list attempts: %w", err)
|
|
}
|
|
operationCtx, cancel, err := store.operationContext(ctx, "list attempts")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cancel()
|
|
|
|
out, err := loadAttempts(operationCtx, store.db, deliveryID, expectedCount)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list attempts: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// List returns one filtered ordered page of delivery records keyed by
|
|
// (created_at DESC, delivery_id DESC). Filters compose into SQL WHERE
|
|
// clauses — every supported filter is index-friendly.
|
|
func (store *Store) List(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
|
|
if store == nil {
|
|
return listdeliveries.Result{}, errors.New("list deliveries: nil store")
|
|
}
|
|
if err := input.Validate(); err != nil {
|
|
return listdeliveries.Result{}, fmt.Errorf("list deliveries: %w", err)
|
|
}
|
|
limit := input.Limit
|
|
if limit <= 0 {
|
|
limit = listdeliveries.DefaultLimit
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "list deliveries")
|
|
if err != nil {
|
|
return listdeliveries.Result{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
if input.Cursor != nil {
|
|
cursorStmt := pg.SELECT(pgtable.Deliveries.CreatedAt).
|
|
FROM(pgtable.Deliveries).
|
|
WHERE(pgtable.Deliveries.DeliveryID.EQ(pg.String(input.Cursor.DeliveryID.String())))
|
|
cursorQuery, cursorArgs := cursorStmt.Sql()
|
|
row := store.db.QueryRowContext(operationCtx, cursorQuery, cursorArgs...)
|
|
var createdAt sql.NullTime
|
|
if err := row.Scan(&createdAt); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return listdeliveries.Result{}, listdeliveries.ErrInvalidCursor
|
|
}
|
|
return listdeliveries.Result{}, fmt.Errorf("list deliveries: validate cursor: %w", err)
|
|
}
|
|
if !createdAt.Valid || !createdAt.Time.UTC().Equal(input.Cursor.CreatedAt.UTC()) {
|
|
return listdeliveries.Result{}, listdeliveries.ErrInvalidCursor
|
|
}
|
|
}
|
|
|
|
conditions := make([]pg.BoolExpression, 0, 8)
|
|
|
|
if input.Cursor != nil {
|
|
cursorCreatedAt := pg.TimestampzT(input.Cursor.CreatedAt.UTC())
|
|
cursorID := pg.String(input.Cursor.DeliveryID.String())
|
|
// (created_at, delivery_id) < (cursorCreatedAt, cursorID) expressed as
|
|
// the equivalent OR/AND expansion since jet has no row-comparison
|
|
// builder.
|
|
conditions = append(conditions, pg.OR(
|
|
pgtable.Deliveries.CreatedAt.LT(cursorCreatedAt),
|
|
pg.AND(
|
|
pgtable.Deliveries.CreatedAt.EQ(cursorCreatedAt),
|
|
pgtable.Deliveries.DeliveryID.LT(cursorID),
|
|
),
|
|
))
|
|
}
|
|
if input.Filters.Status != "" {
|
|
conditions = append(conditions, pgtable.Deliveries.Status.EQ(pg.String(string(input.Filters.Status))))
|
|
}
|
|
if input.Filters.Source != "" {
|
|
conditions = append(conditions, pgtable.Deliveries.Source.EQ(pg.String(string(input.Filters.Source))))
|
|
}
|
|
if !input.Filters.TemplateID.IsZero() {
|
|
conditions = append(conditions, pgtable.Deliveries.TemplateID.EQ(pg.String(input.Filters.TemplateID.String())))
|
|
}
|
|
if !input.Filters.IdempotencyKey.IsZero() {
|
|
conditions = append(conditions, pgtable.Deliveries.IdempotencyKey.EQ(pg.String(input.Filters.IdempotencyKey.String())))
|
|
}
|
|
if input.Filters.FromCreatedAt != nil {
|
|
conditions = append(conditions, pgtable.Deliveries.CreatedAt.GT_EQ(pg.TimestampzT(input.Filters.FromCreatedAt.UTC())))
|
|
}
|
|
if input.Filters.ToCreatedAt != nil {
|
|
conditions = append(conditions, pgtable.Deliveries.CreatedAt.LT_EQ(pg.TimestampzT(input.Filters.ToCreatedAt.UTC())))
|
|
}
|
|
if !input.Filters.Recipient.IsZero() {
|
|
recipientSub := pg.SELECT(pgtable.DeliveryRecipients.DeliveryID).
|
|
FROM(pgtable.DeliveryRecipients).
|
|
WHERE(pg.AND(
|
|
pgtable.DeliveryRecipients.Kind.NOT_EQ(pg.String(recipientKindReplyTo)),
|
|
pg.LOWER(pgtable.DeliveryRecipients.Email).EQ(pg.LOWER(pg.String(input.Filters.Recipient.String()))),
|
|
))
|
|
conditions = append(conditions, pgtable.Deliveries.DeliveryID.IN(recipientSub))
|
|
}
|
|
|
|
stmt := pg.SELECT(deliverySelectColumns).
|
|
FROM(pgtable.Deliveries)
|
|
|
|
if len(conditions) > 0 {
|
|
stmt = stmt.WHERE(pg.AND(conditions...))
|
|
}
|
|
stmt = stmt.
|
|
ORDER_BY(pgtable.Deliveries.CreatedAt.DESC(), pgtable.Deliveries.DeliveryID.DESC()).
|
|
LIMIT(int64(limit + 1))
|
|
|
|
query, args := stmt.Sql()
|
|
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
|
if err != nil {
|
|
return listdeliveries.Result{}, fmt.Errorf("list deliveries: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]deliverydomain.Delivery, 0, limit+1)
|
|
for rows.Next() {
|
|
record, _, err := scanDelivery(rows)
|
|
if err != nil {
|
|
return listdeliveries.Result{}, fmt.Errorf("list deliveries: scan: %w", err)
|
|
}
|
|
envelope, err := loadEnvelope(operationCtx, store.db, record.DeliveryID)
|
|
if err != nil {
|
|
return listdeliveries.Result{}, fmt.Errorf("list deliveries: load envelope: %w", err)
|
|
}
|
|
record.Envelope = envelope
|
|
items = append(items, record)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return listdeliveries.Result{}, fmt.Errorf("list deliveries: %w", err)
|
|
}
|
|
|
|
result := listdeliveries.Result{}
|
|
if len(items) > limit {
|
|
next := listdeliveries.Cursor{
|
|
CreatedAt: items[limit-1].CreatedAt.UTC(),
|
|
DeliveryID: items[limit-1].DeliveryID,
|
|
}
|
|
result.NextCursor = &next
|
|
items = items[:limit]
|
|
}
|
|
result.Items = items
|
|
return result, nil
|
|
}
|
|
|
|
// CreateResend writes the cloned delivery, its first attempt, and the
|
|
// optional cloned payload bundle inside one transaction. Resend deliveries
|
|
// share the (source, idempotency_key) UNIQUE constraint, so a duplicate clone
|
|
// surfaces as a generic acceptance conflict — but the resend service
|
|
// generates fresh idempotency keys, so a conflict here always indicates a
|
|
// caller bug rather than user-replay.
|
|
func (store *Store) CreateResend(ctx context.Context, input resenddelivery.CreateResendInput) error {
|
|
if store == nil {
|
|
return errors.New("create resend: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return errors.New("create resend: nil context")
|
|
}
|
|
if err := input.Validate(); err != nil {
|
|
return fmt.Errorf("create resend: %w", err)
|
|
}
|
|
|
|
return store.withTx(ctx, "create resend", func(ctx context.Context, tx *sql.Tx) error {
|
|
// Use the delivery's own UpdatedAt as a deterministic finite expiry —
|
|
// the resend has no caller-supplied idempotency.Record reservation.
|
|
fallbackExpiresAt := input.Delivery.CreatedAt.Add(maxIdempotencyExpiry)
|
|
first := input.FirstAttempt
|
|
if err := insertDelivery(ctx, tx, input.Delivery, idempotency.Record{}, fallbackExpiresAt, &first); err != nil {
|
|
if isUniqueViolation(err) {
|
|
return fmt.Errorf("create resend: %w", err)
|
|
}
|
|
return fmt.Errorf("create resend: insert delivery: %w", err)
|
|
}
|
|
if err := insertAttempt(ctx, tx, input.FirstAttempt); err != nil {
|
|
return fmt.Errorf("create resend: insert first attempt: %w", err)
|
|
}
|
|
if input.DeliveryPayload != nil {
|
|
payload, err := marshalDeliveryPayload(*input.DeliveryPayload)
|
|
if err != nil {
|
|
return fmt.Errorf("create resend: %w", err)
|
|
}
|
|
payloadStmt := pgtable.DeliveryPayloads.INSERT(
|
|
pgtable.DeliveryPayloads.DeliveryID,
|
|
pgtable.DeliveryPayloads.Payload,
|
|
).VALUES(
|
|
input.Delivery.DeliveryID.String(),
|
|
payload,
|
|
)
|
|
payloadQuery, payloadArgs := payloadStmt.Sql()
|
|
if _, err := tx.ExecContext(ctx, payloadQuery, payloadArgs...); err != nil {
|
|
return fmt.Errorf("create resend: insert delivery payload: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|