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:
| Category | Promotional? | Examples |
|---|---|---|
TRANSACTIONAL | no | Booking confirmations, payment receipts, account notices |
MARKETING | yes | Campaigns, offers |
WINBACK | yes | Win-back pre-claim outreach |
NEWSLETTER | yes | Newsletter |
PRODUCT_UPDATES | yes | Product 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
UNSUBSCRIBEDpreference 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_idis 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/**:
| Route | Purpose |
|---|---|
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/preferences | Save 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
sendBulkTemplatedEmailcannot 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 & path | Purpose |
|---|---|
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 /stats | Aggregate SES event counts by type |