Skip to content

feat: AWS SES integration — Webhook to detect AWS bounces, updates email-suppression table, Message Tracking UI#1163

Open
mrjbj wants to merge 5 commits intoHiEventsDev:developfrom
mrjbj:feature/transactional-email-tracking
Open

feat: AWS SES integration — Webhook to detect AWS bounces, updates email-suppression table, Message Tracking UI#1163
mrjbj wants to merge 5 commits intoHiEventsDev:developfrom
mrjbj:feature/transactional-email-tracking

Conversation

@mrjbj
Copy link
Copy Markdown

@mrjbj mrjbj commented Apr 14, 2026

What changes I've made:

Comprehensive AWS Integration to SNS Delivery Events

Why I've made these changes

Overview

Current State

System currently includes messages and outgoing_messages tables to track announcement messages sent out by organizers to recipients. However, there is no integration with inbound AWS Delivery Event notifications sent via SNS to hi-events system. So currently, organizers can send outbound announcement emails but the delivery status of those emails do not get updated and importantly, if a recipient bounces, the system does not know about it and may resend to them again.

Improvements - Bounce Collection & Email Suppression

This PR includes a new table email_suppressions that gets populated by inbound AWS Delivery Events and is consulted account wide before future outbound emails are released for delivery. This way, if a bounce is detected, future messages to that email will be suppressed, account wide. Bounces are classified as permanent or transient. Permanent bounces suppress both announcement messages and transaction notifications, while transient bounces only suppress announcement messages but continue to send the transaction related ones.

This PR adds a message_id field to the outgoing_messages table (so AWS Delivery Events can find the outbound message they relate to) but otherwise leaves the existing table alone. Outgoing_messages continues to reflect announcement related emails only. This PR includes a secondary table outgoing_transaction_messages to track emails related to transaction events, like new orders, tickets, waitlist, events by recipient along with their AWS delivery status.

Improvements - User Interface

It is currently difficult to review status of outbound emails, as they are buried in the "To:" link within the address section of each message. Users have to navigate to the message, click the link and then can see the recipients of that message and the delivery status (which is currently always set to "sent" since no AWS Delivery Event integration but with this PR will be updated to BOUNCED or SUPPRESSED or DELIVERED as appropriate).

Users can view and resend from the new "Message Tracking" page available in Guest Management. Message tracking has three tabs - one for "delivery failures" (e.g. bounces that should be resolved by updating email address on the ticket, order or message and resent), and one tab for event announcements and transactions respectively.

Superadmin users can access the email_suppression list to add or remove email suppressions that affect subsequent transaction or announcement activity. This is placed at superadmin account level because if an email gets a hard bounce for one event, it should be bad for any event elsewhere.

All of this foundation is packaged up into one PR. I have it running in my instance in production and have confirmed that it works, though my volumes are not very high, so performance testing might be warranted separately.

This only works for AWS SES integration over SNS. If system is using other email provider, this functionality is skipped as if it never existed at all.

Technical details below.

Architecture

┌──────────────┐    ┌─────────┐    ┌──────────────────┐
│  Hi.Events   │───▶│ AWS SES │───▶│ Recipient Inbox  │
│  (send mail) │    │         │    └──────────────────┘
└──────┬───────┘    └────┬────┘           │
       │                 │          ┌─────▼──────┐
       │   SES Message   │          │  Bounce /  │
       │   ID captured   │          │ Complaint  │
       │                 │          └─────┬──────┘
       │            ┌────▼────┐           │
       │            │ AWS SNS │◀──────────┘
       │            │  Topic  │  Configuration Set
       │            └────┬────┘  routes events
       │                 │
       │     ┌───────────▼───────────┐
       │     │ POST /webhooks/ses    │
       │     │ (SNS Notification)    │
       │     └───────────┬───────────┘
       │                 │
       │     ┌───────────▼───────────┐
       │     │ IncomingSesWebhook    │
       │     │ Handler               │
       │     │  • Verify signature   │
       │     │  • Deduplicate        │
       │     │  • Route by type      │
       │     └──┬──────┬──────┬──────┘
       │        │      │      │
       │   ┌────▼─┐ ┌──▼──┐ ┌▼────────┐
       │   │Bounce│ │Comp-│ │Delivery  │
       │   │Hndlr │ │laint│ │Handler   │
       │   └──┬───┘ └──┬──┘ └────┬─────┘
       │      │        │         │
       │   ┌──▼────────▼──┐  ┌──▼──────────────┐
       └──▶│ Suppression  │  │ Status Update    │
           │ Service      │  │ SENT → DELIVERED │
           │ (suppress    │  │ SENT → BOUNCED   │
           │  future      │  │ DELIVERED →      │
           │  sends)      │  │   BOUNCED        │
           └──────────────┘  └──────────────────┘

