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
+3
View File
@@ -646,3 +646,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-28] The "senaste bokförda verifikat" line in the balans-/resultatrapport header (#1267) reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that provably drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails (the reversal path in engine.ts does exactly that), delete_last_voucher decrements blindly by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind MAX. Since the whole point of the line is avstämning, printing an allocated number would send a reconciler chasing a gap that does not exist, so the label states plainly that the number is the last posted one. Scoped to the report's own date range rather than the fiscal year, so a Q1 report printed in November says something true about Q1; the balansrapport keeps the fiscal-year start as its lower bound because it accumulates. Skipped entirely on a dimension-filtered resultatrapport: that report already discloses it is partial, and an unfiltered voucher range beside a filtered result invites the wrong conclusion. No new i18n keys: both report views and the PDF template are hard-coded Swedish, per the "stays Swedish" report surfaces in .claude/rules/i18n.md, so the issue's acceptance criterion asking for sv+en strings does not apply here.
[2026-07-28] Recurring-schedule dims PR ships API/MCP/generator only, no schedule-dialog pickers: UI needs visual sign-off per house rule; substrate stops the cron-spawned-invoices-born-untagged leak now.
[2026-07-29] Retired the generic design skills now that emilkowalski/skills is installed globally (animation-vocabulary, apple-design, emil-design-eng, find-animation-opportunities, improve-animations, pick-ui-library, prototype, review-animations in ~/.claude/skills). Deleted .claude/skills/mobile-ux-core (52 lines of universal mobile UX whose file triggers are *.dart/*.swift/*Activity.kt, paths that do not exist in this repo; superseded by design.md's accessibility section plus apple-design) and .claude/skills/scout-design (a design scan that filed Linear tickets via mcp__claude_ai_Linear__save_issue, while this project files GitHub issues and loop-design-scan is the same scan with the right output; loop-design-scan's sibling reference updated). Kept web-design-guidelines: it is a Vercel-plugin symlink, cheap to keep, and may regenerate anyway. Also removed the global ui-ux-pro-max skill, a 67-style/96-palette catalogue that pulls against a locked editorial-monochrome system.
[2026-07-29] Consent-expiry follow-up sent from invoiceservice@arcim.io, not a new sender: matching the address the original batch came from lets the two mails corroborate each other; RESEND_FROM_EMAIL alignment to accounted.se stays a separate ops task.
@@ -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
@@ -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}`
}