fix(email): calm, correctly-signed consent expiry notification (#1276)

* fix(email): calm, correctly-signed consent expiry notification

The consent expiry email was signed with the recipient's own company
name instead of the app, used red alarm chrome (header pill + button),
and never said why the recipient got it. After the #1271 health probe
drained a backlog of 25 dead sessions in one 05:00 cron run, that
design read as phishing to a batch of users at once.

- Sign off as the app; the company the connection belongs to moves
  into a details row and the why-did-I-get-this footer
- Drop all red/orange chrome; neutral editorial layout, pill button
- Explain that PSD2 consent expiry is routine and that no data is lost
- Show the destination URL as plain text next to the button
- Calmer subjects (renewal framing instead of 'synkronisering stoppad')
- Reply-to support instead of dead-ending at noreply
- Add template tests (was untested)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): pause consent expiry emails behind env flag

Founder call 2026-07-29: the in-app surfaces already flag a dead
connection, so the cron email adds noise. Status transitions keep
running; set BANK_CONSENT_EXPIRY_EMAILS=true to resume sending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-29 10:20:17 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 80a14ddfd2
commit 0df05c83c6
4 changed files with 196 additions and 50 deletions
@@ -428,6 +428,11 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
/**
* Send consent expiry notification email.
* Guards with last_expiry_notification_at to avoid spamming (2-day cooldown).
*
* Paused by default (founder call 2026-07-29, after the probe backlog drain
* mass-emailed 24 users at once): the settings panel and attention surfaces
* already flag a dead connection in-app. Set BANK_CONSENT_EXPIRY_EMAILS=true
* to resume sending; the status transitions below run either way.
*/
async function sendConsentExpiryNotification(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -438,6 +443,8 @@ async function sendConsentExpiryNotification(
baseUrl: string
): Promise<void> {
try {
if (process.env.BANK_CONSENT_EXPIRY_EMAILS !== 'true') return
// Check cooldown: skip if notified within last 2 days
const lastNotified = connection.last_expiry_notification_at as string | null
if (lastNotified) {
@@ -465,7 +472,7 @@ async function sendConsentExpiryNotification(
bankName: connection.bank_name as string,
daysUntilExpiry: daysLeft,
renewalUrl: `${baseUrl}/settings/banking`,
companyName: companySettings?.company_name || getBranding().appName.toLowerCase(),
companyName: companySettings?.company_name || '',
isExpired,
}
@@ -474,6 +481,7 @@ async function sendConsentExpiryNotification(
subject: generateConsentExpiryEmailSubject(emailData),
html: generateConsentExpiryEmailHtml(emailData),
text: generateConsentExpiryEmailText(emailData),
replyTo: getBranding().supportEmail,
})
// Update last notification timestamp