Files
accounted/extensions/general/email/__tests__/sending-domains.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

428 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
validateClaimableSendingDomain,
mapResendSendingStatus,
isSendingOnlyProfile,
claimSendingDomain,
checkSendingDomainVerification,
updateSendingDomainSettings,
removeSendingDomain,
applySendingDomainStatusFromWebhook,
} from '@/extensions/general/email/lib/sending-domains'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
const { domainsMock } = vi.hoisted(() => ({
domainsMock: {
create: vi.fn(),
get: vi.fn(),
verify: vi.fn(),
remove: vi.fn(),
list: vi.fn(),
},
}))
vi.mock('resend', () => ({
Resend: class {
domains = domainsMock
},
}))
const DKIM_RECORD = {
record: 'DKIM',
name: 'resend._domainkey.hansbolag.example',
value: 'p=MIGf...',
type: 'TXT',
ttl: 'Auto',
status: 'not_started',
}
const SENDING_ONLY = { sending: 'enabled', receiving: 'disabled' }
const ROW = {
id: 'row-1',
company_id: 'company-1',
domain: 'hansbolag.example',
status: 'pending',
sender_local_part: 'faktura',
sender_name: null,
enabled: true,
resend_domain_id: 'rd_1',
dns_records: [DKIM_RECORD],
verified_at: null,
last_checked_at: null,
created_at: '2026-08-22T00:00:00Z',
updated_at: '2026-08-22T00:00:00Z',
}
function sb(): ReturnType<typeof createQueuedMockSupabase> & { client: SupabaseClient } {
const m = createQueuedMockSupabase()
return Object.assign(m, { client: m.supabase as unknown as SupabaseClient })
}
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_API_KEY = 'test-key'
process.env.RESEND_FROM_EMAIL = 'noreply@platform.example'
process.env.RESEND_INBOUND_DOMAIN = 'inbox.platform.example'
process.env.NEXT_PUBLIC_APP_URL = 'https://app.platform.example'
})
afterEach(() => {
delete process.env.RESEND_INBOUND_DOMAIN
})
describe('validateClaimableSendingDomain', () => {
it('blocks public mailbox providers', () => {
expect(validateClaimableSendingDomain('gmail.com')).toMatch(/Publika/)
})
it("blocks the platform's own sender domain, the inbound domain and the app host (and subdomains)", () => {
expect(validateClaimableSendingDomain('platform.example')).toMatch(/reserverad/)
expect(validateClaimableSendingDomain('mail.platform.example')).toMatch(/reserverad/)
expect(validateClaimableSendingDomain('inbox.platform.example')).toMatch(/reserverad/)
expect(validateClaimableSendingDomain('app.platform.example')).toMatch(/reserverad/)
})
it('allows an ordinary company domain', () => {
expect(validateClaimableSendingDomain('hansbolag.example')).toBeNull()
})
})
describe('mapResendSendingStatus', () => {
it('maps verified and temporary_failure to verified, failures to failed, the rest to pending', () => {
expect(mapResendSendingStatus('verified')).toBe('verified')
expect(mapResendSendingStatus('temporary_failure')).toBe('verified')
expect(mapResendSendingStatus('failed')).toBe('failed')
expect(mapResendSendingStatus('partially_failed')).toBe('failed')
expect(mapResendSendingStatus('pending')).toBe('pending')
expect(mapResendSendingStatus('not_started')).toBe('pending')
expect(mapResendSendingStatus('partially_verified')).toBe('pending')
})
})
describe('isSendingOnlyProfile', () => {
it('accepts sending-only and rejects receiving or unknown', () => {
expect(isSendingOnlyProfile(SENDING_ONLY)).toBe(true)
expect(isSendingOnlyProfile({ sending: 'enabled', receiving: 'enabled' })).toBe(false)
expect(isSendingOnlyProfile({ sending: 'disabled', receiving: 'enabled' })).toBe(false)
expect(isSendingOnlyProfile(null)).toBe(false)
})
})
describe('claimSendingDomain', () => {
it('rejects an invalid domain without touching the DB or Resend', async () => {
const { client, calls } = sb()
const result = await claimSendingDomain(client, client, 'company-1', 'nodots')
expect(result).toEqual({ ok: false, status: 400, error: expect.stringMatching(/Ogiltig/) })
expect(calls).toHaveLength(0)
expect(domainsMock.create).not.toHaveBeenCalled()
})
it('rejects a reserved domain', async () => {
const { client } = sb()
const result = await claimSendingDomain(client, client, 'company-1', 'platform.example')
expect(result.ok).toBe(false)
expect(domainsMock.create).not.toHaveBeenCalled()
})
it('inserts the row, registers a sending-only domain in Resend, and stores the DNS records', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { ...ROW, resend_domain_id: null, dns_records: null } }) // insert
enqueue({ data: ROW }) // update
domainsMock.create.mockResolvedValue({ data: { id: 'rd_1' }, error: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', status: 'not_started', records: [DKIM_RECORD], capabilities: SENDING_ONLY },
error: null,
})
const result = await claimSendingDomain(client, client, 'company-1', 'HansBolag.example')
expect(result.ok).toBe(true)
expect(domainsMock.create).toHaveBeenCalledWith({
name: 'hansbolag.example',
region: 'eu-west-1',
capabilities: { sending: 'enabled', receiving: 'disabled' },
})
const insertArgs = findCall('company_sending_domains', 'insert')
expect(insertArgs?.[0]).toEqual({ company_id: 'company-1', domain: 'hansbolag.example', status: 'pending' })
const updateArgs = findCall('company_sending_domains', 'update')
expect(updateArgs?.[0]).toMatchObject({ resend_domain_id: 'rd_1', dns_records: [DKIM_RECORD], status: 'pending' })
})
it('writes the verification state through the service-role writer, not the tenant client', async () => {
const tenant = sb()
const writer = sb()
tenant.enqueue({ data: { ...ROW, resend_domain_id: null, dns_records: null } }) // insert (RLS client)
writer.enqueue({ data: ROW }) // update (service role)
domainsMock.create.mockResolvedValue({ data: { id: 'rd_1' }, error: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', status: 'not_started', records: [DKIM_RECORD], capabilities: SENDING_ONLY },
error: null,
})
const result = await claimSendingDomain(tenant.client, writer.client, 'company-1', 'hansbolag.example')
expect(result.ok).toBe(true)
expect(tenant.findCall('company_sending_domains', 'update')).toBeUndefined()
expect(writer.findCall('company_sending_domains', 'update')?.[0]).toMatchObject({ resend_domain_id: 'rd_1' })
// Still scoped to the company on the service-role path, and bound only to
// the row that still carries the registered domain and no Resend id (a
// concurrent tenant delete + re-insert under the same id matches nothing).
expect(writer.findCalls('company_sending_domains', 'eq')).toEqual(
expect.arrayContaining([
['id', 'row-1'],
['company_id', 'company-1'],
['domain', 'hansbolag.example'],
]),
)
expect(writer.findCall('company_sending_domains', 'is')).toEqual(['resend_domain_id', null])
})
it('maps a unique-violation on company_id to a 409 about the existing domain', async () => {
const { client, enqueue } = sb()
enqueue({ data: null, error: { code: '23505', message: 'duplicate key idx_company_sending_domains_company' } })
const result = await claimSendingDomain(client, client, 'company-1', 'hansbolag.example')
expect(result).toEqual({ ok: false, status: 409, error: expect.stringMatching(/redan en avsändardomän/) })
})
it('never adopts an existing Resend domain: "already exists" rolls back and returns 409', async () => {
const { client, enqueue, findCalls } = sb()
enqueue({ data: { ...ROW, resend_domain_id: null } }) // insert
enqueue({ data: null }) // rollback delete
domainsMock.create.mockResolvedValue({ data: null, error: { message: 'Domain already exists' } })
const result = await claimSendingDomain(client, client, 'company-1', 'hansbolag.example')
expect(result).toEqual({ ok: false, status: 409, error: expect.stringMatching(/finns redan/) })
expect(domainsMock.list).not.toHaveBeenCalled()
expect(findCalls('company_sending_domains', 'delete')).toHaveLength(1)
})
it('removes the Resend domain it just created when persisting the DNS records fails', async () => {
const { client, enqueue } = sb()
enqueue({ data: { ...ROW, resend_domain_id: null } }) // insert
enqueue({ data: null, error: { message: 'db down' } }) // update fails
enqueue({ data: null }) // rollback delete
domainsMock.create.mockResolvedValue({ data: { id: 'rd_1' }, error: null })
domainsMock.get.mockResolvedValue({ data: { id: 'rd_1', status: 'pending', records: [], capabilities: SENDING_ONLY }, error: null })
domainsMock.remove.mockResolvedValue({ data: null, error: null })
const result = await claimSendingDomain(client, client, 'company-1', 'hansbolag.example')
expect(result.ok).toBe(false)
expect(domainsMock.remove).toHaveBeenCalledWith('rd_1')
})
})
describe('checkSendingDomainVerification', () => {
it('404s without a row', async () => {
const { client, enqueue } = sb()
enqueue({ data: null })
const result = await checkSendingDomainVerification(client, client, 'company-1')
expect(result).toMatchObject({ ok: false, status: 404 })
})
it('verifies, then persists verified + verified_at', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: ROW })
enqueue({ data: { ...ROW, status: 'verified' } })
domainsMock.verify.mockResolvedValue({ data: {}, error: null })
domainsMock.get.mockResolvedValue({
data: {
id: 'rd_1',
name: 'hansbolag.example',
status: 'verified',
records: [{ ...DKIM_RECORD, status: 'verified' }],
capabilities: SENDING_ONLY,
},
error: null,
})
const result = await checkSendingDomainVerification(client, client, 'company-1')
expect(result.ok).toBe(true)
expect(domainsMock.verify).toHaveBeenCalledWith('rd_1')
const updateArgs = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(updateArgs.status).toBe('verified')
expect(typeof updateArgs.verified_at).toBe('string')
})
it('refuses to flip when Resend reports a different domain name than the row (swapped row)', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { ...ROW, domain: 'platform.example' } })
domainsMock.verify.mockResolvedValue({ data: {}, error: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', status: 'verified', records: [], capabilities: SENDING_ONLY },
error: null,
})
const result = await checkSendingDomainVerification(client, client, 'company-1')
expect(result).toMatchObject({ ok: false, status: 409 })
expect(findCall('company_sending_domains', 'update')).toBeUndefined()
})
it('refuses to flip a domain without the sending capability', async () => {
const { client, enqueue } = sb()
enqueue({ data: ROW })
domainsMock.verify.mockResolvedValue({ data: {}, error: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', status: 'verified', records: [], capabilities: { sending: 'disabled', receiving: 'enabled' } },
error: null,
})
const result = await checkSendingDomainVerification(client, client, 'company-1')
expect(result).toMatchObject({ ok: false, status: 409 })
})
})
describe('updateSendingDomainSettings', () => {
it('normalizes the local part, strips header characters from the name, and toggles enabled', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: ROW })
enqueue({ data: { ...ROW, sender_local_part: 'ekonomi', sender_name: 'Hans Bolag', enabled: false } })
const result = await updateSendingDomainSettings(client, 'company-1', {
sender_local_part: 'Ekonomi',
sender_name: 'Hans <Bolag>\r\n',
enabled: false,
})
expect(result.ok).toBe(true)
expect(findCall('company_sending_domains', 'update')?.[0]).toEqual({
sender_local_part: 'ekonomi',
sender_name: 'Hans Bolag',
enabled: false,
})
})
it('rejects an invalid local part with 400', async () => {
const { client, enqueue } = sb()
enqueue({ data: ROW })
const result = await updateSendingDomainSettings(client, 'company-1', { sender_local_part: 'no spaces' })
expect(result).toMatchObject({ ok: false, status: 400 })
})
it('clears the sender name with null', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { ...ROW, sender_name: 'Old' } })
enqueue({ data: ROW })
const result = await updateSendingDomainSettings(client, 'company-1', { sender_name: null })
expect(result.ok).toBe(true)
expect(findCall('company_sending_domains', 'update')?.[0]).toEqual({ sender_name: null })
})
})
describe('removeSendingDomain', () => {
it('deletes a sending-only Resend domain, then the row', async () => {
const { client, enqueue, findCalls } = sb()
enqueue({ data: ROW })
enqueue({ data: null }) // delete
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', capabilities: SENDING_ONLY },
error: null,
})
domainsMock.remove.mockResolvedValue({ data: null, error: null })
const result = await removeSendingDomain(client, 'company-1')
expect(result).toEqual({ ok: true, data: { removed: true } })
expect(domainsMock.remove).toHaveBeenCalledWith('rd_1')
expect(findCalls('company_sending_domains', 'delete')).toHaveLength(1)
})
it("never deletes the platform's own sender domain from Resend, even when a row points at it", async () => {
const { client, enqueue } = sb()
enqueue({ data: { ...ROW, domain: 'platform.example', resend_domain_id: 'rd_platform' } })
enqueue({ data: null }) // delete row
domainsMock.get.mockResolvedValue({
data: { id: 'rd_platform', name: 'platform.example', capabilities: SENDING_ONLY },
error: null,
})
const result = await removeSendingDomain(client, 'company-1')
expect(result.ok).toBe(true)
expect(domainsMock.remove).not.toHaveBeenCalled()
})
it('leaves a receiving-capable Resend domain alone', async () => {
const { client, enqueue } = sb()
enqueue({ data: ROW })
enqueue({ data: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', capabilities: { sending: 'enabled', receiving: 'enabled' } },
error: null,
})
const result = await removeSendingDomain(client, 'company-1')
expect(result.ok).toBe(true)
expect(domainsMock.remove).not.toHaveBeenCalled()
})
})
describe('applySendingDomainStatusFromWebhook', () => {
it('returns no_match for an unknown Resend domain id', async () => {
const { client, enqueue } = sb()
enqueue({ data: null })
await expect(applySendingDomainStatusFromWebhook(client, { id: 'rd_other', status: 'verified' })).resolves.toBe(
'no_match',
)
expect(domainsMock.get).not.toHaveBeenCalled()
})
it('returns error (so the webhook is retried) when the lookup or the update fails', async () => {
const lookupFail = sb()
lookupFail.enqueue({ data: null, error: { message: 'db down' } })
await expect(
applySendingDomainStatusFromWebhook(lookupFail.client, { id: 'rd_1', status: 'failed' }),
).resolves.toBe('error')
const updateFail = sb()
updateFail.enqueue({ data: { id: 'row-1', verified_at: null } })
updateFail.enqueue({ data: null, error: { message: 'db down' } })
await expect(
applySendingDomainStatusFromWebhook(updateFail.client, { id: 'rd_1', status: 'failed' }),
).resolves.toBe('error')
})
it('keeps the stored status when Resend names a different domain than the row (swapped row)', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { id: 'row-1', domain: 'platform.example', verified_at: null } })
enqueue({ data: null }) // update (records/last_checked only)
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', capabilities: SENDING_ONLY },
error: null,
})
const ok = await applySendingDomainStatusFromWebhook(client, { id: 'rd_1', status: 'verified' })
expect(ok).toBe('applied')
const update = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(update.status).toBeUndefined()
})
it('flips to verified only after confirming the sending capability with Resend', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { id: 'row-1', domain: 'hansbolag.example', verified_at: null } })
enqueue({ data: null }) // update
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', capabilities: SENDING_ONLY },
error: null,
})
const ok = await applySendingDomainStatusFromWebhook(client, { id: 'rd_1', status: 'verified', records: [DKIM_RECORD] })
expect(ok).toBe('applied')
const update = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(update.status).toBe('verified')
expect(update.dns_records).toEqual([DKIM_RECORD])
expect(typeof update.verified_at).toBe('string')
})
it('keeps the stored status when Resend cannot confirm sending', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { id: 'row-1', verified_at: null } })
enqueue({ data: null })
domainsMock.get.mockResolvedValue({ data: null, error: { message: 'nope' } })
const ok = await applySendingDomainStatusFromWebhook(client, { id: 'rd_1', status: 'verified' })
expect(ok).toBe('applied')
const update = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(update.status).toBeUndefined()
})
it('records a failed status without calling Resend', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { id: 'row-1', verified_at: '2026-08-01T00:00:00Z' } })
enqueue({ data: null })
const ok = await applySendingDomainStatusFromWebhook(client, { id: 'rd_1', status: 'failed' })
expect(ok).toBe('applied')
expect(domainsMock.get).not.toHaveBeenCalled()
const update = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(update.status).toBe('failed')
expect(update.verified_at).toBe('2026-08-01T00:00:00Z')
})
})