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 enabledINotificationChannel. Two atomicity contracts:NotifyAsync(transaction-bound, failures propagate) andNotifyBestEffortAsync(catch-log-never-throw). See marketplace-notifications.md "Atomicity" for the exact caller list.INotificationChannel(InApp/Email/Sms) - registered inDependencyInjection.cs, gated byMarketplace:Notifications:{InAppEnabled,EmailEnabled,SmsEnabled}. Before this tranche,EmailNotificationChannel/SmsNotificationChannel.DeliverAsyncwere 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
MarketplaceNotificationTypevalues - 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 theMarketplaceNotificationarchitecture. 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.cshas only Login/Register/ RefreshToken/RevokeToken.IEmailService.SendPasswordResetAsyncabove 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.yamlalready 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 remainsemailEnabled: 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 constructedEmailNotificationChanneldirectly (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-EmailNotificationChannelstill implements the same three-member interface. - The per-recipient idempotency guarantee (unique
IdempotencyKeyindex) is inherited for free - an email is only ever queued when a genuinely newMarketplaceNotificationrow 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;EmailMessagein,EmailSendResultout (Success,ProviderMessageId,ErrorCode,IsTransient). Business/application code never references AWS types.SesEmailSender(Infrastructure/Services/SesEmailSender.cs) - the onlyIEmailSenderimplementation. Registered unconditionally as a Singleton (reuses oneAmazonSimpleEmailServiceV2Client- AWS SDK guidance) - it checksMarketplace:Notifications:Ses:Enabled/config validity at call time and returnsEmailSendResult.Disabled()rather than throwing or calling AWS, mirroring every otherINotificationChannel's "checked at call time, safe when off" convention.ISesTransport- a narrow one-method seam (SendEmailAsynconly) around the AWS SDK, so tests inject a small fake instead of mockingIAmazonSimpleEmailServiceV2's entire surface.AmazonSesTransportis the one real, production adapter.- Credentials: never read from
IConfiguration/appsettings*.json anywhere in this codebase.AmazonSimpleEmailServiceV2Clientis constructed with only aRegionEndpoint- 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:
- A channel-specific delivery-attempt entity (mirror
EmailDeliveryAttempt) - own status/attempt- count/backoff/provider-message-id/error-code, never the rendered content at rest. - 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.
- A background dispatcher mirroring
EmailDeliveryBackgroundService's exact shape. DeliverAsyncstays 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 -
Userhas 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 -
Userhas no locale/language field; every existing notification is English-only. - Phone number -
User.PhoneNumberexists but is unvalidated/unnormalized (no E.164 normalization visible inUser) - 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/EmailSendResultshape; a futureWhatsAppDeliveryAttempt/ISmsSenderwould 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
ISesTransportfakes orEnabled=false). - WhatsApp/SMS vendor integration - architecture only, per the tranche brief.
- Backoff jitter -
EmailDeliveryBackgroundService.ComputeBackoffis 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
IEmailServiceinterface - out of scope, zero behavior change either way. - Live
dotnet ef database updateverification against MySQL - theAddEmailDeliveryAttemptsmigration 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.
Related pages¶
- 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 checklistproduction-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