Database

New Tables

  • email_suppressions — account-scoped suppression list with bounce/complaint reason tracking
  • outgoing_transaction_messages — tracks all transactional email sends (order confirmations, attendee tickets, waitlist notifications)

Modified Tables

  • outgoing_messages — added ses_message_id for bounce correlation to specific outbound email sent earlier and to which the inboudn SNS Delivery Event relates.

Message Lifecycle

SENT ──▶ DELIVERED  (SES delivery confirmation)
  │          │
  └──────────┴──▶ BOUNCED  (SES bounce/complaint notification)

SUPPRESSED  (address on suppression list, email not sent)
FAILED      (send error)

Suppression Rules

Event Type Bounce Type Suppresses
Permanent Bounce Hard All emails
Transient Bounce Soft Marketing only
Complaint Abuse etc. Marketing only

Suppressions upgrade: a transient bounce followed by a permanent bounce updates the record to suppress all email types.

Configuration

# SES event handling (all optional, feature is additive)
SES_CONFIGURATION_SET=your-config-set     # Routes events to SNS
AWS_SNS_TOPIC_ARN=arn:aws:sns:...         # Validates incoming topic (optional, extra protection against spoofing)
AWS_SNS_VERIFY_SIGNATURE=true             # Verify SNS signatures (prevents spoofing)
SES_SUPPRESSION_ENABLED=false             # Enable pre-send checks

Frontend: Message Tracking

Three-tab page replacing "Delivery Issues":

  • Delivery Issues — failed/bounced/suppressed transactional emails with resolve workflow (update email, resend, auto-resolve)
  • Marketing — all broadcast messages with status/date filters and search
  • Transactions — all transactional messages with email type/status/date filters and search

Key Backend Files

  • app/Http/Actions/Common/Webhooks/SesIncomingWebhookAction.php — webhook entry
  • app/Services/Application/Handlers/Email/Ses/IncomingSesWebhookHandler.php — SNS routing
  • app/Services/Domain/Email/Ses/EventHandlers/{Bounce,Complaint,Delivery}Handler.php
  • app/Services/Domain/Email/EmailSuppressionService.php — suppression logic
  • app/Services/Domain/Email/TransactionalEmailTrackingService.php — wraps sends
  • app/Http/Actions/TransactionMessages/{Resend,Resolve}TransactionMessageAction.php

Development

SNS webhook simulator for local testing without AWS infrastructure:

./docker/development/scripts/sns-webhook-simulator.sh help
./docker/development/scripts/sns-webhook-simulator.sh bounce marketing:10
./docker/development/scripts/sns-webhook-simulator.sh delivery transaction:1
./docker/development/scripts/sns-webhook-simulator.sh list-suppressions

Requires AWS_SNS_VERIFY_SIGNATURE=false and SES_SUPPRESSION_ENABLED=true in docker/development/.env (already set).


How I've tested these changes

Automated tests (389 passing)

  • IncomingSesWebhookHandlerTest — SNS envelope parsing, bounce/complaint/delivery routing, subscription confirmation, TopicArn validation,
    signature rejection, deduplication, invalid JSON handling
  • BounceHandlerTest — processes recipients, skips empty emails, looks up account ID, marks outgoing messages as bounced by ses_message_id, marks
    transaction messages as bounced
  • ComplaintHandlerTest — same pattern as bounce
  • DeliveryHandlerTest — marks outgoing messages as delivered, marks transaction messages as delivered, skips when no ses_message_id
  • EmailSuppressionServiceTest — permanent bounce suppresses all, transient suppresses marketing only, complaint suppresses marketing only, clean
    addresses not suppressed
  • TransactionalEmailTrackingServiceTest — records sends with ses_message_id, checks suppression before sending, records SUPPRESSED status, records
    FAILED on error
  • SesIncomingWebhookActionTest (feature test) — full HTTP POST to webhook endpoint

