feat: add customizable contact double opt-in flow (#350)

* feat: add customizable contact double opt-in flow

* test: add double opt-in service coverage

* fix: address review comments for double opt-in PR

- Make pending status conditional on doubleOptInEnabled flag
- Backfill legacy unsubscribeReason for reliable pending detection
- Add doubleOptInContent to contact book listing select
- Fix duplicate toast on DOI editor subject save failure
- Harden searchParams parsing against string[] values
- Make default DOI template use link mark for clickable URL
- Make public API create+update atomic via transaction
- Prevent contact upsert failure when DOI email send fails
- Fix empty string template variable replacement

Co-authored-by: opencode <opencode@anthropic.com>

* fix: harden double opt-in confirmation safeguards

Preserve explicit unsubscribe intent in DOI flows and prevent confirmation links from re-subscribing opted-out contacts. Also sanitize subscribe-page error messaging and use timing-safe hash comparison for link verification.

* ui stuff

* fix: require doubleOptInUrl in double opt-in templates

* feat: add configurable from address for double opt-in emails

* feat: add resend confirmation flow for pending contacts

* fix: move subscribe confirmation to explicit POST flow

* test: add contact book public API endpoint coverage

* docs: add double opt-in documentation and update OpenAPI spec

Add a user guide for the double opt-in feature covering setup, contact
statuses, email customization, template variables, and best practices.
Update the OpenAPI spec to include doubleOptIn fields in all contactBook
request/response schemas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: opencode <opencode@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
KM Koushik
2026-03-01 00:34:20 +11:00
committed by GitHub
parent edcd32a4ea
commit e3e9635a5f
27 changed files with 3500 additions and 288 deletions
+68
View File
@@ -1095,6 +1095,28 @@
"description": "The emoji associated with the contact book",
"example": "📙"
},
"doubleOptInEnabled": {
"type": "boolean",
"description": "Whether double opt-in is enabled for new contacts",
"example": true
},
"doubleOptInFrom": {
"type": "string",
"nullable": true,
"description": "From address used for double opt-in emails (must use a verified domain)",
"example": "Newsletter <hello@example.com>"
},
"doubleOptInSubject": {
"type": "string",
"nullable": true,
"description": "Subject line used for double opt-in confirmation email",
"example": "Please confirm your subscription"
},
"doubleOptInContent": {
"type": "string",
"nullable": true,
"description": "Email editor JSON content used for double opt-in confirmation"
},
"createdAt": {
"type": "string",
"description": "The creation timestamp"
@@ -1142,6 +1164,23 @@
"properties": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"doubleOptInEnabled": {
"type": "boolean",
"description": "Whether double opt-in is enabled for new contacts"
},
"doubleOptInFrom": {
"type": "string",
"nullable": true,
"description": "From address used for double opt-in emails (must use a verified domain)"
},
"doubleOptInSubject": {
"type": "string",
"description": "Subject line used for double opt-in confirmation email"
},
"doubleOptInContent": {
"type": "string",
"description": "Email editor JSON content used for double opt-in confirmation"
}
},
"required": ["name"]
@@ -1165,6 +1204,10 @@
"additionalProperties": { "type": "string" }
},
"emoji": { "type": "string" },
"doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" },
"doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" },
"doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" },
"doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" },
"createdAt": { "type": "string" },
"updatedAt": { "type": "string" }
},
@@ -1210,6 +1253,10 @@
"additionalProperties": { "type": "string" }
},
"emoji": { "type": "string" },
"doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" },
"doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" },
"doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" },
"doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" },
"createdAt": { "type": "string" },
"updatedAt": { "type": "string" },
"_count": {
@@ -1279,6 +1326,23 @@
"properties": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"doubleOptInEnabled": {
"type": "boolean",
"description": "Whether double opt-in is enabled for new contacts"
},
"doubleOptInFrom": {
"type": "string",
"nullable": true,
"description": "From address used for double opt-in emails (must use a verified domain)"
},
"doubleOptInSubject": {
"type": "string",
"description": "Subject line used for double opt-in confirmation email"
},
"doubleOptInContent": {
"type": "string",
"description": "Email editor JSON content used for double opt-in confirmation"
}
}
}
@@ -1301,6 +1365,10 @@
"additionalProperties": { "type": "string" }
},
"emoji": { "type": "string" },
"doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" },
"doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" },
"doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" },
"doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" },
"createdAt": { "type": "string" },
"updatedAt": { "type": "string" }
},