- Shipped
- September 12, 2026 at 12:04 AM UTC
- Author
- Kamo
- Commit
- 0fcb14a
Addresses are matched trimmed and case-insensitively, and a row the campaign has handed to the relay (sent, in flight, bounced, failed, or retried) is now its permanent record of that address: never deleted, reset or re-queued. - materializeRecipients used to delete every row and rebuild the list on each save, so editing a paused campaign put already-emailed addresses back in the queue. It now keeps history, refreshes rows not yet contacted in place, removes only uncontacted rows that left the list, and never re-adds an address the campaign has contacted, however many times it is added. - The dispatcher claims a row SENDING before the relay sees it. A crash between the relay accepting a message and the row being marked SENT used to leave it QUEUED and send it again; now it stays SENDING and a sweep closes it out as FAILED without retrying. - A recipient added while a campaign was paused never got a release time, so it was never sent and the campaign never finished. Release times now go to any queued row that lacks one. - A startup unique index on (campaign_id, lower(trim(email))) backs the rule at the table. The recipient list endpoint now returns each row's name, merge values, source and whether it was contacted, so reopening and re-saving a draft no longer erases personalization. The From address falls back to the bulk provider's, and the From name is actually sent (it was stored and dropped).