Manual testing with sns-webhook-simulator.sh

  • Sent bounce against marketing:10 (had ses_message_id) → status changed to BOUNCED, suppression created
  • Sent transient bounce against ana@anajones.com → suppression created with Transient type
  • Sent delivery against transaction:1 → status changed to DELIVERED
  • Sent bounce against already-DELIVERED transaction:1 → status changed DELIVERED → BOUNCED
  • Sent delivery against already-BOUNCED marketing:10 → correctly no change (BOUNCED is terminal)
  • Verified status reporting: script shows before/after status with explanation when unchanged
  • Verified list command shows all messages with ses_message_id indicator
  • Verified list-suppressions shows suppression records
  • Verified unsuppress removes suppression by id and by email
  • Verified updateOrCreate: transient bounce followed by permanent bounce upgrades the suppression record

Manual UI testing in browser (local dev)

  • Message Tracking page loads with three tabs
  • Delivery Issues tab: resolve icon toggles, "Show resolved" switch works, Resolve button opens modal
  • Resolve modal: email auto-checks "resend" when changed, radio buttons for resolution, Cancel/Submit buttons
  • Marketing tab: table loads, status filter dropdown works, text search across recipient/subject works, date range segmented control filters
    correctly
  • Transactions tab: email type filter, status filter, text search, date range all work
  • Status badges display correct colors (DELIVERED=green, SENT=teal, BOUNCED=red, FAILED=orange, SUPPRESSED=gray)

What we haven't tested end-to-end yet

  • Real SES → SNS → webhook flow on production (sending to bounce@simulator.amazonses.com and receiving the real signed SNS callback)
  • Resend workflow from the Resolve modal (updating email + re-sending)
  • Auto-resolve after delivery confirmation on a resent message

Checklist

  • [ x] I have read the contributing guidelines.
  • [ x] My code follows the coding standards of the project.
  • [ x] I have tested my changes, and they work as expected.
  • I understand that this PR will be closed if I do not follow the contributor guidelines and if this PR template is left unedited.

mrjbj and others added 5 commits April 13, 2026 20:11
… suppression, transactional tracking, and Message Tracking UI

## Overview

Complete integration with AWS SES event notifications via SNS webhooks.
Tracks delivery status for both marketing (broadcast) and transactional
emails, suppresses addresses that bounce or complain, and provides a
full Message Tracking UI for event organizers.

## Architecture

```
┌──────────────┐    ┌─────────┐    ┌──────────────────┐
│  Hi.Events   │───▶│ AWS SES │───▶│ Recipient Inbox  │
│  (send mail) │    │         │    └──────────────────┘
└──────┬───────┘    └────┬────┘           │
       │                 │          ┌─────▼──────┐
       │   SES Message   │          │  Bounce /  │
       │   ID captured   │          │ Complaint  │
       │                 │          └─────┬──────┘
       │            ┌────▼────┐           │
       │            │ AWS SNS │◀──────────┘
       │            │  Topic  │  Configuration Set
       │            └────┬────┘  routes events
       │                 │
       │     ┌───────────▼───────────┐
       │     │ POST /webhooks/ses    │
       │     │ (SNS Notification)    │
       │     └───────────┬───────────┘
       │                 │
       │     ┌───────────▼───────────┐
       │     │ IncomingSesWebhook    │
       │     │ Handler               │
       │     │  • Verify signature   │
       │     │  • Deduplicate        │
       │     │  • Route by type      │
       │     └──┬──────┬──────┬──────┘
       │        │      │      │
       │   ┌────▼─┐ ┌──▼──┐ ┌▼────────┐
       │   │Bounce│ │Comp-│ │Delivery  │
       │   │Hndlr │ │laint│ │Handler   │
       │   └──┬───┘ └──┬──┘ └────┬─────┘
       │      │        │         │
       │   ┌──▼────────▼──┐  ┌──▼──────────────┐
       └──▶│ Suppression  │  │ Status Update    │
           │ Service      │  │ SENT → DELIVERED │
           │ (suppress    │  │ SENT → BOUNCED   │
           │  future      │  │ DELIVERED →      │
           │  sends)      │  │   BOUNCED        │
           └──────────────┘  └──────────────────┘
```

## Database

### New Tables
- `email_suppressions` — account-scoped suppression list with bounce/complaint reason tracking
- `outgoing_transaction_messages` — tracks all transactional email sends (order confirmations, attendee tickets, waitlist notifications)

