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
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest'
import {
generateConsentExpiryEmailHtml,
generateConsentExpiryEmailText,
generateConsentExpiryEmailSubject,
} from '@/lib/email/consent-notification-templates'
import { getBranding } from '@/lib/branding/service'
const expired = {
bankName: 'SEB',
daysUntilExpiry: 0,
renewalUrl: 'https://app.gnubok.se/settings/banking',
companyName: 'Glimworks AB',
isExpired: true,
}
const expiringSoon = {
...expired,
daysUntilExpiry: 3,
isExpired: false,
}
describe('consent expiry email templates', () => {
it('signs off as the app, never as the recipient company', () => {
const html = generateConsentExpiryEmailHtml(expired)
const text = generateConsentExpiryEmailText(expired)
const { appName } = getBranding()
expect(html).toContain(`Med vänliga hälsningar,<br>\n <strong>${appName}</strong>`)
expect(text).toContain(`Med vänliga hälsningar,\n${appName}`)
expect(html).not.toContain('Med vänliga hälsningar,<br>\n <strong>Glimworks')
})
it('uses no alarm colors in the chrome', () => {
for (const data of [expired, expiringSoon]) {
const html = generateConsentExpiryEmailHtml(data)
expect(html).not.toMatch(/#dc2626|#ea580c|#ef4444|#b91c1c/i)
expect(html).not.toContain('Åtgärd krävs')
}
})
it('names the bank, the company, and the destination URL', () => {
const html = generateConsentExpiryEmailHtml(expired)
expect(html).toContain('SEB')
expect(html).toContain('Glimworks AB')
// The URL is shown as plain text next to the button so the recipient can
// verify where it leads before clicking.
const urlMentions = html.split(expired.renewalUrl).length - 1
expect(urlMentions).toBeGreaterThanOrEqual(2)
})
it('explains why the recipient got the email', () => {
const html = generateConsentExpiryEmailHtml(expired)
const text = generateConsentExpiryEmailText(expired)
const { supportEmail } = getBranding()
expect(html).toContain('Du får det här mejlet eftersom')
expect(html).toContain(supportEmail)
expect(text).toContain('Du får det här mejlet eftersom')
expect(text).toContain(supportEmail)
})
it('omits the company row cleanly when no company name is available', () => {
const data = { ...expired, companyName: '' }
const html = generateConsentExpiryEmailHtml(data)
const text = generateConsentExpiryEmailText(data)
expect(html).not.toContain('Företag')
expect(html).not.toContain('för </')
expect(text).not.toContain('Företag:')
expect(generateConsentExpiryEmailSubject(data)).toBe('Förnya bankkopplingen till SEB')
})
it('generates calm, specific subjects', () => {
expect(generateConsentExpiryEmailSubject(expired)).toBe(
'Förnya bankkopplingen till SEB - Glimworks AB'
)
expect(generateConsentExpiryEmailSubject(expiringSoon)).toBe(
'Bankkopplingen till SEB löper ut om 3 dagar - Glimworks AB'
)
expect(generateConsentExpiryEmailSubject({ ...expiringSoon, daysUntilExpiry: 1 })).toBe(
'Bankkopplingen till SEB löper ut om 1 dag - Glimworks AB'
)
})
it('pluralizes days in the expiring-soon body', () => {
const html = generateConsentExpiryEmailHtml({ ...expiringSoon, daysUntilExpiry: 1 })
expect(html).toContain('löper ut om 1 dag')
const html3 = generateConsentExpiryEmailHtml(expiringSoon)
expect(html3).toContain('löper ut om 3 dagar')
})
})
+94 -49
View File
@@ -4,20 +4,50 @@ export interface ConsentExpiryEmailData {
bankName: string
daysUntilExpiry: number
renewalUrl: string
/** The company the bank connection belongs to. Shown so the recipient knows
* which of their companies the email concerns; never used as the sender. */
companyName: string
isExpired: boolean
}
/**
* Consent expiry notification emails.
*
* Tone and layout are deliberately calm: a PSD2 consent running out is
* routine, not an incident. No red, no urgency chrome; the email is signed
* by the app (never the recipient's own company), states plainly why the
* recipient got it, and shows the destination URL in plain text next to the
* button so it does not pattern-match phishing.
*/
const SERIF = `Georgia, 'Times New Roman', serif`
const SANS = `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif`
function dagar(n: number): string {
return `${n} ${n === 1 ? 'dag' : 'dagar'}`
}
/**
* Generate HTML email for consent expiry notification
*/
export function generateConsentExpiryEmailHtml(data: ConsentExpiryEmailData): string {
const { bankName, daysUntilExpiry, renewalUrl, companyName, isExpired } = data
const { appName } = getBranding()
const headerColor = isExpired ? '#dc2626' : '#ea580c'
const { appName, supportEmail } = getBranding()
const title = isExpired
? 'Banksynkronisering har stoppats'
: `Samtycket för ${bankName} löper ut snart`
? 'Bankkopplingen behöver förnyas'
: `Bankkopplingen löper ut om ${dagar(daysUntilExpiry)}`
const intro = isExpired
? `Banksamtycket för <strong>${bankName}</strong> har löpt ut och den automatiska hämtningen av nya transaktioner är pausad.`
: `Banksamtycket för <strong>${bankName}</strong> löper ut om ${dagar(daysUntilExpiry)}.`
const explanation =
'Det här är väntat: av säkerhetsskäl gäller ett banksamtycke (PSD2) bara en begränsad tid, och därefter behöver det godkännas på nytt hos banken.'
const consequence = isExpired
? 'Ingenting har försvunnit. Redan hämtade transaktioner och din bokföring påverkas inte, och när kopplingen är förnyad hämtas mellanliggande transaktioner ikapp.'
: 'Förnya gärna i förväg så fortsätter transaktionerna att hämtas utan avbrott. Din bokföring påverkas inte.'
return `
<!DOCTYPE html>
@@ -27,47 +57,54 @@ export function generateConsentExpiryEmailHtml(data: ConsentExpiryEmailData): st
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f9fafb;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="background: white; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: ${headerColor}15; color: ${headerColor}; padding: 8px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
${isExpired ? 'Åtgärd krävs' : 'Påminnelse'}
</div>
<body style="margin: 0; padding: 0; font-family: ${SANS}; line-height: 1.6; color: #374151; background-color: #f5f4f1;">
<div style="max-width: 560px; margin: 0 auto; padding: 40px 20px;">
<div style="background: #ffffff; border: 1px solid #e7e5e0; border-radius: 12px; padding: 40px;">
<div style="font-family: ${SERIF}; font-size: 19px; color: #111111; margin-bottom: 28px;">
${appName}
</div>
<h1 style="margin: 0 0 20px 0; font-size: 22px; font-weight: 600; color: #111; text-align: center;">
<h1 style="margin: 0 0 16px 0; font-family: ${SERIF}; font-size: 23px; font-weight: 400; color: #111111; line-height: 1.3;">
${title}
</h1>
<div style="margin-bottom: 30px;">
${isExpired ? `
<p style="margin: 0 0 15px 0; color: #dc2626; font-weight: 500;">
PSD2-samtycket för ${bankName} har löpt ut. Automatisk transaktionssynkronisering är stoppad.
</p>
<p style="margin: 0 0 15px 0;">
Förnya anslutningen för att återuppta synkroniseringen.
</p>
` : `
<p style="margin: 0 0 15px 0;">
Samtycket för ${bankName} löper ut om ${daysUntilExpiry} ${daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
</p>
<p style="margin: 0 0 15px 0;">
Förnya anslutningen innan samtycket löper ut för att undvika avbrott i transaktionssynkroniseringen.
</p>
`}
</div>
<p style="margin: 0 0 14px 0; font-size: 15px;">${intro}</p>
<p style="margin: 0 0 14px 0; font-size: 15px;">${explanation}</p>
<p style="margin: 0 0 28px 0; font-size: 15px;">${consequence}</p>
<div style="text-align: center; margin-bottom: 30px;">
<a href="${renewalUrl}" style="display: inline-block; background: ${headerColor}; color: white; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 500; font-size: 14px;">
${isExpired ? 'Förnya anslutning' : 'Hantera bankanslutningar'}
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 28px; font-size: 14px;">
<tr>
<td style="padding: 8px 0; border-top: 1px solid #ececea; color: #9ca3af; width: 90px;">Bank</td>
<td style="padding: 8px 0; border-top: 1px solid #ececea; color: #111111;">${bankName}</td>
</tr>
${companyName ? `
<tr>
<td style="padding: 8px 0; border-top: 1px solid #ececea; border-bottom: 1px solid #ececea; color: #9ca3af;">Företag</td>
<td style="padding: 8px 0; border-top: 1px solid #ececea; border-bottom: 1px solid #ececea; color: #111111;">${companyName}</td>
</tr>
` : ''}
</table>
<div style="margin-bottom: 12px;">
<a href="${renewalUrl}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 26px; border-radius: 99px; text-decoration: none; font-weight: 500; font-size: 14px;">
Förnya bankkopplingen
</a>
</div>
<div style="padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; color: #666; font-size: 14px;">
<p style="margin: 0 0 32px 0; font-size: 13px; color: #9ca3af;">
Knappen leder till ${renewalUrl}.<br>
Du kan också logga in som vanligt och gå till Inställningar och sedan Bank.
</p>
<div style="padding-top: 20px; border-top: 1px solid #ececea;">
<p style="margin: 0 0 12px 0; font-size: 14px; color: #374151;">
Med vänliga hälsningar,<br>
<strong>${companyName || appName.toLowerCase()}</strong>
<strong>${appName}</strong>
</p>
<p style="margin: 0; font-size: 12.5px; color: #9ca3af;">
Du får det här mejlet eftersom det finns en bankkoppling i ${appName}${companyName ? ` för ${companyName}` : ''}.
Undrar du något? Mejla <a href="mailto:${supportEmail}" style="color: #6b7280;">${supportEmail}</a>.
</p>
</div>
</div>
@@ -82,26 +119,33 @@ export function generateConsentExpiryEmailHtml(data: ConsentExpiryEmailData): st
*/
export function generateConsentExpiryEmailText(data: ConsentExpiryEmailData): string {
const { bankName, daysUntilExpiry, renewalUrl, companyName, isExpired } = data
const { appName } = getBranding()
const { appName, supportEmail } = getBranding()
let text = ''
if (isExpired) {
text += `BANKSYNKRONISERING HAR STOPPATS\n`
text += `=`.repeat(40) + `\n\n`
text += `PSD2-samtycket för ${bankName} har löpt ut.\n`
text += `Automatisk transaktionssynkronisering är stoppad.\n\n`
text += `Förnya anslutningen för att återuppta synkroniseringen.\n\n`
text += `Bankkopplingen behöver förnyas\n\n`
text += `Banksamtycket för ${bankName} har löpt ut och den automatiska hämtningen av nya transaktioner är pausad.\n\n`
} else {
text += `SAMTYCKET FÖR ${bankName.toUpperCase()} LÖPER UT SNART\n`
text += `=`.repeat(40) + `\n\n`
text += `Samtycket för ${bankName} löper ut om ${daysUntilExpiry} ${daysUntilExpiry === 1 ? 'dag' : 'dagar'}.\n\n`
text += `Förnya anslutningen innan samtycket löper ut för att undvika avbrott.\n\n`
text += `Bankkopplingen löper ut om ${dagar(daysUntilExpiry)}\n\n`
text += `Banksamtycket för ${bankName} löper ut om ${dagar(daysUntilExpiry)}.\n\n`
}
text += `Hantera bankanslutningar: ${renewalUrl}\n\n`
text += `Det här är väntat: av säkerhetsskäl gäller ett banksamtycke (PSD2) bara en begränsad tid, och därefter behöver det godkännas på nytt hos banken.\n\n`
if (isExpired) {
text += `Ingenting har försvunnit. Redan hämtade transaktioner och din bokföring påverkas inte, och när kopplingen är förnyad hämtas mellanliggande transaktioner ikapp.\n\n`
} else {
text += `Förnya gärna i förväg så fortsätter transaktionerna att hämtas utan avbrott. Din bokföring påverkas inte.\n\n`
}
text += `Bank: ${bankName}\n`
if (companyName) text += `Företag: ${companyName}\n`
text += `\nFörnya bankkopplingen: ${renewalUrl}\n`
text += `Du kan också logga in som vanligt och gå till Inställningar och sedan Bank.\n\n`
text += `Med vänliga hälsningar,\n`
text += `${companyName || appName.toLowerCase()}\n`
text += `${appName}\n\n`
text += `Du får det här mejlet eftersom det finns en bankkoppling i ${appName}${companyName ? ` för ${companyName}` : ''}. Undrar du något? Mejla ${supportEmail}.\n`
return text
}
@@ -110,8 +154,9 @@ export function generateConsentExpiryEmailText(data: ConsentExpiryEmailData): st
* Generate email subject for consent expiry notification
*/
export function generateConsentExpiryEmailSubject(data: ConsentExpiryEmailData): string {
const suffix = data.companyName ? ` - ${data.companyName}` : ''
if (data.isExpired) {
return `Banksynkronisering stoppad - ${data.bankName}`
return `Förnya bankkopplingen till ${data.bankName}${suffix}`
}
return `Banksamtycke löper ut om ${data.daysUntilExpiry} ${data.daysUntilExpiry === 1 ? 'dag' : 'dagar'} - ${data.bankName}`
return `Bankkopplingen till ${data.bankName} löper ut om ${dagar(data.daysUntilExpiry)}${suffix}`
}