Skip to main content

Email Suppression, Unsubscribe & SES Feedback

Every email the platform sends through Amazon SES now passes a suppression gate before it leaves the building, SES bounce/complaint feedback is ingested and acted on automatically, and recipients get a standards-compliant one-click unsubscribe and a preference center.

The core lives in the notification module and is reused by crs, admin-api, and website.

Email categories

Each send is classified by EmailCategory:

CategoryPromotional?Examples
TRANSACTIONALnoBooking confirmations, payment receipts, account notices
MARKETINGyesCampaigns, offers
WINBACKyesWin-back pre-claim outreach
NEWSLETTERyesNewsletter
PRODUCT_UPDATESyesProduct update announcements

EmailRequest/BulkEmailRequest default to TRANSACTIONAL, so existing callers are unchanged; promotional senders set the real category. The marketing controller forces MARKETING server-side, and the winback pre-claim email is tagged WINBACK (the "offer claimed" confirmation stays transactional).

The send gate

EmailSuppressionService.canSend(email, category) is the single decision point, consulted by both DefaultNotificationService.sendEmail and DefaultBulkEmailService.sendBulkEmail:

  • A hard-suppressed address with suppress_transactional = true (bounce/complaint) is blocked for all categories.
  • A hard-suppressed address with suppress_transactional = false (a plain global opt-out) is blocked for promotional categories but still receives transactional mail.
  • For a promotional category, an UNSUBSCRIBED preference row blocks the send.
  • Otherwise the send proceeds.

Blocked recipients are dropped (bulk) or the whole send is skipped (single) and logged to notification_logs with status SUPPRESSED.

Data model (Liquibase 072-add-email-suppression.sql)

  • email_suppression — hard block list. email (PK, normalised lowercase), reason (HARD_BOUNCE | COMPLAINT | MANUAL | GLOBAL_OPT_OUT), suppress_transactional, source, detail.
  • email_subscription_preference — per-(email, category) opt-out; absence = subscribed (default-on).
  • email_event — audit ledger of every SES feedback notification; sns_message_id is the idempotency key.

SES feedback ingestion

A SES Configuration Set event destination publishes BOUNCE, COMPLAINT, DELIVERY, and REJECT events to an SNS topic, which is subscribed to the crs webhook:

POST /api/webhooks/ses-notifications

SesEventController handles the SNS SubscriptionConfirmation handshake (auto-confirms) and Notification delivery; SesFeedbackService records every event and auto-suppresses permanent bounces (HARD_BOUNCE) and complaints (COMPLAINT) with suppress_transactional = true. Transient bounces and deliveries are recorded only. Idempotent on the SNS MessageId.

Provisioning (AWS CLI)

The pipeline is created by the idempotent script:

SES_EVENT_ENDPOINT="https://bard-crs-api.elivaas.com/api/webhooks/ses-notifications" \
SES_IDENTITY="[email protected]" \
./infra/ses/setup-ses-notifications.sh

It creates the configuration set (elivaas-default), the SNS topic (elivaas-ses-events), the event destination, the HTTPS subscription, enables account-level suppression for bounce/complaint, and defaults the identity to the config set. Then set on the apps:

elivaas.notification.email.configuration-set=elivaas-default
elivaas.notification.ses.events.topic-arn=arn:aws:sns:ap-south-1:<acct>:elivaas-ses-events # crs

Unsubscribe & preference center

Promotional single sends are dispatched as raw MIME (SesClient.sendRawEmail) carrying RFC 8058 headers:

List-Unsubscribe: <https://bard-web-api.elivaas.com/u/unsubscribe?token=...>, <mailto:[email protected]?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The token is a stateless HMAC of email|category|expiry (UnsubscribeTokenService) — nothing is stored. The customer-facing pages live on the website host under /u/**:

RoutePurpose
GET /u/unsubscribe?token=…One-click landing — unsubscribes and confirms
POST /u/unsubscribe?token=…RFC 8058 one-click POST target (clients call automatically)
GET /u/preferences?token=…Preference center — checkbox per promotional category
POST /u/preferencesSave preferences

The signing secret (elivaas.notification.unsubscribe.secret, env UNSUBSCRIBE_SECRET) must be identical across the signing apps (admin-api, crs) and the verifying app (website).

SES sendBulkTemplatedEmail cannot carry per-recipient headers, so bulk campaigns must render the unsubscribe link in the template body; header-level one-click for bulk would require a SES v2 migration.

Admin / ops API (admin-api)

Under /api/v1/pms/email/suppression (and /api/v1/admin/...):

Method & pathPurpose
GET /List/search the suppression list (?reason=&limit=&offset=)
GET /{email}Full status: suppression + effective preferences
POST /Manually suppress an address
DELETE /{email}Remove a suppression (manual unblock)
GET /statsAggregate SES event counts by type