### Modified Tables
- `outgoing_messages` — added `ses_message_id` for bounce correlation

## Message Lifecycle

```
SENT ──▶ DELIVERED  (SES delivery confirmation)
  │          │
  └──────────┴──▶ BOUNCED  (SES bounce/complaint notification)

SUPPRESSED  (address on suppression list, email not sent)
FAILED      (send error)
```

## Suppression Rules

| Event Type        | Bounce Type | Suppresses           |
|-------------------|-------------|----------------------|
| Permanent Bounce  | Hard        | All emails           |
| Transient Bounce  | Soft        | Marketing only       |
| Complaint         | Abuse etc.  | Marketing only       |

Suppressions upgrade: a transient bounce followed by a permanent bounce
updates the record to suppress all email types.

## Configuration

```env
# SES event handling (all optional, feature is additive)
SES_CONFIGURATION_SET=your-config-set     # Routes events to SNS
AWS_SNS_TOPIC_ARN=arn:aws:sns:...         # Validates incoming topic
AWS_SNS_VERIFY_SIGNATURE=true             # Verify SNS signatures
SES_SUPPRESSION_ENABLED=false             # Enable pre-send checks
```

## Frontend: Message Tracking

Three-tab page replacing "Delivery Issues":
- **Delivery Issues** — failed/bounced/suppressed transactional emails with resolve workflow (update email, resend, auto-resolve)
- **Marketing** — all broadcast messages with status/date filters and search
- **Transactions** — all transactional messages with email type/status/date filters and search

## Key Backend Files

- `app/Http/Actions/Common/Webhooks/SesIncomingWebhookAction.php` — webhook entry
- `app/Services/Application/Handlers/Email/Ses/IncomingSesWebhookHandler.php` — SNS routing
- `app/Services/Domain/Email/Ses/EventHandlers/{Bounce,Complaint,Delivery}Handler.php`
- `app/Services/Domain/Email/EmailSuppressionService.php` — suppression logic
- `app/Services/Domain/Email/TransactionalEmailTrackingService.php` — wraps sends
- `app/Http/Actions/TransactionMessages/{Resend,Resolve}TransactionMessageAction.php`

## Development

SNS webhook simulator for local testing without AWS infrastructure:
```bash
./docker/development/scripts/sns-webhook-simulator.sh help
./docker/development/scripts/sns-webhook-simulator.sh bounce marketing:10
./docker/development/scripts/sns-webhook-simulator.sh delivery transaction:1
./docker/development/scripts/sns-webhook-simulator.sh list-suppressions
```

Requires `AWS_SNS_VERIFY_SIGNATURE=false` and `SES_SUPPRESSION_ENABLED=true`
in `docker/development/.env` (already set).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Issue modal

Resending creates a new tracking row with a new ses_message_id, so
delivery confirmation updates the new row, not the original failure.
Simplified to two checkboxes: "Resend this email" and "Resolve now".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… sorting

Consolidate delivery issue handling into unified endpoints, replace
per-type resolve/failures routes with a single /delivery-issues API,
and clean up dead code from the prior design.

Backend:
- Add unified delivery-issues endpoints (get, resolve) for both
  announcement and transaction messages
- Add resend-with-email-update support for outgoing messages
- Add retry tracking with ExtractsRetryHeader trait
- Add resolved_at, resolution_type, and retry columns via migrations
- Return updated_at from message resources; allow sorting by it
- Remove dead GetTransactionMessageFailures and
  ResolveTransactionMessage actions/handlers/routes

Frontend:
- Refactor Message Tracking into Announcements and Transactions tabs
  with inline issue filtering, sortable columns, and resolve workflow
- Add Created and Updated date columns, default sort by created_at
  desc so resolving issues doesn't reorder the list
- Add standalone Delivery Issues page with route
- Add Resolve Delivery Issue modal with resend-to-new-address support
- Remove dead useResolveTransactionMessage, useGetTransactionMessageFailures,
  and unused API client methods
- Fix resend mutations to invalidate delivery issues query cache
- Add translations for all supported languages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a subtle refresh icon next to the page title so users can pull
updated delivery statuses without a full browser reload, since SES
webhook status changes arrive asynchronously.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Subtle 90-degree forward-and-back rotation on click to give visual
feedback that the refresh was triggered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant