|
2 | 2 |
|
3 | 3 | How to handle bounces, complaints, and address suppression in the Constructive notification system. |
4 | 4 |
|
5 | | -For SQL-level internals (generator source, index details), see the `constructive-db-notifications` skill in constructive-db. |
6 | | - |
7 | | -## Setting Up Provider Webhooks |
8 | | - |
9 | | -### Amazon SES |
10 | | - |
11 | | -1. **Create an SNS topic** for bounce/complaint notifications: |
12 | | - ```bash |
13 | | - aws sns create-topic --name ses-notifications |
14 | | - ``` |
15 | | - |
16 | | -2. **Subscribe your webhook endpoint** to the topic: |
17 | | - ```bash |
18 | | - aws sns subscribe \ |
19 | | - --topic-arn arn:aws:sns:us-east-1:123456789:ses-notifications \ |
20 | | - --protocol https \ |
21 | | - --notification-endpoint https://api.yourapp.com/webhooks/ses |
22 | | - ``` |
23 | | - |
24 | | -3. **Configure SES to publish to the topic:** |
25 | | - ```bash |
26 | | - aws ses set-identity-notification-topic \ |
27 | | - --identity your-domain.com \ |
28 | | - --notification-type Bounce \ |
29 | | - --sns-topic arn:aws:sns:us-east-1:123456789:ses-notifications |
30 | | - |
31 | | - aws ses set-identity-notification-topic \ |
32 | | - --identity your-domain.com \ |
33 | | - --notification-type Complaint \ |
34 | | - --sns-topic arn:aws:sns:us-east-1:123456789:ses-notifications |
35 | | - ``` |
36 | | - |
37 | | -4. **Webhook handler pseudocode:** |
38 | | - ```ts |
39 | | - async function handleSESWebhook(payload) { |
40 | | - const message = JSON.parse(payload.Message); |
41 | | - const messageId = message.mail.messageId; |
42 | | - |
43 | | - // 1. Correlate via provider_message_id |
44 | | - const logRow = await db.query( |
45 | | - `SELECT * FROM notification_delivery_log WHERE provider_message_id = $1`, |
46 | | - [messageId] |
47 | | - ); |
48 | | - |
49 | | - // 2. Update delivery log status |
50 | | - if (message.notificationType === 'Bounce') { |
51 | | - await db.query( |
52 | | - `UPDATE notification_delivery_log SET status = 'bounced', response_payload = $1 WHERE provider_message_id = $2`, |
53 | | - [message, messageId] |
54 | | - ); |
55 | | - |
56 | | - // 3. Suppress hard bounces |
57 | | - if (message.bounce.bounceType === 'Permanent') { |
58 | | - for (const recipient of message.bounce.bouncedRecipients) { |
59 | | - await db.query( |
60 | | - `INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata) |
61 | | - VALUES ($1, 'email', 'hard_bounce', 'ses_webhook', 'ses', $2) |
62 | | - ON CONFLICT (address, channel_type) DO NOTHING`, |
63 | | - [recipient.emailAddress, message] |
64 | | - ); |
65 | | - } |
66 | | - } |
67 | | - } |
68 | | - |
69 | | - if (message.notificationType === 'Complaint') { |
70 | | - await db.query( |
71 | | - `UPDATE notification_delivery_log SET status = 'complained', response_payload = $1 WHERE provider_message_id = $2`, |
72 | | - [message, messageId] |
73 | | - ); |
74 | | - |
75 | | - for (const recipient of message.complaint.complainedRecipients) { |
76 | | - await db.query( |
77 | | - `INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata) |
78 | | - VALUES ($1, 'email', 'complained', 'ses_webhook', 'ses', $2) |
79 | | - ON CONFLICT (address, channel_type) DO NOTHING`, |
80 | | - [recipient.emailAddress, message] |
81 | | - ); |
82 | | - } |
83 | | - } |
84 | | - } |
85 | | - ``` |
86 | | - |
87 | | -### SendGrid |
88 | | - |
89 | | -1. **Configure Event Webhook** in SendGrid dashboard → Settings → Mail Settings → Event Webhook. |
90 | | -2. Set the POST URL to `https://api.yourapp.com/webhooks/sendgrid`. |
91 | | -3. Enable: `bounce`, `dropped`, `spamreport`, `delivered`. |
92 | | - |
93 | | -4. **Webhook handler pseudocode:** |
94 | | - ```ts |
95 | | - async function handleSendGridWebhook(events) { |
96 | | - for (const event of events) { |
97 | | - const messageId = event.sg_message_id?.split('.')[0]; // strip filter suffix |
98 | | - |
99 | | - if (event.event === 'bounce') { |
100 | | - await db.query( |
101 | | - `UPDATE notification_delivery_log SET status = 'bounced', response_payload = $1 WHERE provider_message_id = $2`, |
102 | | - [event, messageId] |
103 | | - ); |
104 | | - await db.query( |
105 | | - `INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata) |
106 | | - VALUES ($1, 'email', 'hard_bounce', 'sendgrid_webhook', 'sendgrid', $2) |
107 | | - ON CONFLICT (address, channel_type) DO NOTHING`, |
108 | | - [event.email, event] |
109 | | - ); |
110 | | - } |
111 | | - |
112 | | - if (event.event === 'spamreport') { |
113 | | - await db.query( |
114 | | - `UPDATE notification_delivery_log SET status = 'complained', response_payload = $1 WHERE provider_message_id = $2`, |
115 | | - [event, messageId] |
116 | | - ); |
117 | | - await db.query( |
118 | | - `INSERT INTO notification_suppressions (address, channel_type, reason, source, provider, metadata) |
119 | | - VALUES ($1, 'email', 'complained', 'sendgrid_webhook', 'sendgrid', $2) |
120 | | - ON CONFLICT (address, channel_type) DO NOTHING`, |
121 | | - [event.email, event] |
122 | | - ); |
123 | | - } |
124 | | - } |
125 | | - } |
126 | | - ``` |
127 | | - |
128 | | -### Twilio (SMS) |
129 | | - |
130 | | -Configure `StatusCallback` URL when sending: |
131 | | - |
132 | | -```ts |
133 | | -const message = await client.messages.create({ |
134 | | - to: '+15551234567', |
135 | | - from: '+15559876543', |
136 | | - body: 'Your verification code is 123456', |
137 | | - statusCallback: 'https://api.yourapp.com/webhooks/twilio' |
138 | | -}); |
139 | | - |
140 | | -// Store message.sid as provider_message_id in delivery_log |
141 | | -``` |
| 5 | +For SQL-level internals (generator source, table DDL, indexes, monitoring queries), see the `constructive-db-notifications` skill in constructive-db. |
142 | 6 |
|
143 | | -Handle `undelivered` and `failed` statuses to suppress undeliverable phone numbers. |
| 7 | +## Delivery Status Lifecycle |
144 | 8 |
|
145 | | -## Suppression List Management |
| 9 | +Notifications move through these statuses as they're processed by the delivery worker: |
146 | 10 |
|
147 | | -### Checking before send |
| 11 | +| Status | Meaning | Set by | |
| 12 | +|--------|---------|--------| |
| 13 | +| `pending` | Created, not yet picked up | Default on insert | |
| 14 | +| `sent` | Provider accepted the message | Delivery worker | |
| 15 | +| `delivered` | Provider confirmed delivery | Webhook handler | |
| 16 | +| `failed` | Delivery attempt failed (bad endpoint, auth error) | Delivery worker | |
| 17 | +| `throttled` | Provider rate-limited the request (retry later) | Delivery worker | |
| 18 | +| `grouped` | Collapsed into a digest batch, no individual send | Digest worker | |
| 19 | +| `bounced` | Provider reported a hard or soft bounce | Webhook handler | |
| 20 | +| `complained` | Recipient marked the message as spam | Webhook handler | |
148 | 21 |
|
149 | | -The delivery worker runs this before every send: |
| 22 | +### Bounced vs Complained |
150 | 23 |
|
151 | | -```sql |
152 | | -SELECT EXISTS ( |
153 | | - SELECT 1 FROM notification_suppressions |
154 | | - WHERE address = $1 AND channel_type = $2 |
155 | | -) AS is_suppressed; |
156 | | -``` |
| 24 | +These are different signals requiring different responses: |
157 | 25 |
|
158 | | -### Viewing recent suppressions |
| 26 | +- **Bounced** — the mailbox doesn't exist, is full, or the domain is unreachable. Hard bounces mean "never send to this address again." |
| 27 | +- **Complained** — the recipient actively clicked "Report Spam." This is a CAN-SPAM/GDPR signal. ISPs penalize complaint rates above 0.1%, so this is more damaging to sender reputation than bounces. |
159 | 28 |
|
160 | | -```sql |
161 | | -SELECT address, channel_type, reason, source, provider, created_at |
162 | | -FROM notification_suppressions |
163 | | -ORDER BY created_at DESC |
164 | | -LIMIT 50; |
165 | | -``` |
| 29 | +Both should result in suppression entries to prevent future sends. |
166 | 30 |
|
167 | | -### Removing a suppression |
| 31 | +## Webhook Ingestion |
168 | 32 |
|
169 | | -When a user re-verifies their email or an admin decides to retry: |
| 33 | +When a provider (SES, SendGrid, Twilio) fires an async webhook for a bounce or complaint, your handler should: |
170 | 34 |
|
171 | | -```sql |
172 | | -DELETE FROM notification_suppressions |
173 | | -WHERE address = 'dan@example.com' AND channel_type = 'email'; |
174 | | -``` |
| 35 | +1. **Correlate** the webhook payload to the delivery_log row using `provider_message_id` (indexed for O(1) lookup) |
| 36 | +2. **Update the delivery_log status** to `bounced` or `complained`, storing the raw webhook payload in `response_payload` |
| 37 | +3. **Insert a suppression entry** for hard bounces and complaints (the unique constraint on `(address, channel_type)` makes this idempotent) |
| 38 | +4. **Update the channel** — increment `failed_count`, set `last_error` and `last_error_at` |
175 | 39 |
|
176 | | -### Bulk export for audit |
| 40 | +### Provider-Specific Notes |
177 | 41 |
|
178 | | -```sql |
179 | | -COPY ( |
180 | | - SELECT address, channel_type, reason, source, provider, created_at |
181 | | - FROM notification_suppressions |
182 | | - ORDER BY created_at |
183 | | -) TO '/tmp/suppressions.csv' WITH CSV HEADER; |
184 | | -``` |
| 42 | +**Amazon SES:** Configure SNS topics for Bounce and Complaint notification types on your sending identity. The webhook payload contains `mail.messageId` for correlation and `bounce.bounceType` (`Permanent` vs `Transient`) to distinguish hard from soft bounces. |
185 | 43 |
|
186 | | -## Channel Auto-Deactivation |
| 44 | +**SendGrid:** Enable Event Webhook for `bounce`, `dropped`, `spamreport`, and `delivered` events. The `sg_message_id` field contains the correlation ID (strip the `.filterXXX` suffix). The `spamreport` event maps to the `complained` status. |
187 | 45 |
|
188 | | -Each `notification_channels` row tracks consecutive failures: |
| 46 | +**Twilio (SMS):** Set a `StatusCallback` URL when sending messages. The `MessageSid` serves as the correlation ID. `undelivered` and `failed` statuses indicate undeliverable phone numbers. |
189 | 47 |
|
190 | | -``` |
191 | | -failed_count: 0 → 1 → 2 → 3 → is_active = false |
192 | | -``` |
| 48 | +## Suppression List |
193 | 49 |
|
194 | | -The delivery worker should: |
195 | | -1. On failure: increment `failed_count`, set `last_error` and `last_error_at` |
196 | | -2. On success: reset `failed_count = 0`, set `last_used_at = now()` |
197 | | -3. When `failed_count` exceeds threshold (e.g., 3): set `is_active = false` |
| 50 | +The `notification_suppressions` table provides address-level blocking. The delivery worker should check this before every send — if an address is suppressed for the given channel type, skip the send entirely. |
198 | 51 |
|
199 | | -This is **per-endpoint** deactivation (expired push tokens, revoked webhooks). Address-level suppression (bounced emails) is handled separately by `notification_suppressions`. |
| 52 | +### Suppression Reasons |
200 | 53 |
|
201 | | -## Monitoring & Troubleshooting |
| 54 | +| Reason | When to insert | Source | |
| 55 | +|--------|---------------|--------| |
| 56 | +| `hard_bounce` | Provider reported a permanent bounce | Auto (webhook handler) | |
| 57 | +| `complained` | Recipient reported spam | Auto (webhook handler) | |
| 58 | +| `manual` | Admin manually suppressed an address | Manual (admin action) | |
| 59 | +| `unsubscribed` | User unsubscribed from all notifications via that channel | Manual (user action) | |
202 | 60 |
|
203 | | -### Delivery success rate (last 24h) |
| 61 | +### Lifecycle |
204 | 62 |
|
205 | | -```sql |
206 | | -SELECT channel_type, status, COUNT(*) AS cnt, |
207 | | - ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY channel_type), 1) AS pct |
208 | | -FROM notification_delivery_log |
209 | | -WHERE created_at > now() - interval '24 hours' |
210 | | -GROUP BY channel_type, status |
211 | | -ORDER BY channel_type, cnt DESC; |
212 | | -``` |
| 63 | +- **Add:** Webhook handler inserts on hard bounce or complaint. The unique constraint on `(address, channel_type)` makes repeated inserts idempotent. |
| 64 | +- **Remove:** When a user re-verifies their email or an admin decides to retry, delete the suppression entry. |
| 65 | +- The suppression table is in the **private schema** — it's not exposed via GraphQL. Management is done through the delivery worker and admin tooling. |
213 | 66 |
|
214 | | -### Complaint rate (should be < 0.1%) |
215 | | - |
216 | | -```sql |
217 | | -SELECT |
218 | | - COUNT(*) FILTER (WHERE status = 'complained') AS complaints, |
219 | | - COUNT(*) FILTER (WHERE status IN ('delivered', 'sent')) AS total_sent, |
220 | | - ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'complained') |
221 | | - / NULLIF(COUNT(*) FILTER (WHERE status IN ('delivered', 'sent')), 0), 3) AS complaint_rate_pct |
222 | | -FROM notification_delivery_log |
223 | | -WHERE channel_type = 'email' |
224 | | - AND created_at > now() - interval '7 days'; |
225 | | -``` |
| 67 | +## Channel Auto-Deactivation |
226 | 68 |
|
227 | | -### Channels approaching deactivation |
| 69 | +Each `notification_channels` row tracks consecutive delivery failures via `failed_count`: |
228 | 70 |
|
229 | | -```sql |
230 | | -SELECT id, owner_id, channel_type, endpoint, failed_count, last_error, last_error_at |
231 | | -FROM notification_channels |
232 | | -WHERE failed_count > 0 AND is_active = true |
233 | | -ORDER BY failed_count DESC; |
| 71 | +``` |
| 72 | +failed_count: 0 → 1 → 2 → 3 → is_active = false |
234 | 73 | ``` |
235 | 74 |
|
236 | | -### Debug a specific delivery |
| 75 | +The delivery worker should: |
| 76 | +1. **On failure:** increment `failed_count`, set `last_error` and `last_error_at` |
| 77 | +2. **On success:** reset `failed_count = 0`, set `last_used_at` |
| 78 | +3. **When threshold exceeded** (e.g., 3): set `is_active = false` |
237 | 79 |
|
238 | | -```sql |
239 | | -SELECT status, provider, provider_message_id, error, response_payload, attempt_count, sent_at |
240 | | -FROM notification_delivery_log |
241 | | -WHERE notification_id = $1 |
242 | | -ORDER BY created_at; |
243 | | -``` |
| 80 | +This handles **per-endpoint** deactivation (expired push tokens, revoked webhook URLs). Address-level suppression (hard-bounced emails) is a separate concern handled by `notification_suppressions`. |
| 81 | + |
| 82 | +## Key Metrics to Monitor |
| 83 | + |
| 84 | +- **Delivery rate** — percentage of `sent` that reach `delivered`. Below 95% warrants investigation. |
| 85 | +- **Bounce rate** — hard bounces as a percentage of total sends. Above 2% risks ISP throttling. |
| 86 | +- **Complaint rate** — `complained` as a percentage of `delivered`. Above 0.1% triggers ISP penalties and potential account suspension. |
| 87 | +- **Channels approaching deactivation** — channels with `failed_count > 0` and `is_active = true` indicate endpoints that may need attention. |
0 commit comments