Files
accounted/extensions/general/email/__tests__/resend-service.test.ts
T
MattssonandClaude Fable 5 0040cadacc feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)
* feat(invoicing): opt-in invoice email from the company's own sending domain

Companies holding the custom_sender_domain capability grant can register
their own domain (Resend sending-only profile), publish DKIM/SPF, and once
verified every invoice email (send, reminders, recurring, payment
confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>"
instead of the platform sender. Reply-To is unchanged.

- New table company_sending_domains (RLS: members read, owner/admin write;
  audit trigger), types, archive-export classification.
- New capability key custom_sender_domain: manually granted per company,
  deliberately outside PAID_CAPABILITIES (never trial-seeded, never written
  by the Stripe sync). Without the grant the settings section is hidden and
  nothing changes.
- Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify),
  Resend domain lifecycle without orphan adoption, domain.updated handling
  on the delivery webhook, explicit From support in the Resend adapter.
- Core resolveInvoiceSender(): verified + enabled + entitled, else the
  platform sender; never throws.
- Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en).
- Unit tests for the resolver, domain helpers, routes, From header; pg-real
  test for RLS and constraints.

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

* fix(invoicing): harden sending-domain writes, sender fallback, review findings

Skeptic refutations:
- Tenant JWTs could insert/update company_sending_domains with status =
  'verified' and an arbitrary domain through PostgREST (RLS only checked
  membership), then send invoice mail as that domain. New migration
  20260822130000 adds a BEFORE trigger: tenants may only open a pending
  claim and edit sender_local_part/sender_name/enabled; domain and
  verification state are service-role only. claim/verify helpers now take
  a service-role writer for those columns; the route's RLS client still
  does the insert.
- A company domain Resend later rejects made every invoice send fail: the
  Resend adapter retries once as the platform sender when an explicit
  company From is rejected (nothing was sent, so no double send).

Review findings:
- domain.updated webhook: discriminated outcome; DB errors answer 500 so
  Svix retries, unknown domains are acknowledged.
- Display names are RFC 5322-quoted only when they carry specials.
- Sender local part is a strict dot-atom (no trailing/consecutive dots),
  in code and in the CHECK constraint; resend_domain_id index is UNIQUE.
- IME composition guard on the claim input; event bus reset in tests;
  settings section skips its request for non-admins.

Deferred (needs a product call): persisting the effective From address in
the invoice delivery log touches the hardened evidence triggers; recorded
in DECISIONS.md.

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

* fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test

Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a
tenant could delete and re-insert its pending row under the same id with a
reserved domain, and the service-role writer updated by id alone. Now:
- the claim's verification-state write filters on (id, company_id, domain,
  resend_domain_id IS NULL) and rolls back on zero rows;
- verify and the domain.updated webhook compare Resend's domain name with
  the row before writing verified;
- resolveInvoiceSender refuses reserved platform domains and non-hostnames
  at send time (reserved-domain logic moved to lib/email/domain-name.ts and
  shared with the claim validator).

pg-real: the case-insensitive uniqueness assertion now expects the
domain_shape CHECK (lowercase enforced) for an uppercase variant and the
unique index for a same-case duplicate.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:07:30 +02:00

