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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
561e64afc7
commit
0040cadacc
@@ -46,6 +46,10 @@ import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockIsConfigured = vi.fn()
|
||||
vi.mock('@/lib/email/invoice-sender', () => ({
|
||||
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({
|
||||
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildPaymentLinkQrDataUrl,
|
||||
} from '@/lib/invoices/pdf-render-helpers'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
|
||||
import {
|
||||
generatePaymentConfirmationEmailHtml,
|
||||
generatePaymentConfirmationEmailSubject,
|
||||
@@ -181,6 +182,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
text: generatePaymentConfirmationEmailText(emailData),
|
||||
replyTo: company.email || undefined,
|
||||
fromName: company.company_name,
|
||||
from: await resolveInvoiceSender(supabase, companyId, company.company_name),
|
||||
attachments: [
|
||||
{
|
||||
filename,
|
||||
|
||||
@@ -47,6 +47,12 @@ import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockIsConfigured = vi.fn()
|
||||
// The sender resolver reads company_sending_domains; keep it out of the
|
||||
// queued-mock sequence (its own tests live in lib/email/__tests__).
|
||||
vi.mock('@/lib/email/invoice-sender', () => ({
|
||||
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({
|
||||
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailText,
|
||||
@@ -481,6 +482,7 @@ export const POST = withRouteContext(
|
||||
text,
|
||||
replyTo: company.email || undefined,
|
||||
fromName: company.company_name,
|
||||
from: await resolveInvoiceSender(supabase, companyId!, company.company_name),
|
||||
filename,
|
||||
pdfBuffer,
|
||||
})
|
||||
|
||||
@@ -53,6 +53,10 @@ vi.mock('@react-pdf/renderer', () => ({
|
||||
// Email service mock: configurable per test
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockIsConfigured = vi.fn().mockReturnValue(true)
|
||||
vi.mock('@/lib/email/invoice-sender', () => ({
|
||||
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/email/service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/email/service')>()
|
||||
return {
|
||||
|
||||
@@ -50,6 +50,7 @@ import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailSubject,
|
||||
@@ -622,6 +623,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
text,
|
||||
replyTo: settings.email ?? undefined,
|
||||
fromName: settings.company_name ?? undefined,
|
||||
from: await resolveInvoiceSender(ctx.supabase, ctx.companyId!, settings.company_name),
|
||||
filename,
|
||||
pdfBuffer,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user