* 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>
144 lines
6.0 KiB
TypeScript
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)
|
|
})
|
|
})
|