Skip to content

Production Communications (SES / WhatsApp / SMS foundation)

Purpose

Extends Marketplace Notifications - read that first; this page only covers what changed in the feat/vayzo-production-communications tranche. Nothing here replaces the existing MarketplaceNotification/INotificationService/INotificationChannel architecture; every design decision below is explicitly constrained by it (see "Why not a new notification system" below).

Work Package A: what already existed (as found)

  • INotificationService/MarketplaceNotificationService - centralized idempotent persistence, dispatch to every enabled INotificationChannel. Two atomicity contracts: NotifyAsync (transaction-bound, failures propagate) and NotifyBestEffortAsync (catch-log-never-throw). See marketplace-notifications.md "Atomicity" for the exact caller list.
  • INotificationChannel (InApp/Email/Sms) - registered in DependencyInjection.cs, gated by Marketplace:Notifications:{InAppEnabled,EmailEnabled,SmsEnabled}. Before this tranche, EmailNotificationChannel/SmsNotificationChannel.DeliverAsync were genuine no-ops - no vendor was ever called anywhere in the codebase.
  • 17 real call sites across Orders/Payment/Logistics/Returns/Reviews/Settlements firing 20 MarketplaceNotificationType values - the complete, authoritative list is marketplace-notifications.md's "Event matrix". No payment-failure, payment-confirmation-distinct- from-order-placed, or password-reset notification exists anywhere in the codebase.
  • IEmailService (Application/Common/Interfaces/IServices.cs) - SendWelcomeEmailAsync/ SendPasswordResetAsync/etc. - dead code: declared, never implemented, never registered in DI, never called. Predates the MarketplaceNotification architecture. Not used by this tranche (see "Reuse existing architecture" below) and not removed either (out of scope - a pure deletion with no behavior change, left for a separate cleanup).
  • No password-reset flow exists at all - AuthFeatures.cs has only Login/Register/ RefreshToken/RevokeToken. IEmailService.SendPasswordResetAsync above is the only trace of one ever being planned. Work Package C therefore cannot wire password-reset email - the business capability itself doesn't exist yet (see the tranche's own "Do not invent business events").
  • No background retry/outbox mechanism for external delivery exists. The only existing background worker is InventoryReservationExpiryBackgroundService (unrelated - inventory reservation sweeping). The codebase's own comments already anticipated this gap explicitly: MarketplaceNotificationStatus's doc comment says future delivery attempts "would be modeled as a separate entity, not as more values here"; marketplace-notifications.md says real external delivery "needs a separate outbox/worker/retry/dead-letter design before the channel flags can be enabled in production"; the Helm chart's own comment says the same. This tranche builds exactly that separate entity/worker.
  • No Razorpay webhook endpoint exists - only client-driven POST /api/payment/verify (signature-checked checkout completion). See "Razorpay audit findings" below - documented separately, not fixed in this tranche.
  • No policy/terms acceptance tracking exists - zero references to Terms/Privacy/Seller Agreement acceptance anywhere in the domain. See production-communications-policy-acceptance.md.
  • Kubernetes/Helm: helm/akshayabazaar/values.yaml already documents the "C4 launch policy" - in-app only, Email/SMS registered as no-ops, must stay disabled in Production "until real providers are designed with an outbox/retry story" (its own words). This tranche is that story; the Helm default remains emailEnabled: false (see "Configuration" below) - enabling it in any real environment is a deliberate follow-up change, not part of this tranche.
  • Existing test coverage: NotificationFlowTests.cs/NotificationReliabilityTests.cs/ MarketplaceNotificationTests.cs/NotificationsControllerAuthorizationTests.cs - see marketplace-notifications.md "Tests". None constructed EmailNotificationChannel directly (confirmed - the full 715-test suite still passes unchanged after this tranche's constructor signature change).

Why not a new notification system

Every requirement in this tranche is satisfied by extending the existing abstraction, never duplicating it:

  • Business handlers still only ever call INotificationService.NotifyAsync/NotifyBestEffortAsync
  • none of them changed.
  • Email is still just another INotificationChannel - EmailNotificationChannel still implements the same three-member interface.
  • The per-recipient idempotency guarantee (unique IdempotencyKey index) is inherited for free - an email is only ever queued when a genuinely new MarketplaceNotification row is created.

Work Package B: Amazon SES

Why DeliverAsync cannot call SES directly

MarketplaceNotificationService.NotifyAsync is called inside an open database transaction by VerifyAndCreateOrderCommandHandler, CreateReturnRequestCommandHandler, RejectReturnCommandHandler, InspectReturnCommandHandler (checkout, return-request, reject, refund) - see marketplace-notifications.md "Atomicity". If EmailNotificationChannel.DeliverAsync made a real HTTP call to SES, an SES outage/timeout could roll back checkout, refunds, or a return rejection. This is the load-bearing constraint the whole design below exists to satisfy.

Design: enqueue in-transaction, send out-of-transaction

Business handler
  -> INotificationService.NotifyAsync/NotifyBestEffortAsync
       -> MarketplaceNotification row persisted (unchanged)
       -> EmailNotificationChannel.DeliverAsync (still inside the caller's transaction, if any)
            -> EmailTemplateCatalog.IsEmailWorthy(type)?  no -> return (nothing queued)
            -> resolve recipient User.Email via IUnitOfWork.Users
            -> EmailDeliveryAttempt.Create(...) + IEmailDeliveryRepository.EnqueueAsync + SaveChangesAsync
               (a fast, local DB write only - no network I/O, safe inside any transaction)

EmailDeliveryBackgroundService (PeriodicTimer, own DI scope per tick - mirrors
InventoryReservationExpiryBackgroundService exactly)
  -> IEmailDeliveryRepository.GetActionableBatchAsync (Pending, or Retrying with NextAttemptAt due)
  -> re-render each notification via EmailTemplateCatalog (body is never persisted - see below)
  -> IEmailSender.SendAsync (SesEmailSender - the ONLY place that ever calls AWS)
  -> EmailDeliveryAttempt.MarkSent(...) or .RecordFailure(errorCode, isTransient, maxAttempts, backoff)

EmailDeliveryAttempt (Domain/Entities/EmailDeliveryAttempt.cs) is the "separate entity" the codebase's own MarketplaceNotificationStatus comment already called for - Pending -> Sent / Retrying (exponential backoff, capped at 1h) -> Failed (permanent rejection, or attempts exhausted). It never stores the rendered HTML/text body - only Subject (short, non-sensitive) - so a retry re-renders from the source MarketplaceNotification each time (a template fix automatically applies to not-yet-sent retries).

IEmailSender / SES implementation

  • IEmailSender (Application/Common/Interfaces/IEmailSender.cs) - provider-agnostic; EmailMessage in, EmailSendResult out (Success, ProviderMessageId, ErrorCode, IsTransient). Business/application code never references AWS types.
  • SesEmailSender (Infrastructure/Services/SesEmailSender.cs) - the only IEmailSender implementation. Registered unconditionally as a Singleton (reuses one AmazonSimpleEmailServiceV2Client - AWS SDK guidance) - it checks Marketplace:Notifications:Ses:Enabled/config validity at call time and returns EmailSendResult.Disabled() rather than throwing or calling AWS, mirroring every other INotificationChannel's "checked at call time, safe when off" convention.
  • ISesTransport - a narrow one-method seam (SendEmailAsync only) around the AWS SDK, so tests inject a small fake instead of mocking IAmazonSimpleEmailServiceV2's entire surface. AmazonSesTransport is the one real, production adapter.
  • Credentials: never read from IConfiguration/appsettings*.json anywhere in this codebase. AmazonSimpleEmailServiceV2Client is constructed with only a RegionEndpoint - the AWS SDK's default credential provider chain resolves credentials from environment variables/IAM role/ container credentials endpoint at runtime. See the SES runbook.
  • Exception classification (SesEmailSender.SendAsync): TooManyRequestsException/ LimitExceededException/SendingPausedException/5xx/429 -> transient (retried with backoff); MessageRejectedException/MailFromDomainNotVerifiedException/AccountSuspendedException/ NotFoundException -> permanent (never retried); anything unrecognized -> transient ("fail open toward retry" rather than silently dropping an unexpected error class).

Configuration (Marketplace:Notifications:Ses:*)

Key Default Notes
Enabled false Master kill switch for the background dispatcher's real SES calls
Region - e.g. ap-south-1 (Mumbai) - required when Enabled=true
SenderAddress - noreply@<VAYZO_DOMAIN> - required when Enabled=true, never a placeholder
ReplyToAddress falls back to SenderAddress support@<VAYZO_DOMAIN>
ConfigurationSetName omitted optional - bounce/complaint/delivery event tracking (SNS)
MaxSendAttempts 5
DispatchIntervalSeconds 30 mirrors InventoryReservations:SweepIntervalSeconds
DispatchBatchSize 20
FrontendBaseUrl none absolute origin for turning ActionUrl into a clickable link; omitted -> plain-text reference, never a broken link

Two independent gates, both required for a real send: Marketplace:Notifications:EmailEnabled (channel-level - whether anything is queued at all) and Marketplace:Notifications:Ses:Enabled (provider-level - whether the dispatcher actually calls AWS). Either alone is a safe no-op - EmailEnabled=true with Ses:Enabled=false queues rows that simply sit Pending forever (a valid staged-rollout state), and Ses:Enabled=true with EmailEnabled=false never queues anything for it to send.

Startup validation (Program.ValidateRuntimeConfiguration)

Mirrors the existing Razorpay KeyId/KeySecret block exactly: outside Development, if EmailEnabled && Ses:Enabled, Region and a real (non-placeholder) SenderAddress are required or startup throws. LooksLikePlaceholder gained three new markers (<VAYZO_DOMAIN>, @example.com, CHANGE_ME) - a small, general improvement that also benefits the Razorpay/DB/Redis checks it already guards.

Work Package C: which events are wired

EmailTemplateCatalog.IsEmailWorthy is an explicit allowlist (Work Package D: "do not send the same notification over every channel by default") - not every MarketplaceNotificationType:

Emailed: OrderPlaced, SellerOrderCreated, SellerOrderConfirmed, SellerOrderShipped, SellerOrderDelivered, SellerOrderCancelled, ShipmentOutForDelivery, ReturnRequested, ReturnApproved, ReturnRejected, ReturnReceived, ReturnRefundRecorded.

Not emailed in this tranche (in-app only, deliberately deferred - not explicitly prioritized by the tranche brief): Settlement* (5 values), Review* (3 values).

Not wired because the business event doesn't exist (see Work Package A above): password reset, payment confirmation as distinct from OrderPlaced, payment failure.

Template content: reuse, never invent

EmailTemplateCatalog.Render wraps a MarketplaceNotification's own Title/Message in one generic branded HTML+text envelope - it does not write new per-type marketing copy. This is deliberate: Title/Message are already the exact, reviewed wording the in-app channel shows, including the refund disclaimer ("No automated Razorpay/bank refund is executed in this environment" - see marketplace-notifications.md "Refund wording"). Reusing it verbatim is what automatically satisfies every launch-safety rule (no fake phone numbers/addresses, no invented refund timelines, no unsupported delivery promises) without a second copy of the same claims to audit. All interpolated values (recipient name, Title, Message) are HTML-escaped regardless of provenance.

Work Package D: WhatsApp/SMS foundation (architecture only - no vendor)

No Meta/Twilio/MSG91 credentials or provider calls were added. INotificationChannel's existing three-member shape (Name, IsEnabled, DeliverAsync) already needs no interface change to support a future WhatsAppNotificationChannel/hardened SmsNotificationChannel - Email is now the reference implementation for the pattern every future channel should follow:

  1. A channel-specific delivery-attempt entity (mirror EmailDeliveryAttempt) - own status/attempt- count/backoff/provider-message-id/error-code, never the rendered content at rest.
  2. A channel-specific template catalog with its own per-type allowlist - WhatsApp/SMS need pre-approved template IDs (Meta template review; India DLT registration - see Work Package E), a fundamentally different constraint from email's free-form HTML/text, so a shared catalog across channels would be wrong even structurally.
  3. A background dispatcher mirroring EmailDeliveryBackgroundService's exact shape.
  4. DeliverAsync stays a fast, local enqueue - the transaction-safety constraint from Work Package B applies identically to every future external channel, not just email.

Hooks a real implementation will need that don't exist yet (explicitly out of scope to build without a vendor decision, per the tranche brief):

  • Consent/opt-in state - User has no WhatsApp-opt-in or SMS-consent field. Per marketplace-notifications.md "Deferred work", per-user notification preferences don't exist for any channel yet - every enabled channel currently applies uniformly. A real WhatsApp channel cannot ship without this (Meta requires explicit opt-in - see Work Package E).
  • Locale - User has no locale/language field; every existing notification is English-only.
  • Phone number - User.PhoneNumber exists but is unvalidated/unnormalized (no E.164 normalization visible in User) - a prerequisite for both WhatsApp and SMS.
  • Provider response/message id, delivery/failure status, retry classification - all already modeled generically in this tranche's EmailDeliveryAttempt/EmailSendResult shape; a future WhatsAppDeliveryAttempt/ISmsSender would copy the same shape.

Deferred work (explicitly out of scope for this tranche)

  • Real SES sending - no AWS account/domain/credentials available; nothing in this tranche has ever called real AWS (see tests - all use ISesTransport fakes or Enabled=false).
  • WhatsApp/SMS vendor integration - architecture only, per the tranche brief.
  • Backoff jitter - EmailDeliveryBackgroundService.ComputeBackoff is a fixed exponential schedule (1m/2m/4m/8m/...), not randomized - fine for this tranche's low initial volume, but add jitter before relying on this under real multi-instance production load (a herd of retries could otherwise synchronize).
  • Bounce/complaint webhook ingestion (SES ConfigurationSetName -> SNS) - the config key exists; nothing consumes the events yet. See the SES runbook.
  • Per-user notification preferences/consent for any channel (pre-existing gap, not created by this tranche).
  • Deleting the dead IEmailService interface - out of scope, zero behavior change either way.
  • Live dotnet ef database update verification against MySQL - the AddEmailDeliveryAttempts migration was generated cleanly and the resulting EF model is exercised by the full SQLite-backed test suite (EnsureCreated()), but Docker Desktop was not running during part of this session so the migration could not also be applied to a live MySQL instance. Structurally identical to every other migration in this codebase (same column/index conventions) - low risk, but flagging rather than claiming a verification that didn't happen.
  • Marketplace Notifications - the base architecture this tranche extends
  • production-communications-ses-runbook.md - operational SES setup (domain verification, DKIM/ SPF/DMARC, IAM, sandbox->production, bounce/complaint handling)
  • production-communications-india-readiness.md - Work Package E checklist
  • production-communications-razorpay-audit.md - Work Package F findings (separate from this doc per the tranche brief: "document it separately... do not bury it inside SES changes")
  • production-communications-policy-acceptance.md - Work Package G design