144 lines
6.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events'
import {
buildFromHeader,
encodeDisplayName,
ResendEmailService,
} from '@/extensions/general/email/lib/resend-service'
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted' }),
}))
const { sendMock } = vi.hoisted(() => {
// RESEND_FROM_EMAIL / RESEND_API_KEY are read at module load; set them
// before the service module is evaluated.
process.env.RESEND_FROM_EMAIL = 'noreply@platform.example'
process.env.RESEND_API_KEY = 'test-key'
return { sendMock: vi.fn() }
})
vi.mock('resend', () => ({
Resend: class {
emails = { send: sendMock }
},
}))
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('buildFromHeader', () => {
it('renders the platform default with the company name "via" the app', () => {
expect(buildFromHeader({ fromName: 'Hans Bolag AB' })).toBe(
'Hans Bolag AB via Accounted <noreply@platform.example>',
)
})
it('renders the bare app sender without a company name', () => {
expect(buildFromHeader({})).toBe('Accounted <noreply@platform.example>')
})
it('renders an explicit sender as "<name> <address>" with no "via"', () => {
expect(
buildFromHeader({ fromName: 'ignored', from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' } }),
).toBe('Hans Bolag AB <faktura@hansbolag.example>')
})
it('strips header-injection characters from the explicit name', () => {
expect(
buildFromHeader({ from: { name: 'Hans <Bolag>\r\nBcc: x', address: 'faktura@hansbolag.example' } }),
).toBe('"Hans BolagBcc: x" <faktura@hansbolag.example>') // CR/LF/<> stripped; ':' forces quoting
})
it('quotes a display name only when it carries RFC 5322 specials, escaping quotes and backslashes', () => {
expect(encodeDisplayName('Hans Bolag AB')).toBe('Hans Bolag AB')
expect(encodeDisplayName('Hans "Bolag", AB')).toBe('"Hans \\"Bolag\\", AB"')
expect(encodeDisplayName('Back\\slash')).toBe('"Back\\\\slash"')
expect(buildFromHeader({ from: { name: 'Hans Bolag, AB', address: 'faktura@hansbolag.example' } })).toBe(
'"Hans Bolag, AB" <faktura@hansbolag.example>',
)
// Platform path: a comma in the company name used to yield an ambiguous
// mailbox list; plain names are byte-identical to before.
expect(buildFromHeader({ fromName: 'Hans Bolag, AB' })).toBe(
'"Hans Bolag, AB via Accounted" <noreply@platform.example>',
)
})
it('falls back to the platform sender when the explicit address is malformed', () => {
expect(buildFromHeader({ fromName: 'Hans Bolag AB', from: { name: 'Hans', address: 'not an address' } })).toBe(
'Hans Bolag AB via Accounted <noreply@platform.example>',
)
expect(buildFromHeader({ fromName: 'Hans Bolag AB', from: { name: ' ', address: 'faktura@hansbolag.example' } })).toBe(
'Hans Bolag AB via Accounted <noreply@platform.example>',
)
})
})
describe('ResendEmailService.sendEmail', () => {
const service = new ResendEmailService()
const base = { to: 'kund@example.com', subject: 'Faktura 1', html: '<p>x</p>', fromName: 'Hans Bolag AB' }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
sendMock.mockReset()
})
it('sends once as the platform sender when no explicit From is given', async () => {
sendMock.mockResolvedValue({ data: { id: 'msg_1' }, error: null })
const result = await service.sendEmail(base)
expect(result).toEqual({ success: true, provider: 'resend', messageId: 'msg_1' })
expect(sendMock).toHaveBeenCalledTimes(1)
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB via Accounted <noreply@platform.example>')
})
it('sends as the company sender when Resend accepts it', async () => {
sendMock.mockResolvedValue({ data: { id: 'msg_2' }, error: null })
const result = await service.sendEmail({
...base,
from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' },
})
expect(result.success).toBe(true)
expect(sendMock).toHaveBeenCalledTimes(1)
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB <faktura@hansbolag.example>')
})
it('retries once as the platform sender when Resend rejects the company sender', async () => {
sendMock
.mockResolvedValueOnce({ data: null, error: { message: 'The hansbolag.example domain is not verified' } })
.mockResolvedValueOnce({ data: { id: 'msg_3' }, error: null })
const result = await service.sendEmail({
...base,
from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' },
})
expect(result).toEqual({ success: true, provider: 'resend', messageId: 'msg_3' })
expect(sendMock).toHaveBeenCalledTimes(2)
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB <faktura@hansbolag.example>')
expect(sendMock.mock.calls[1][0].from).toBe('Hans Bolag AB via Accounted <noreply@platform.example>')
// Same recipients and content on the retry.
expect(sendMock.mock.calls[1][0].to).toEqual(['kund@example.com'])
expect(sendMock.mock.calls[1][0].subject).toBe('Faktura 1')
})
it('does not retry a platform-sender failure (nothing to fall back to)', async () => {
sendMock.mockResolvedValue({ data: null, error: { message: 'invalid recipient' } })
const result = await service.sendEmail(base)
expect(result).toEqual({ success: false, provider: 'resend', error: 'invalid recipient' })
expect(sendMock).toHaveBeenCalledTimes(1)
})
it('reports the platform-sender error when the fallback also fails', async () => {
sendMock
.mockResolvedValueOnce({ data: null, error: { message: 'domain not verified' } })
.mockResolvedValueOnce({ data: null, error: { message: 'rate limited' } })
const result = await service.sendEmail({
...base,
from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' },
})
expect(result).toEqual({ success: false, provider: 'resend', error: 'rate limited' })
expect(sendMock).toHaveBeenCalledTimes(2)
})
})