* 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>
116 lines
4.8 KiB
TypeScript
116 lines
4.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
buildSenderAddress,
|
|
senderFromRow,
|
|
resolveInvoiceSender,
|
|
} from '@/lib/email/invoice-sender'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
const hasCapabilityMock = vi.fn()
|
|
vi.mock('@/lib/entitlements/has-capability', () => ({
|
|
hasCapability: (...args: unknown[]) => hasCapabilityMock(...args),
|
|
}))
|
|
|
|
const VERIFIED_ROW = {
|
|
domain: 'hansbolag.example',
|
|
status: 'verified' as const,
|
|
enabled: true,
|
|
sender_local_part: 'faktura',
|
|
sender_name: null,
|
|
}
|
|
|
|
describe('buildSenderAddress', () => {
|
|
it('joins local part and domain', () => {
|
|
expect(buildSenderAddress('faktura', 'hansbolag.example')).toBe('faktura@hansbolag.example')
|
|
})
|
|
})
|
|
|
|
describe('senderFromRow', () => {
|
|
it('uses the company name when no sender name is stored', () => {
|
|
expect(senderFromRow(VERIFIED_ROW, 'Hans Bolag AB')).toEqual({
|
|
name: 'Hans Bolag AB',
|
|
address: 'faktura@hansbolag.example',
|
|
})
|
|
})
|
|
|
|
it('prefers an explicit sender name', () => {
|
|
expect(senderFromRow({ ...VERIFIED_ROW, sender_name: 'Hans Bolag Ekonomi' }, 'Hans Bolag AB')).toEqual({
|
|
name: 'Hans Bolag Ekonomi',
|
|
address: 'faktura@hansbolag.example',
|
|
})
|
|
})
|
|
|
|
it('never sends as a reserved platform domain or a malformed domain, even from a verified row', () => {
|
|
const previous = process.env.RESEND_FROM_EMAIL
|
|
process.env.RESEND_FROM_EMAIL = 'noreply@platform.example'
|
|
try {
|
|
expect(senderFromRow({ ...VERIFIED_ROW, domain: 'platform.example' }, 'X')).toBeUndefined()
|
|
expect(senderFromRow({ ...VERIFIED_ROW, domain: 'mail.platform.example' }, 'X')).toBeUndefined()
|
|
expect(senderFromRow({ ...VERIFIED_ROW, domain: 'not a host' }, 'X')).toBeUndefined()
|
|
expect(senderFromRow(VERIFIED_ROW, 'X')).toEqual({ name: 'X', address: 'faktura@hansbolag.example' })
|
|
} finally {
|
|
if (previous === undefined) delete process.env.RESEND_FROM_EMAIL
|
|
else process.env.RESEND_FROM_EMAIL = previous
|
|
}
|
|
})
|
|
|
|
it('returns undefined for missing, unverified, paused, or nameless rows', () => {
|
|
expect(senderFromRow(null, 'X')).toBeUndefined()
|
|
expect(senderFromRow({ ...VERIFIED_ROW, status: 'pending' }, 'X')).toBeUndefined()
|
|
expect(senderFromRow({ ...VERIFIED_ROW, status: 'failed' }, 'X')).toBeUndefined()
|
|
expect(senderFromRow({ ...VERIFIED_ROW, enabled: false }, 'X')).toBeUndefined()
|
|
expect(senderFromRow(VERIFIED_ROW, ' ')).toBeUndefined()
|
|
expect(senderFromRow(VERIFIED_ROW, null)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('resolveInvoiceSender', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns undefined and skips the entitlement check when the company has no verified row', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: null })
|
|
const result = await resolveInvoiceSender(supabase as unknown as SupabaseClient, 'company-1', 'Hans Bolag AB')
|
|
expect(result).toBeUndefined()
|
|
expect(hasCapabilityMock).not.toHaveBeenCalled()
|
|
// Only verified + enabled rows are ever read.
|
|
const eqArgs = findCall('company_sending_domains', 'eq')
|
|
expect(eqArgs).toEqual(['company_id', 'company-1'])
|
|
})
|
|
|
|
it('returns the sender when the row is verified and the company holds the grant', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: VERIFIED_ROW })
|
|
hasCapabilityMock.mockResolvedValue(true)
|
|
const result = await resolveInvoiceSender(supabase as unknown as SupabaseClient, 'company-1', 'Hans Bolag AB')
|
|
expect(result).toEqual({ name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' })
|
|
expect(hasCapabilityMock).toHaveBeenCalledWith(expect.anything(), 'company-1', 'custom_sender_domain')
|
|
})
|
|
|
|
it('falls back to the platform sender when the grant has lapsed', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: VERIFIED_ROW })
|
|
hasCapabilityMock.mockResolvedValue(false)
|
|
const result = await resolveInvoiceSender(supabase as unknown as SupabaseClient, 'company-1', 'Hans Bolag AB')
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it('never throws: a read error or a thrown entitlement check yields undefined', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null, error: { message: 'boom' } })
|
|
await expect(
|
|
resolveInvoiceSender(supabase as unknown as SupabaseClient, 'company-1', 'X'),
|
|
).resolves.toBeUndefined()
|
|
|
|
const second = createQueuedMockSupabase()
|
|
second.enqueue({ data: VERIFIED_ROW })
|
|
hasCapabilityMock.mockRejectedValue(new Error('network'))
|
|
await expect(
|
|
resolveInvoiceSender(second.supabase as unknown as SupabaseClient, 'company-1', 'X'),
|
|
).resolves.toBeUndefined()
|
|
})
|
|
})
|