From 0040cadacc0ba4fd608d18107fef43b20577acf7 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:07:30 +0200 Subject: [PATCH] feat(invoicing): opt-in invoice email from the company's own sending domain (#1802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 " " 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 3 + .../__tests__/route.test.ts | 4 + .../[id]/send-payment-confirmation/route.ts | 2 + .../[id]/send/__tests__/route.test.ts | 6 + app/api/invoices/[id]/send/route.ts | 2 + .../[id]/send/__tests__/route.test.ts | 4 + .../[companyId]/invoices/[id]/send/route.ts | 2 + .../settings/InvoiceSenderDomainSettings.tsx | 382 +++++++++++++ .../sections/InvoicingSettingsContent.tsx | 3 + .../company-sending-domains.pg.test.ts | 268 +++++++++ .../email/__tests__/resend-service.test.ts | 143 +++++ .../__tests__/sending-domain-routes.test.ts | 278 +++++++++ .../email/__tests__/sending-domains.test.ts | 427 ++++++++++++++ extensions/general/email/index.ts | 198 ++++++- .../general/email/lib/resend-service.ts | 81 ++- .../general/email/lib/sending-domains.ts | 540 ++++++++++++++++++ lib/email/__tests__/domain-name.test.ts | 92 +++ lib/email/__tests__/invoice-sender.test.ts | 115 ++++ lib/email/domain-name.ts | 83 +++ lib/email/invoice-sender.ts | 83 +++ lib/email/service.ts | 7 + lib/entitlements/keys.ts | 8 + .../recurring-schedule-service.test.ts | 4 + .../__tests__/reminder-processor.test.ts | 4 + lib/invoices/invoice-deliveries.ts | 4 + lib/invoices/recurring-schedule-service.ts | 2 + lib/invoices/reminder-processor.ts | 6 +- .../__tests__/executors.test.ts | 4 + lib/pending-operations/commit.ts | 2 + lib/reports/full-archive-export.ts | 1 + messages/en.json | 52 ++ messages/sv.json | 52 ++ ...20260822120000_company_sending_domains.sql | 125 ++++ ...0_company_sending_domains_tenant_guard.sql | 106 ++++ types/index.ts | 25 + 35 files changed, 3104 insertions(+), 14 deletions(-) create mode 100644 components/settings/InvoiceSenderDomainSettings.tsx create mode 100644 extensions/general/email/__tests__/company-sending-domains.pg.test.ts create mode 100644 extensions/general/email/__tests__/resend-service.test.ts create mode 100644 extensions/general/email/__tests__/sending-domain-routes.test.ts create mode 100644 extensions/general/email/__tests__/sending-domains.test.ts create mode 100644 extensions/general/email/lib/sending-domains.ts create mode 100644 lib/email/__tests__/domain-name.test.ts create mode 100644 lib/email/__tests__/invoice-sender.test.ts create mode 100644 lib/email/domain-name.ts create mode 100644 lib/email/invoice-sender.ts create mode 100644 supabase/migrations/20260822120000_company_sending_domains.sql create mode 100644 supabase/migrations/20260822130000_company_sending_domains_tenant_guard.sql diff --git a/DECISIONS.md b/DECISIONS.md index c4271773..437dcd13 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1167,3 +1167,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-20] Invoice detail hydration addresses the endpoint by the resource config's own `idField` read off the raw payload, not by `dto.id`. Björn Lundén's sales config names `invoiceNumber` while its mapper builds `dto.id` from `entityId`, so `dto.id` would have requested a different invoice or none; every other provider/resource pair happens to agree, which is exactly why the mismatch was easy to miss. [2026-08-21] A migrated mixed-rate invoice stores `vat_rate: null` while keeping a treatment, matching what buildInvoiceWriteData already does for a natively created one (`isMixedRate ? null : theRate`). Labelling the header with the first line's rate would assert 25 % on an invoice that is 25 % and 6 %, and dividing the rate out of the totals gives a blended figure matching no statutory rate. The money is unaffected either way: generatePerRateLines groups per ITEM rate, which is why the per-line vat_rate/vat_amount are the part that has to be right. [2026-08-21] Invoice detail hydration stops the whole pass on a 401/403 and bounds every in-flight call against the budget deadline. The provider clients retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s apart), so a call starting one millisecond inside the budget can still be retrying minutes later, and three concurrent ones could hold the migration past its 300 s ceiling; racing each against the deadline returns control even though the socket is not cancelled. A rejected token fails identically for every remaining invoice, so continuing would spend the Fortnox rate-limit budget for nothing: note that limiter keys on the literal string 'global', making 4 req/s a PLATFORM-WIDE budget shared by every company and every concurrent migration, not a per-token one. +[2026-08-22] Per-company invoice sending domains are gated by a manually granted capability (custom_sender_domain), deliberately NOT in PAID_CAPABILITIES: the opt-in must not be trial-seeded or written by the Stripe subscription sync, and non-grantees must see an unchanged invoicing settings page (the section hides on the 403 capability_blocked envelope). The sending-domain module has no Resend orphan-adoption path (a name that already exists is a 409), because the same Resend account holds the platform's own outbound domain. The delivery log was left untouched (no from_address column): adding it would re-open the hardened invoice_deliveries evidence triggers/redaction paths for a nice-to-have, and the log already measures delivered/bounced per send. +[2026-08-22] company_sending_domains verification state (domain, status, resend_domain_id, dns_records, verified_at, last_checked_at) is service-role only via a BEFORE trigger keyed on the JWT role claim; tenant JWTs may only open a pending claim and edit sender_local_part/sender_name/enabled. Skeptic refutation: RLS alone let a granted admin insert {domain: platform sender domain, status: verified} through PostgREST and send invoice mail as the platform. The claim/verify helpers therefore take a separate service-role writer for those columns. Second refutation: a domain Resend later flips to failed made every invoice send for that company fail; the Resend adapter now retries once as the platform sender when an explicit company From is rejected (nothing was sent on the rejected attempt, so the retry cannot double-send). +[2026-08-22] Sending-domain verification writes bind by (id, company_id, domain, resend_domain_id IS NULL) and verify/webhook compare Resend's domain name with the row before writing verified; resolveInvoiceSender additionally refuses reserved platform domains and non-hostnames at send time. Skeptic re-check: a tenant could delete and re-insert its pending row under the same id with a reserved domain during the claim's Resend round-trip (TOCTOU), and the service-role writer updated by id alone. Defense in depth over a single gate. diff --git a/app/api/invoices/[id]/send-payment-confirmation/__tests__/route.test.ts b/app/api/invoices/[id]/send-payment-confirmation/__tests__/route.test.ts index 2c75be94..e826f2cc 100644 --- a/app/api/invoices/[id]/send-payment-confirmation/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send-payment-confirmation/__tests__/route.test.ts @@ -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), diff --git a/app/api/invoices/[id]/send-payment-confirmation/route.ts b/app/api/invoices/[id]/send-payment-confirmation/route.ts index e4745167..464655ef 100644 --- a/app/api/invoices/[id]/send-payment-confirmation/route.ts +++ b/app/api/invoices/[id]/send-payment-confirmation/route.ts @@ -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, diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 1419f73e..1e25fc93 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -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), diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index b11093ce..046c60a3 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -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, }) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index 8989092a..c9ba7558 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -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() return { diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 7d36ecba..ed16144e 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -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, }) diff --git a/components/settings/InvoiceSenderDomainSettings.tsx b/components/settings/InvoiceSenderDomainSettings.tsx new file mode 100644 index 00000000..76839acf --- /dev/null +++ b/components/settings/InvoiceSenderDomainSettings.tsx @@ -0,0 +1,382 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { Skeleton } from '@/components/ui/skeleton' +import { useToast } from '@/components/ui/use-toast' +import { Check, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react' +import { + SettingsGroup, + SettingsRow, + SettingsRowNote, +} from '@/components/settings/SettingsRows' +import type { CompanySendingDomain, SendingDomainDnsRecord } from '@/types' +import { getErrorMessage as getUserErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import { useFormat } from '@/lib/hooks/use-format' +import { useCompany } from '@/contexts/CompanyContext' +import { copyToClipboard } from '@/lib/browser/copy-to-clipboard' + +const BASE = '/api/extensions/ext/email/sending-domain' + +const STATUS_VARIANT: Record = { + pending: 'secondary', + verified: 'success', + failed: 'destructive', +} + +/** + * Opt-in "send invoice email from our own domain" section. Rendered only + * when the company holds the capability grant: the GET answers 403 + * capability_blocked otherwise and the section renders nothing, so every + * other company keeps the unchanged invoicing settings page. + * + * Three states: no domain (claim form), pending (DNS records + re-check), + * verified (sender address/name, pause toggle). Everything that touches the + * From header is decided server-side; this surface only manages the claim. + */ +export function InvoiceSenderDomainSettings({ companyName }: { companyName: string | null }) { + const t = useTranslations('settings_invoice_sender_domain') + const { toast } = useToast() + const { locale, formatDateLong } = useFormat() + const errorLocale = locale as ErrorLocale + const { role } = useCompany() + const canManage = role === 'owner' || role === 'admin' + + const [available, setAvailable] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [loadFailed, setLoadFailed] = useState(false) + const [domain, setDomain] = useState(null) + const [domainInput, setDomainInput] = useState('') + const [localPart, setLocalPart] = useState('faktura') + const [senderName, setSenderName] = useState('') + const [isClaiming, setIsClaiming] = useState(false) + const [isChecking, setIsChecking] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [isRemoving, setIsRemoving] = useState(false) + + const applyRow = useCallback((row: CompanySendingDomain | null) => { + setDomain(row) + setLocalPart(row?.sender_local_part ?? 'faktura') + setSenderName(row?.sender_name ?? '') + }, []) + + const fetchDomain = useCallback(async () => { + setIsLoading(true) + setLoadFailed(false) + try { + const res = await fetch(BASE) + if (res.status === 403 || res.status === 404) { + // Not opted in (no capability grant) or extension not mounted: + // stay invisible rather than advertise a feature the company lacks. + setAvailable(false) + return + } + if (!res.ok) { + setAvailable(true) + setLoadFailed(true) + return + } + const json = await res.json() + setAvailable(true) + applyRow(json.data ?? null) + } catch { + setAvailable(true) + setLoadFailed(true) + } finally { + setIsLoading(false) + } + }, [applyRow]) + + useEffect(() => { + // Only owners/admins can ever see the section: skip the request (and its + // capability lookups) for everyone else. + if (!canManage) return + void fetchDomain() + }, [canManage, fetchDomain]) + + const fail = useCallback( + (title: string, err: unknown) => { + toast({ + title, + description: err instanceof Error ? getUserErrorMessage(err, { locale: errorLocale }) : t('try_again'), + variant: 'destructive', + }) + }, + [errorLocale, t, toast], + ) + + const handleClaim = useCallback(async () => { + if (!domainInput.trim()) return + setIsClaiming(true) + try { + const res = await fetch(BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain: domainInput }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json.error ?? t('claim_error_title')) + applyRow(json.data) + setDomainInput('') + toast({ title: t('claim_success_title'), description: t('claim_success_description') }) + } catch (err) { + fail(t('claim_error_title'), err) + } finally { + setIsClaiming(false) + } + }, [applyRow, domainInput, fail, t, toast]) + + const handleVerify = useCallback(async () => { + setIsChecking(true) + try { + const res = await fetch(`${BASE}/verify`, { method: 'POST' }) + const json = await res.json() + if (!res.ok) throw new Error(json.error ?? t('verify_error_title')) + applyRow(json.data) + toast( + json.data.status === 'verified' + ? { title: t('verify_success_title'), description: t('verify_success_description') } + : { title: t('verify_pending_title'), description: t('verify_pending_description') }, + ) + } catch (err) { + fail(t('verify_error_title'), err) + } finally { + setIsChecking(false) + } + }, [applyRow, fail, t, toast]) + + const patch = useCallback( + async (body: { sender_local_part?: string; sender_name?: string | null; enabled?: boolean }) => { + setIsSaving(true) + try { + const res = await fetch(BASE, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const json = await res.json() + if (!res.ok) throw new Error(json.error ?? t('save_error_title')) + applyRow(json.data) + toast({ title: t('saved_title') }) + } catch (err) { + fail(t('save_error_title'), err) + } finally { + setIsSaving(false) + } + }, + [applyRow, fail, t, toast], + ) + + const handleSaveSender = useCallback(() => { + const name = senderName.trim() + void patch({ sender_local_part: localPart.trim(), sender_name: name ? name : null }) + }, [localPart, patch, senderName]) + + const handleRemove = useCallback(async () => { + if (!domain) return + if (!confirm(t('remove_confirm', { domain: domain.domain }))) return + setIsRemoving(true) + try { + const res = await fetch(BASE, { method: 'DELETE' }) + const json = await res.json() + if (!res.ok) throw new Error(json.error ?? t('remove_error_title')) + applyRow(null) + toast({ title: t('remove_success_title') }) + } catch (err) { + fail(t('remove_error_title'), err) + } finally { + setIsRemoving(false) + } + }, [applyRow, domain, fail, t, toast]) + + const handleCopy = useCallback( + async (value: string) => { + const result = await copyToClipboard(value) + toast( + result === 'copied' + ? { title: t('copied') } + : { title: t('copy_failed_title'), description: t('copy_failed_description'), variant: 'destructive' }, + ) + }, + [t, toast], + ) + + if (!canManage) return null + // Stay invisible until the opt-in is confirmed: no skeleton flash for the + // companies that do not hold the grant (i.e. almost all of them). + if (!available) return null + + const records: SendingDomainDnsRecord[] = domain?.dns_records ?? [] + const statusLabels: Record = { + pending: t('status_pending'), + verified: t('status_verified'), + failed: t('status_failed'), + } + const effectiveName = (domain?.sender_name ?? companyName ?? '').trim() + const previewAddress = domain ? `${domain.sender_local_part}@${domain.domain}` : '' + + return ( + + {isLoading ? ( +
+ + +
+ ) : loadFailed ? ( +
+

{t('load_error')}

+ +
+ ) : !domain ? ( + <> + + setDomainInput(e.target.value)} + placeholder="dittbolag.se" + className="max-w-xs" + onKeyDown={(e) => { + if (e.nativeEvent.isComposing) return + if (e.key === 'Enter') void handleClaim() + }} + /> + + + {t('fallback_note')} + + ) : ( + <> + + {domain.domain} + {statusLabels[domain.status]} +
+ + +
+
+ + {domain.status === 'verified' ? ( + <> + + void patch({ enabled: checked })} + aria-label={t('enabled_label')} + /> + {domain.enabled ? t('enabled_on') : t('enabled_off')} + + + setLocalPart(e.target.value)} + className="max-w-[10rem] font-mono" + /> + @{domain.domain} + + + setSenderName(e.target.value)} + placeholder={companyName ?? ''} + className="max-w-xs" + /> + +
+ +
+

+ {t('preview_label')}{' '} + + {effectiveName ? `${effectiveName} <${previewAddress}>` : previewAddress} + +

+

+ {domain.verified_at + ? t('verified_description_with_date', { date: formatDateLong(domain.verified_at) }) + : t('verified_description')} +

+
+
+
+ +
+ + ) : ( +
+

{t('dns_instructions')}

+ {records.length > 0 ? ( +
+ + + + + + + + + + + {records.map((r, i) => ( + + + + + + + + ))} + +
{t('dns_type')}{t('dns_name')}{t('dns_value')}{t('dns_status')} +
{r.type}{r.name}{r.value}{r.status} + +
+
+ ) : ( +

{t('dns_empty')}

+ )} + {t('fallback_note')} +
+ )} + + )} +
+ ) +} diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx index d1aa3612..18ed62c4 100644 --- a/components/settings/sections/InvoicingSettingsContent.tsx +++ b/components/settings/sections/InvoicingSettingsContent.tsx @@ -7,6 +7,7 @@ import { PeppolReceiveSettings } from '@/components/settings/PeppolReceiveSettin import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings' import { InvoiceEmailTextsSettings } from '@/components/settings/InvoiceEmailTextsSettings' import { InvoiceEmailRecipientsSettings } from '@/components/settings/InvoiceEmailRecipientsSettings' +import { InvoiceSenderDomainSettings } from '@/components/settings/InvoiceSenderDomainSettings' import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard' import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings' import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper' @@ -75,6 +76,8 @@ export function InvoicingSettingsContent() { {/* Fixed invoice email recipients: explicit save (owner/admin only) */} + + {/* Invoice email texts: autosaves on blur */} diff --git a/extensions/general/email/__tests__/company-sending-domains.pg.test.ts b/extensions/general/email/__tests__/company-sending-domains.pg.test.ts new file mode 100644 index 00000000..d493e42f --- /dev/null +++ b/extensions/general/email/__tests__/company-sending-domains.pg.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool, withUserContext, runAsServiceRole } from '@/tests/pg/setup' +import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures' + +/** + * company_sending_domains (20260822120000): RLS shape and column constraints. + * - members read their company's row, never another company's + * - only owner/admin may insert/update/delete + * - status, sender_local_part and sender_name are constrained + * - one domain per company, one company per domain (case-insensitive) + */ +describe('company_sending_domains', () => { + let ownerId: string + let memberId: string + let outsiderId: string + let companyId: string + let otherCompanyId: string + const domain = `pg-real-${randomUUID().slice(0, 8)}.example` + + beforeAll(async () => { + ownerId = await insertAuthUser() + memberId = await insertAuthUser() + outsiderId = await insertAuthUser() + companyId = await insertCompany({ createdBy: ownerId }) + otherCompanyId = await insertCompany({ createdBy: outsiderId }) + await insertCompanyMember({ companyId, userId: ownerId, role: 'owner' }) + await insertCompanyMember({ companyId, userId: memberId, role: 'member' }) + await insertCompanyMember({ companyId: otherCompanyId, userId: outsiderId, role: 'owner' }) + await getPool().query( + `INSERT INTO public.company_sending_domains (company_id, domain) VALUES ($1, $2)`, + [companyId, domain], + ) + }) + + it('defaults to pending, faktura@, enabled', async () => { + const { rows } = await getPool().query( + `SELECT status, sender_local_part, sender_name, enabled + FROM public.company_sending_domains WHERE company_id = $1`, + [companyId], + ) + expect(rows[0]).toEqual({ status: 'pending', sender_local_part: 'faktura', sender_name: null, enabled: true }) + }) + + it('members of the company can read the row; outsiders cannot', async () => { + const own = await withUserContext(memberId, (c) => + c.query(`SELECT domain FROM public.company_sending_domains WHERE company_id = $1`, [companyId]), + ) + expect(own.rows).toHaveLength(1) + + const foreign = await withUserContext(outsiderId, (c) => + c.query(`SELECT domain FROM public.company_sending_domains WHERE company_id = $1`, [companyId]), + ) + expect(foreign.rows).toHaveLength(0) + }) + + it('a plain member cannot update or delete; the owner can', async () => { + const memberUpdate = await withUserContext(memberId, (c) => + c.query(`UPDATE public.company_sending_domains SET enabled = false WHERE company_id = $1`, [companyId]), + ) + expect(memberUpdate.rowCount).toBe(0) + + const memberDelete = await withUserContext(memberId, (c) => + c.query(`DELETE FROM public.company_sending_domains WHERE company_id = $1`, [companyId]), + ) + expect(memberDelete.rowCount).toBe(0) + + const ownerUpdate = await withUserContext(ownerId, (c) => + c.query(`UPDATE public.company_sending_domains SET enabled = false WHERE company_id = $1`, [companyId]), + ) + expect(ownerUpdate.rowCount).toBe(1) + }) + + it('a plain member cannot insert for their company; an outsider cannot insert for someone else', async () => { + await expect( + withUserContext(memberId, (c) => + c.query(`INSERT INTO public.company_sending_domains (company_id, domain) VALUES ($1, $2)`, [ + companyId, + `member-${domain}`, + ]), + ), + ).rejects.toThrow(/row-level security/) + + await expect( + withUserContext(outsiderId, (c) => + c.query(`INSERT INTO public.company_sending_domains (company_id, domain) VALUES ($1, $2)`, [ + companyId, + `outsider-${domain}`, + ]), + ), + ).rejects.toThrow(/row-level security/) + }) + + it('enforces the status, local part and sender name constraints', async () => { + await expect( + getPool().query(`UPDATE public.company_sending_domains SET status = 'weird' WHERE company_id = $1`, [companyId]), + ).rejects.toThrow(/company_sending_domains_status_check/) + + await expect( + getPool().query( + `UPDATE public.company_sending_domains SET sender_local_part = 'Not Valid' WHERE company_id = $1`, + [companyId], + ), + ).rejects.toThrow(/sender_local_part_check/) + + await expect( + getPool().query(`UPDATE public.company_sending_domains SET sender_name = '' WHERE company_id = $1`, [companyId]), + ).rejects.toThrow(/sender_name_check/) + + // Dot-atom rule (20260822130000): no trailing or consecutive dots. + for (const bad of ['faktura.', 'fak..tura', '.faktura']) { + await expect( + getPool().query(`UPDATE public.company_sending_domains SET sender_local_part = $2 WHERE company_id = $1`, [ + companyId, + bad, + ]), + ).rejects.toThrow(/sender_local_part_check/) + } + const ok = await getPool().query( + `UPDATE public.company_sending_domains SET sender_local_part = 'fak.tura' WHERE company_id = $1`, + [companyId], + ) + expect(ok.rowCount).toBe(1) + }) + + it('a Resend domain id maps to at most one row', async () => { + await getPool().query( + `UPDATE public.company_sending_domains SET resend_domain_id = 'rd_unique' WHERE company_id = $1`, + [companyId], + ) + await expect( + getPool().query( + `INSERT INTO public.company_sending_domains (company_id, domain, resend_domain_id) VALUES ($1, $2, 'rd_unique')`, + [otherCompanyId, `other-${domain}`], + ), + ).rejects.toThrow(/idx_company_sending_domains_resend_id/) + }) + + it('rejects a malformed or non-lowercase domain (domain_shape CHECK)', async () => { + await expect( + getPool().query(`UPDATE public.company_sending_domains SET domain = 'Not A Domain' WHERE company_id = $1`, [ + companyId, + ]), + ).rejects.toThrow(/company_sending_domains_domain_shape/) + await expect( + getPool().query(`UPDATE public.company_sending_domains SET domain = 'Upper.Example' WHERE company_id = $1`, [ + companyId, + ]), + ).rejects.toThrow(/company_sending_domains_domain_shape/) + }) + + it('tenant guard: an owner cannot open a claim as verified, nor touch verification state', async () => { + // Fresh owner + company so the unique indexes do not interfere. + const forgerId = await insertAuthUser() + const forgerCompanyId = await insertCompany({ createdBy: forgerId }) + await insertCompanyMember({ companyId: forgerCompanyId, userId: forgerId, role: 'owner' }) + + await expect( + withUserContext(forgerId, (c) => + c.query( + `INSERT INTO public.company_sending_domains (company_id, domain, status) + VALUES ($1, $2, 'verified')`, + [forgerCompanyId, `forged-${domain}`], + ), + ), + ).rejects.toThrow(/tenant claim starts as pending/) + + await expect( + withUserContext(forgerId, (c) => + c.query( + `INSERT INTO public.company_sending_domains (company_id, domain, resend_domain_id) + VALUES ($1, $2, 'rd_forged')`, + [forgerCompanyId, `forged-${domain}`], + ), + ), + ).rejects.toThrow(/tenant claim starts as pending/) + + // A pending claim is fine for the tenant (what the route does)... + const pending = await withUserContext(forgerId, (c) => + c.query( + `INSERT INTO public.company_sending_domains (company_id, domain) VALUES ($1, $2) RETURNING status`, + [forgerCompanyId, `forged-${domain}`], + ), + ) + expect(pending.rows[0].status).toBe('pending') + + // ...but on the seeded row the owner can neither verify it nor retarget it. + await expect( + withUserContext(ownerId, (c) => + c.query(`UPDATE public.company_sending_domains SET status = 'verified' WHERE company_id = $1`, [companyId]), + ), + ).rejects.toThrow(/server-managed/) + await expect( + withUserContext(ownerId, (c) => + c.query(`UPDATE public.company_sending_domains SET domain = 'other.example' WHERE company_id = $1`, [ + companyId, + ]), + ), + ).rejects.toThrow(/server-managed/) + await expect( + withUserContext(ownerId, (c) => + c.query(`UPDATE public.company_sending_domains SET resend_domain_id = 'rd_x' WHERE company_id = $1`, [ + companyId, + ]), + ), + ).rejects.toThrow(/server-managed/) + + // Sender presentation stays tenant-editable. + const presentation = await withUserContext(ownerId, (c) => + c.query( + `UPDATE public.company_sending_domains + SET sender_local_part = 'ekonomi', sender_name = 'Ekonomi', enabled = true + WHERE company_id = $1`, + [companyId], + ), + ) + expect(presentation.rowCount).toBe(1) + }) + + it('tenant guard: the service role and direct sessions may write verification state', async () => { + await runAsServiceRole(async (c) => { + const r = await c.query( + `UPDATE public.company_sending_domains + SET status = 'verified', resend_domain_id = 'rd_pg', verified_at = now(), last_checked_at = now() + WHERE company_id = $1`, + [companyId], + ) + expect(r.rowCount).toBe(1) + }) + const { rows } = await getPool().query( + `SELECT status, resend_domain_id FROM public.company_sending_domains WHERE company_id = $1`, + [companyId], + ) + expect(rows[0]).toEqual({ status: 'verified', resend_domain_id: 'rd_pg' }) + // Direct (superuser) session: allowed, used by the other tests above. + const direct = await getPool().query( + `UPDATE public.company_sending_domains SET status = 'pending' WHERE company_id = $1`, + [companyId], + ) + expect(direct.rowCount).toBe(1) + }) + + it('one domain per company, and a domain belongs to one company (case-insensitive)', async () => { + await expect( + getPool().query(`INSERT INTO public.company_sending_domains (company_id, domain) VALUES ($1, $2)`, [ + companyId, + `second-${domain}`, + ]), + ).rejects.toThrow(/idx_company_sending_domains_company/) + + // Same domain for another company: the global unique index wins. + await expect( + getPool().query(`INSERT INTO public.company_sending_domains (company_id, domain) VALUES ($1, $2)`, [ + otherCompanyId, + domain, + ]), + ).rejects.toThrow(/idx_company_sending_domains_domain/) + // Case variants never reach the index: the domain_shape CHECK + // (20260822130000) requires lowercase, so uniqueness is case-insensitive + // by construction. + await expect( + getPool().query(`INSERT INTO public.company_sending_domains (company_id, domain) VALUES ($1, $2)`, [ + otherCompanyId, + domain.toUpperCase(), + ]), + ).rejects.toThrow(/company_sending_domains_domain_shape/) + }) +}) diff --git a/extensions/general/email/__tests__/resend-service.test.ts b/extensions/general/email/__tests__/resend-service.test.ts new file mode 100644 index 00000000..99632dd9 --- /dev/null +++ b/extensions/general/email/__tests__/resend-service.test.ts @@ -0,0 +1,143 @@ +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 ', + ) + }) + + it('renders the bare app sender without a company name', () => { + expect(buildFromHeader({})).toBe('Accounted ') + }) + + it('renders an explicit sender as "
" with no "via"', () => { + expect( + buildFromHeader({ fromName: 'ignored', from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' } }), + ).toBe('Hans Bolag AB ') + }) + + it('strips header-injection characters from the explicit name', () => { + expect( + buildFromHeader({ from: { name: 'Hans \r\nBcc: x', address: 'faktura@hansbolag.example' } }), + ).toBe('"Hans BolagBcc: x" ') // 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" ', + ) + // 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" ', + ) + }) + + 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 ', + ) + expect(buildFromHeader({ fromName: 'Hans Bolag AB', from: { name: ' ', address: 'faktura@hansbolag.example' } })).toBe( + 'Hans Bolag AB via Accounted ', + ) + }) +}) + +describe('ResendEmailService.sendEmail', () => { + const service = new ResendEmailService() + const base = { to: 'kund@example.com', subject: 'Faktura 1', html: '

x

', 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 ') + }) + + 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 ') + }) + + 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 ') + expect(sendMock.mock.calls[1][0].from).toBe('Hans Bolag AB via Accounted ') + // 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) + }) +}) diff --git a/extensions/general/email/__tests__/sending-domain-routes.test.ts b/extensions/general/email/__tests__/sending-domain-routes.test.ts new file mode 100644 index 00000000..5650cdbb --- /dev/null +++ b/extensions/general/email/__tests__/sending-domain-routes.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { emailExtension } from '@/extensions/general/email' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' +import type { ExtensionContext } from '@/lib/extensions/types' + +const claimMock = vi.fn() +const verifyMock = vi.fn() +const removeMock = vi.fn() +const getMock = vi.fn() +const updateMock = vi.fn() +const webhookApplyMock = vi.fn() + +vi.mock('@/extensions/general/email/lib/sending-domains', () => ({ + claimSendingDomain: (...args: unknown[]) => claimMock(...args), + checkSendingDomainVerification: (...args: unknown[]) => verifyMock(...args), + removeSendingDomain: (...args: unknown[]) => removeMock(...args), + getSendingDomain: (...args: unknown[]) => getMock(...args), + updateSendingDomainSettings: (...args: unknown[]) => updateMock(...args), + applySendingDomainStatusFromWebhook: (...args: unknown[]) => webhookApplyMock(...args), +})) + +const hasCapabilityMock = vi.fn() +vi.mock('@/lib/entitlements/has-capability', async () => { + const actual = await vi.importActual( + '@/lib/entitlements/has-capability', + ) + return { + ...actual, + hasCapability: (...args: unknown[]) => hasCapabilityMock(...args), + requireCapability: async (supabase: unknown, companyId: string, key: string) => + (await hasCapabilityMock(supabase, companyId, key)) ? null : actual.capabilityBlockedResponse(key as never), + } +}) + +const isSandboxMock = vi.fn() +vi.mock('@/lib/sandbox/guard', () => ({ + isSandboxCompany: (...args: unknown[]) => isSandboxMock(...args), +})) + +// The delivery webhook path is covered by delivery-webhook.test.ts; here we +// only need the domain.updated branch, so the signature check is stubbed. +const verifyWebhookMock = vi.fn() +vi.mock('@/extensions/general/email/lib/delivery-webhook', async () => { + const actual = await vi.importActual( + '@/extensions/general/email/lib/delivery-webhook', + ) + return { + ...actual, + isDeliveryWebhookConfigured: () => true, + verifyDeliveryWebhook: (...args: unknown[]) => verifyWebhookMock(...args), + } +}) + +vi.mock('@/lib/auth/api-keys', () => ({ + createServiceClientNoCookies: () => ({ rpc: vi.fn().mockResolvedValue({ data: null, error: null }) }), +})) + +function findRoute(method: string, path: string) { + return emailExtension.apiRoutes!.find((r) => r.method === method && r.path === path)! +} + +function buildCtx(supabase: unknown, overrides: Partial = {}): ExtensionContext { + return { + userId: 'user-1', + companyId: 'company-1', + extensionId: 'email', + supabase: supabase as ExtensionContext['supabase'], + emit: vi.fn(), + settings: { get: vi.fn(), set: vi.fn() }, + storage: { from: vi.fn() } as unknown as ExtensionContext['storage'], + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'], + services: {}, + ...overrides, + } as ExtensionContext +} + +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: [], + verified_at: null, + last_checked_at: null, +} + +/** A context whose supabase answers the admin-role lookup with `role`. */ +function adminCtx(role: 'owner' | 'admin' | 'member' = 'owner') { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { role } }) + return buildCtx(supabase) +} + +beforeEach(() => { + vi.clearAllMocks() + hasCapabilityMock.mockResolvedValue(true) + isSandboxMock.mockResolvedValue(false) +}) + +describe('GET /sending-domain', () => { + const route = findRoute('GET', '/sending-domain') + + it('returns 401 without context', async () => { + const res = await route.handler(createMockRequest('/sending-domain'), undefined) + expect(res.status).toBe(401) + }) + + it('returns 403 capability_blocked without the opt-in grant (the UI hides on this)', async () => { + hasCapabilityMock.mockResolvedValue(false) + const { supabase } = createQueuedMockSupabase() + const res = await route.handler(createMockRequest('/sending-domain'), buildCtx(supabase)) + const { status, body } = await parseJsonResponse<{ capability_blocked: boolean; capability: string }>(res) + expect(status).toBe(403) + expect(body.capability_blocked).toBe(true) + expect(body.capability).toBe('custom_sender_domain') + expect(getMock).not.toHaveBeenCalled() + }) + + it('returns the current row (or null) for any member with the grant', async () => { + getMock.mockResolvedValue(ROW) + const { supabase } = createQueuedMockSupabase() + const res = await route.handler(createMockRequest('/sending-domain'), buildCtx(supabase)) + const { status, body } = await parseJsonResponse<{ data: typeof ROW }>(res) + expect(status).toBe(200) + expect(body.data.domain).toBe('hansbolag.example') + expect(getMock).toHaveBeenCalledWith(expect.anything(), 'company-1') + }) +}) + +describe('POST /sending-domain', () => { + const route = findRoute('POST', '/sending-domain') + const request = () => + createMockRequest('/sending-domain', { method: 'POST', body: { domain: 'hansbolag.example' } }) + + it('returns 403 for a plain member', async () => { + const res = await route.handler(request(), adminCtx('member')) + expect(res.status).toBe(403) + expect(claimMock).not.toHaveBeenCalled() + }) + + it('returns 403 for a sandbox company', async () => { + isSandboxMock.mockResolvedValue(true) + const res = await route.handler(request(), adminCtx()) + expect(res.status).toBe(403) + expect(claimMock).not.toHaveBeenCalled() + }) + + it('returns 400 on an invalid body', async () => { + const res = await route.handler( + createMockRequest('/sending-domain', { method: 'POST', body: { domain: '' } }), + adminCtx(), + ) + expect(res.status).toBe(400) + }) + + it('claims the domain for an admin', async () => { + claimMock.mockResolvedValue({ ok: true, data: ROW }) + const res = await route.handler(request(), adminCtx('admin')) + const { status, body } = await parseJsonResponse<{ data: typeof ROW }>(res) + expect(status).toBe(200) + expect(body.data.id).toBe('row-1') + // (tenant RLS client, service-role writer, company, domain) + expect(claimMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'company-1', 'hansbolag.example') + }) + + it('propagates the helper status code', async () => { + claimMock.mockResolvedValue({ ok: false, status: 409, error: 'Domänen är redan registrerad.' }) + const res = await route.handler(request(), adminCtx()) + expect(res.status).toBe(409) + }) +}) + +describe('POST /sending-domain/verify', () => { + const route = findRoute('POST', '/sending-domain/verify') + + it('returns 404 when no domain exists', async () => { + verifyMock.mockResolvedValue({ ok: false, status: 404, error: 'Ingen avsändardomän är registrerad.' }) + const res = await route.handler(createMockRequest('/sending-domain/verify', { method: 'POST' }), adminCtx()) + expect(res.status).toBe(404) + }) + + it('returns the re-checked row', async () => { + verifyMock.mockResolvedValue({ ok: true, data: { ...ROW, status: 'verified' } }) + const res = await route.handler(createMockRequest('/sending-domain/verify', { method: 'POST' }), adminCtx()) + const { status, body } = await parseJsonResponse<{ data: typeof ROW }>(res) + expect(status).toBe(200) + expect(body.data.status).toBe('verified') + }) +}) + +describe('PATCH /sending-domain', () => { + const route = findRoute('PATCH', '/sending-domain') + + it('rejects unknown keys with 400', async () => { + const res = await route.handler( + createMockRequest('/sending-domain', { method: 'PATCH', body: { domain: 'x.example' } }), + adminCtx(), + ) + expect(res.status).toBe(400) + expect(updateMock).not.toHaveBeenCalled() + }) + + it('passes the validated patch through', async () => { + updateMock.mockResolvedValue({ ok: true, data: { ...ROW, enabled: false } }) + const res = await route.handler( + createMockRequest('/sending-domain', { method: 'PATCH', body: { enabled: false, sender_name: null } }), + adminCtx(), + ) + expect(res.status).toBe(200) + expect(updateMock).toHaveBeenCalledWith(expect.anything(), 'company-1', { enabled: false, sender_name: null }) + }) +}) + +describe('DELETE /sending-domain', () => { + const route = findRoute('DELETE', '/sending-domain') + + it('returns 403 for a plain member', async () => { + const res = await route.handler(createMockRequest('/sending-domain', { method: 'DELETE' }), adminCtx('member')) + expect(res.status).toBe(403) + expect(removeMock).not.toHaveBeenCalled() + }) + + it('removes for an owner', async () => { + removeMock.mockResolvedValue({ ok: true, data: { removed: true } }) + const res = await route.handler(createMockRequest('/sending-domain', { method: 'DELETE' }), adminCtx()) + const { status, body } = await parseJsonResponse<{ data: { removed: boolean } }>(res) + expect(status).toBe(200) + expect(body.data.removed).toBe(true) + }) +}) + +describe('POST /delivery-status: domain.updated', () => { + const route = findRoute('POST', '/delivery-status') + + it('applies domain.updated to sending-domain rows and reports the match', async () => { + process.env.RESEND_DELIVERY_WEBHOOK_SECRET = 'whsec_test' + verifyWebhookMock.mockReturnValue({ + type: 'domain.updated', + created_at: '2026-08-22T10:00:00Z', + data: { id: 'rd_1', name: 'hansbolag.example', status: 'verified', records: [] }, + }) + webhookApplyMock.mockResolvedValue('applied') + const res = await route.handler( + createMockRequest('/delivery-status', { method: 'POST', body: { type: 'domain.updated' } }), + undefined, + ) + const { status, body } = await parseJsonResponse<{ data: { applied: boolean } }>(res) + expect(status).toBe(200) + expect(body.data.applied).toBe(true) + expect(webhookApplyMock).toHaveBeenCalledWith(expect.anything(), { id: 'rd_1', status: 'verified', records: [] }) + }) + + it('acknowledges an unknown domain with 200 but answers 500 on a database error so Svix retries', async () => { + process.env.RESEND_DELIVERY_WEBHOOK_SECRET = 'whsec_test' + verifyWebhookMock.mockReturnValue({ + type: 'domain.updated', + created_at: '2026-08-22T10:00:00Z', + data: { id: 'rd_other', name: 'other.example', status: 'verified', records: [] }, + }) + const request = () => + createMockRequest('/delivery-status', { method: 'POST', body: { type: 'domain.updated' } }) + + webhookApplyMock.mockResolvedValue('no_match') + const ignored = await parseJsonResponse<{ data: { applied: boolean; reason: string } }>( + await route.handler(request(), undefined), + ) + expect(ignored.status).toBe(200) + expect(ignored.body.data).toEqual({ applied: false, reason: 'no_matching_domain' }) + + webhookApplyMock.mockResolvedValue('error') + const failed = await route.handler(request(), undefined) + expect(failed.status).toBe(500) + }) +}) diff --git a/extensions/general/email/__tests__/sending-domains.test.ts b/extensions/general/email/__tests__/sending-domains.test.ts new file mode 100644 index 00000000..6f137e97 --- /dev/null +++ b/extensions/general/email/__tests__/sending-domains.test.ts @@ -0,0 +1,427 @@ +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 & { 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 + 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 \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 + 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 + 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 + 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 + expect(update.status).toBe('failed') + expect(update.verified_at).toBe('2026-08-01T00:00:00Z') + }) +}) diff --git a/extensions/general/email/index.ts b/extensions/general/email/index.ts index ef482454..2ca33c08 100644 --- a/extensions/general/email/index.ts +++ b/extensions/general/email/index.ts @@ -1,8 +1,13 @@ import { NextResponse } from 'next/server' -import type { Extension } from '@/lib/extensions/types' +import { z } from 'zod' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { registerEmailService } from '@/lib/email/service' import { createServiceClientNoCookies } from '@/lib/auth/api-keys' import { createLogger } from '@/lib/logger' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { isSandboxCompany } from '@/lib/sandbox/guard' import { ResendEmailService } from './lib/resend-service' import { ResendDeliverySignatureError, @@ -10,18 +15,187 @@ import { toDeliveryReport, verifyDeliveryWebhook, } from './lib/delivery-webhook' +import { + applySendingDomainStatusFromWebhook, + checkSendingDomainVerification, + claimSendingDomain, + getSendingDomain, + removeSendingDomain, + updateSendingDomainSettings, +} from './lib/sending-domains' // Register the Resend implementation immediately when this extension is loaded registerEmailService(new ResendEmailService()) const log = createLogger('email-delivery-webhook') +// Claim body for POST /sending-domain. Length-capped only: real validation +// (punycode, hostname shape, blocklist) lives in the sending-domains module. +const ClaimSendingDomainSchema = z.object({ + domain: z.string().trim().min(1).max(255), +}) + +const PatchSendingDomainSchema = z + .object({ + sender_local_part: z.string().trim().min(1).max(64).optional(), + sender_name: z.string().trim().max(120).nullable().optional(), + enabled: z.boolean().optional(), + }) + .strict() + +async function isCompanyAdmin( + supabase: SupabaseClient, + userId: string, + companyId: string, +): Promise { + const { data } = await supabase + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', userId) + .maybeSingle() + const role = (data as { role?: string } | null)?.role + return role === 'owner' || role === 'admin' +} + +/** + * Shared preamble for the sending-domain routes: auth context, the opt-in + * capability grant (403 capability_blocked when missing: the UI hides the + * section on that), and for writes the owner/admin role plus the sandbox + * block (anonymous demo accounts must not register domains in our Resend + * account). Returns the response to send, or null to proceed. + */ +async function guardSendingDomainRoute( + ctx: ExtensionContext | undefined, + opts: { write: boolean }, +): Promise { + if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const blocked = await requireCapability(ctx.supabase, ctx.companyId, CAPABILITY.custom_sender_domain) + if (blocked) return blocked + if (!opts.write) return null + if (!(await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId))) { + return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) + } + if (await isSandboxCompany(ctx.supabase, ctx.companyId)) { + return NextResponse.json({ error: 'Egen avsändardomän är inte tillgänglig i sandlådan.' }, { status: 403 }) + } + return null +} + export const emailExtension: Extension = { id: 'email', name: 'E-post (Resend)', version: '1.0.0', apiRoutes: [ + // ── Company sending domain: read current state ─────────── + { + method: 'GET', + path: '/sending-domain', + handler: async (_request: Request, ctx?: ExtensionContext) => { + const denied = await guardSendingDomainRoute(ctx, { write: false }) + if (denied) return denied + try { + // null when the company has no sending domain: the UI renders the + // claim form in that case. + const row = await getSendingDomain(ctx!.supabase, ctx!.companyId) + return NextResponse.json({ data: row }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to load sending domain' }, + { status: 500 }, + ) + } + }, + }, + + // ── Company sending domain: claim (owner/admin only) ───── + { + method: 'POST', + path: '/sending-domain', + handler: async (request: Request, ctx?: ExtensionContext) => { + const denied = await guardSendingDomainRoute(ctx, { write: true }) + if (denied) return denied + + let body: z.infer + try { + body = ClaimSendingDomainSchema.parse(await request.json()) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Invalid request body' }, + { status: 400 }, + ) + } + + // Verification state is service-role only (tenant guard trigger); + // the user client still does the insert, so RLS proves membership. + const result = await claimSendingDomain( + ctx!.supabase, + createServiceClientNoCookies(), + ctx!.companyId, + body.domain, + ) + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status }) + return NextResponse.json({ data: result.data }) + }, + }, + + // ── Company sending domain: re-check verification ──────── + { + method: 'POST', + path: '/sending-domain/verify', + handler: async (_request: Request, ctx?: ExtensionContext) => { + const denied = await guardSendingDomainRoute(ctx, { write: true }) + if (denied) return denied + + const result = await checkSendingDomainVerification( + ctx!.supabase, + createServiceClientNoCookies(), + ctx!.companyId, + ) + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status }) + return NextResponse.json({ data: result.data }) + }, + }, + + // ── Company sending domain: sender address/name, pause ─── + { + method: 'PATCH', + path: '/sending-domain', + handler: async (request: Request, ctx?: ExtensionContext) => { + const denied = await guardSendingDomainRoute(ctx, { write: true }) + if (denied) return denied + + let body: z.infer + try { + body = PatchSendingDomainSchema.parse(await request.json()) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Invalid request body' }, + { status: 400 }, + ) + } + + const result = await updateSendingDomainSettings(ctx!.supabase, ctx!.companyId, body) + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status }) + return NextResponse.json({ data: result.data }) + }, + }, + + // ── Company sending domain: remove (owner/admin only) ──── + { + method: 'DELETE', + path: '/sending-domain', + handler: async (_request: Request, ctx?: ExtensionContext) => { + const denied = await guardSendingDomainRoute(ctx, { write: true }) + if (denied) return denied + + const result = await removeSendingDomain(ctx!.supabase, ctx!.companyId) + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status }) + return NextResponse.json({ data: result.data }) + }, + }, + // ── Resend delivery webhook (Svix-signed, no user auth) ── // Reports whether a sent invoice email actually arrived. Resend pushes // every event for the account to this endpoint, including mail that is not @@ -50,6 +224,28 @@ export const emailExtension: Extension = { return NextResponse.json({ error: 'Verification failed' }, { status: 500 }) } + // Resend pushes domain.* lifecycle events to the same endpoint. Apply + // domain.updated to company sending-domain rows so verification flips + // without the user pressing "Kontrollera igen" (requires the event + // type to be subscribed on the Resend webhook; harmless when it isn't). + if (event.type === 'domain.updated') { + const outcome = await applySendingDomainStatusFromWebhook(createServiceClientNoCookies(), { + id: event.data.id, + status: event.data.status, + records: event.data.records, + }) + // A database error must not be acknowledged: Svix retries non-2xx + // with backoff, which is exactly the recovery wanted for a + // transient failure (same rule as the delivery status below). + if (outcome === 'error') { + log.error('failed to apply domain status', undefined, { domainId: event.data.id }) + return NextResponse.json({ error: 'Failed to record domain status' }, { status: 500 }) + } + return NextResponse.json({ + data: { applied: outcome === 'applied', reason: outcome === 'no_match' ? 'no_matching_domain' : undefined }, + }) + } + const report = toDeliveryReport(event) if (!report) { return NextResponse.json({ data: { applied: false, reason: 'ignored_event' } }) diff --git a/extensions/general/email/lib/resend-service.ts b/extensions/general/email/lib/resend-service.ts index ac9497c3..3f84f2ff 100644 --- a/extensions/general/email/lib/resend-service.ts +++ b/extensions/general/email/lib/resend-service.ts @@ -17,6 +17,56 @@ function sanitizeHeaderPart(s: string): string { return s.replace(/[\r\n<>]/g, '').trim() } +// RFC 5322 "specials" that make a bare display name ambiguous (a comma splits +// the mailbox list, a quote or parenthesis opens a token). Names without any +// of them stay bare so existing headers are byte-identical. +const DISPLAY_NAME_SPECIALS = /[()<>[\]:;@\\,."]/ + +/** Quote a display name only when RFC 5322 requires it; escape `\` and `"`. */ +export function encodeDisplayName(name: string): string { + if (!DISPLAY_NAME_SPECIALS.test(name)) return name + return `"${name.replace(/[\\"]/g, (c) => `\\${c}`)}"` +} + +// Conservative address shape for an explicit From: the local part comes from +// our own validated column and the domain is a verified hostname, so this is +// a last-line guard against a malformed row, not a full RFC 5322 parser. +const FROM_ADDRESS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}@[a-z0-9.-]{4,253}$/ + +/** + * Builds the From header. With an explicit `from` (company's own verified + * sending domain) the mail leaves as "
" and the platform + * sender is not involved at all. Otherwise the platform default: + * " via " or " ". + * + * Strip CRLF and angle brackets from name parts to prevent header injection. + * Resend's API does its own validation, but defense in depth: fromName and + * from.name (user-controlled, from company settings) and appName + * (admin-controlled, from branding) all flow into the From header. + * Exported for unit tests. + */ +export function buildFromHeader(input: { + fromName?: string + from?: { name: string; address: string } +}): string { + const safeAppName = sanitizeHeaderPart(getBranding().appName) + + if (input.from) { + const address = input.from.address.trim().toLowerCase() + const name = sanitizeHeaderPart(input.from.name) + if (FROM_ADDRESS_PATTERN.test(address) && name) { + return `${encodeDisplayName(name)} <${address}>` + } + // A malformed explicit sender falls through to the platform default + // rather than failing the send: the fallback is the whole point. + } + + const safeFromName = input.fromName ? sanitizeHeaderPart(input.fromName) : null + return safeFromName + ? `${encodeDisplayName(`${safeFromName} via ${safeAppName}`)} <${DEFAULT_FROM_EMAIL}>` + : `${encodeDisplayName(safeAppName)} <${DEFAULT_FROM_EMAIL}>` +} + function optionalAddressList(addresses: string | string[] | undefined): string[] | undefined { if (!addresses) return undefined const list = Array.isArray(addresses) ? addresses : [addresses] @@ -47,20 +97,12 @@ export class ResendEmailService implements EmailService { return { success: false, error: 'Email service is not configured' } } - // Strip CRLF and angle brackets from name parts to prevent header injection. - // Resend's API does its own validation, but defense in depth: both fromName - // (user-controlled, from company settings) and appName (admin-controlled, - // from branding) flow into the From header. - const safeAppName = sanitizeHeaderPart(getBranding().appName) - const safeFromName = fromName ? sanitizeHeaderPart(fromName) : null - const from = safeFromName - ? `${safeFromName} via ${safeAppName} <${DEFAULT_FROM_EMAIL}>` - : `${safeAppName} <${DEFAULT_FROM_EMAIL}>` + const from = buildFromHeader({ fromName, from: options.from }) + const platformFrom = buildFromHeader({ fromName }) try { const resend = getResendClient() - const response = await resend.emails.send({ - from, + const payload = { to: Array.isArray(to) ? to : [to], cc: optionalAddressList(cc), bcc: optionalAddressList(bcc), @@ -75,7 +117,22 @@ export class ResendEmailService implements EmailService { : Buffer.from(att.content), contentType: att.contentType, })), - }) + } + let response = await resend.emails.send({ from, ...payload }) + + // A company's own sending domain can stop being accepted after the + // fact (DKIM removed, Resend flipped the domain to failed before the + // webhook or a manual re-check caught up). Resend rejected the send, + // so nothing went out: retry once as the platform sender rather than + // letting every invoice for that company fail. The row is corrected by + // the next verification check; this only keeps mail flowing. + if (response.error && from !== platformFrom) { + log.warn('Resend rejected the company sender, retrying as the platform sender', { + from, + error: response.error.message, + }) + response = await resend.emails.send({ from: platformFrom, ...payload }) + } if (response.error) { log.error('Resend error:', response.error) diff --git a/extensions/general/email/lib/sending-domains.ts b/extensions/general/email/lib/sending-domains.ts new file mode 100644 index 00000000..e39f79bb --- /dev/null +++ b/extensions/general/email/lib/sending-domains.ts @@ -0,0 +1,540 @@ +import { Resend } from 'resend' +import type { DomainStatus } from 'resend' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { CompanySendingDomain, CompanySendingDomainStatus } from '@/types' +import { + isReservedSenderDomain, + normalizeDomainName, + normalizeSenderLocalPart, +} from '@/lib/email/domain-name' + +/** + * Per-company outbound sending domains (opt-in): the Resend side of the + * feature. Claim registers the domain in the platform's Resend account with + * the SENDING capability only; the company publishes DKIM/SPF; once Resend + * reports verified, resolveInvoiceSender() (core) starts using it. + * + * Mirrors the inbox extension's receiving-only custom domains, with one + * deliberate difference: NO orphan adoption. The same Resend account holds + * the platform's own outbound domain(s); binding a tenant row to an existing + * sending domain would hand them production sending infrastructure. A name + * that already exists in Resend is a 409, not an adoption. + */ + +function getResend(): Resend { + const apiKey = process.env.RESEND_API_KEY + if (!apiKey) throw new Error('RESEND_API_KEY is required') + return new Resend(apiKey) +} + +export type SendingDomainResult = + | { ok: true; data: T } + | { ok: false; status: number; error: string } + +// Public mailbox providers a company can never own. DNS verification is the +// real ownership gate: this list only fails fast with a clear message. +const PUBLIC_EMAIL_DOMAINS = new Set([ + 'gmail.com', + 'googlemail.com', + 'outlook.com', + 'hotmail.com', + 'hotmail.se', + 'live.com', + 'live.se', + 'msn.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'yahoo.com', + 'ymail.com', + 'protonmail.com', + 'proton.me', + 'fastmail.com', + 'gmx.com', + 'telia.com', + 'comhem.se', + 'spray.se', + 'passagen.se', +]) + +/** + * Domains a tenant may never claim as a sending domain: public mailbox + * providers, and the platform's reserved domains (RESEND_FROM_EMAIL domain, + * shared inbound domain, app host, plus subdomains; see + * isReservedSenderDomain, which resolveInvoiceSender enforces again at send + * time). Returns a Swedish error message, or null when claimable. + */ +export function validateClaimableSendingDomain(domain: string): string | null { + if (PUBLIC_EMAIL_DOMAINS.has(domain)) { + return 'Publika e-postdomäner (t.ex. Gmail, Outlook) kan inte användas. Ange en domän som bolaget äger.' + } + if (isReservedSenderDomain(domain)) { + return 'Den här domänen är reserverad och kan inte användas som avsändardomän.' + } + return null +} + +/** + * Resend's view of a domain must be the domain our row claims. A tenant can + * delete and re-insert its pending row (same id, different domain) while a + * claim is mid-flight, so every verification-state write re-checks the name + * instead of trusting the row id alone. + */ +function resendNameMatchesRow(resendName: string | undefined, rowDomain: string): boolean { + if (!resendName) return false + return (normalizeDomainName(resendName) ?? resendName.toLowerCase()) === rowDomain.toLowerCase() +} + +// `temporary_failure` is a runtime status the Resend API can still return but +// which the SDK's DomainStatus type dropped: accept it explicitly. +export function mapResendSendingStatus( + status: DomainStatus | 'temporary_failure', +): CompanySendingDomainStatus { + switch (status) { + case 'verified': + return 'verified' + // A previously verified domain failed a DNS re-check; Resend keeps it + // active while it retries (~72h). Keep sending rather than silently + // flipping every invoice back to the platform sender on a DNS blip. + case 'temporary_failure': + return 'verified' + case 'failed': + case 'partially_failed': + return 'failed' + default: + return 'pending' // 'pending' | 'not_started' | 'partially_verified' + } +} + +/** + * Only domains this feature created (sending-only) may be touched in Resend. + * The platform's own sender domain and the inbox feature's receiving-only + * domains live in the same account. + */ +export function isSendingOnlyProfile( + capabilities: { sending?: string; receiving?: string } | null | undefined, +): boolean { + return capabilities?.sending === 'enabled' && capabilities?.receiving !== 'enabled' +} + +export async function getSendingDomain( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { data, error } = await supabase + .from('company_sending_domains') + .select('*') + .eq('company_id', companyId) + .maybeSingle() + + if (error) throw new Error(`Failed to load sending domain: ${error.message}`) + return (data as CompanySendingDomain | null) ?? null +} + +/** + * Claim a sending domain for the company: insert the row, register the + * domain in Resend with the sending capability, store the DNS records the + * user must publish. The DB insert goes first so the unique indexes + * (lower(domain), company_id) serialize concurrent claims before we ever + * talk to Resend; every failure after that rolls the row back. + * + * Two clients on purpose: `supabase` is the caller's RLS client (proves + * owner/admin membership on the insert and the rollback delete); `writer` is + * a service-role client for the verification state (resend_domain_id, + * dns_records, status), which the tenant guard trigger + * (20260822130000) refuses from tenant JWTs. Every writer query still filters + * on company_id: defense in depth, never the only check. + */ +export async function claimSendingDomain( + supabase: SupabaseClient, + writer: SupabaseClient, + companyId: string, + rawDomain: string, +): Promise> { + const domain = normalizeDomainName(rawDomain) + if (!domain) { + return { ok: false, status: 400, error: 'Ogiltig domän. Ange t.ex. dittbolag.se.' } + } + const blocked = validateClaimableSendingDomain(domain) + if (blocked) return { ok: false, status: 400, error: blocked } + + const { data: inserted, error: insertError } = await supabase + .from('company_sending_domains') + .insert({ company_id: companyId, domain, status: 'pending' }) + .select('*') + .single() + + if (insertError || !inserted) { + if (insertError?.code === '23505') { + const message = insertError.message.includes('idx_company_sending_domains_company') + ? 'Bolaget har redan en avsändardomän. Ta bort den innan du lägger till en ny.' + : 'Domänen är redan registrerad.' + return { ok: false, status: 409, error: message } + } + return { + ok: false, + status: 500, + error: insertError?.message ?? 'Kunde inte spara domänen.', + } + } + + const rollback = async () => { + await supabase + .from('company_sending_domains') + .delete() + .eq('id', inserted.id) + .eq('company_id', companyId) + } + + try { + const resend = getResend() + + // Sending only: receiving stays disabled so the DNS list is DKIM/SPF + // only and the company's existing MX (their real mailbox) is untouched. + const created = await resend.domains.create({ + name: domain, + region: 'eu-west-1', + capabilities: { sending: 'enabled', receiving: 'disabled' }, + }) + + if (created.error || !created.data) { + await rollback() + // No adoption path on purpose (see module comment): an existing name + // is a conflict the operator resolves, never something a tenant binds. + const conflict = /exist/i.test(created.error?.message ?? '') + return conflict + ? { + ok: false, + status: 409, + error: + 'Domänen finns redan hos e-postleverantören och kan inte läggas till automatiskt. Kontakta supporten.', + } + : { + ok: false, + status: 502, + error: `Kunde inte registrera domänen hos e-postleverantören: ${created.error?.message ?? 'okänt fel'}`, + } + } + + const resendDomainId = created.data.id + + // get() rather than the create response: it returns the same shape with + // the full DNS record list and the per-record status the UI renders. + const fetched = await resend.domains.get(resendDomainId) + if (fetched.error || !fetched.data) { + await resend.domains.remove(resendDomainId).catch(() => undefined) + await rollback() + return { + ok: false, + status: 502, + error: `Kunde inte hämta DNS-poster: ${fetched.error?.message ?? 'okänt fel'}`, + } + } + + // A freshly created domain has no DNS yet, so it is never verified here; + // mapping the status anyway keeps the helper honest about what Resend + // said rather than hardcoding 'pending'. + const status = mapResendSendingStatus(fetched.data.status) + // Bind the Resend domain only to the row we inserted, still carrying the + // domain we registered and not yet bound: a concurrent tenant + // delete + re-insert under the same id (different domain) matches zero + // rows, which .single() reports as an error and we roll back below. + const { data: updated, error: updateError } = await writer + .from('company_sending_domains') + .update({ + resend_domain_id: resendDomainId, + dns_records: fetched.data.records, + status, + verified_at: status === 'verified' ? new Date().toISOString() : null, + last_checked_at: new Date().toISOString(), + }) + .eq('id', inserted.id) + .eq('company_id', companyId) + .eq('domain', domain) + .is('resend_domain_id', null) + .select('*') + .single() + + if (updateError || !updated) { + await resend.domains.remove(resendDomainId).catch(() => undefined) + await rollback() + return { ok: false, status: 500, error: updateError?.message ?? 'Kunde inte spara DNS-poster.' } + } + + return { ok: true, data: updated as CompanySendingDomain } + } catch (err) { + await rollback() + return { + ok: false, + status: 502, + error: err instanceof Error ? err.message : 'Domänregistreringen misslyckades.', + } + } +} + +/** + * Re-check verification with Resend and persist the outcome. verify() kicks + * off Resend's DNS check; get() reads the (possibly updated) status and the + * per-record state shown in the UI. + */ +export async function checkSendingDomainVerification( + supabase: SupabaseClient, + writer: SupabaseClient, + companyId: string, +): Promise> { + const row = await getSendingDomain(supabase, companyId) + if (!row) return { ok: false, status: 404, error: 'Ingen avsändardomän är registrerad.' } + if (!row.resend_domain_id) { + return { + ok: false, + status: 409, + error: 'Domänen saknar koppling till e-postleverantören. Ta bort den och lägg till den igen.', + } + } + + try { + const resend = getResend() + await resend.domains.verify(row.resend_domain_id) + const fetched = await resend.domains.get(row.resend_domain_id) + if (fetched.error || !fetched.data) { + return { + ok: false, + status: 502, + error: `Kunde inte kontrollera domänen: ${fetched.error?.message ?? 'okänt fel'}`, + } + } + + // A domain without the sending capability can never carry outbound + // mail: fail loudly instead of ever flipping such a row to verified. + if (fetched.data.capabilities?.sending !== 'enabled') { + return { + ok: false, + status: 409, + error: + 'Domänen är inte konfigurerad för utskick hos e-postleverantören. Ta bort den och lägg till den igen.', + } + } + if (!resendNameMatchesRow(fetched.data.name, row.domain)) { + return { + ok: false, + status: 409, + error: 'Domänen stämmer inte med e-postleverantörens registrering. Ta bort den och lägg till den igen.', + } + } + + const status = mapResendSendingStatus(fetched.data.status) + const { data: updated, error: updateError } = await writer + .from('company_sending_domains') + .update({ + status, + dns_records: fetched.data.records, + last_checked_at: new Date().toISOString(), + verified_at: status === 'verified' ? (row.verified_at ?? new Date().toISOString()) : row.verified_at, + }) + .eq('id', row.id) + .eq('company_id', companyId) + .select('*') + .single() + + if (updateError || !updated) { + return { ok: false, status: 500, error: updateError?.message ?? 'Kunde inte spara status.' } + } + return { ok: true, data: updated as CompanySendingDomain } + } catch (err) { + return { + ok: false, + status: 502, + error: err instanceof Error ? err.message : 'Kontrollen misslyckades.', + } + } +} + +export interface SendingDomainSettingsPatch { + sender_local_part?: string + sender_name?: string | null + enabled?: boolean +} + +/** Update the From address local part, display name, or the enabled toggle. */ +export async function updateSendingDomainSettings( + supabase: SupabaseClient, + companyId: string, + patch: SendingDomainSettingsPatch, +): Promise> { + const row = await getSendingDomain(supabase, companyId) + if (!row) return { ok: false, status: 404, error: 'Ingen avsändardomän är registrerad.' } + + // Literal payload (keys visible to the schema guard); undefined values are + // dropped by JSON serialization, so an omitted field is left untouched + // while an explicit null clears sender_name. + let senderLocalPart: string | undefined + if (patch.sender_local_part !== undefined) { + const local = normalizeSenderLocalPart(patch.sender_local_part) + if (!local) { + return { + ok: false, + status: 400, + error: 'Ogiltig avsändaradress. Använd små bokstäver, siffror, punkt, bindestreck eller understreck.', + } + } + senderLocalPart = local + } + let senderName: string | null | undefined + if (patch.sender_name !== undefined) { + const name = patch.sender_name === null ? null : patch.sender_name.replace(/[\r\n<>]/g, '').trim() + if (name !== null && (name.length === 0 || name.length > 120)) { + return { ok: false, status: 400, error: 'Avsändarnamnet måste vara 1 till 120 tecken.' } + } + senderName = name + } + if (senderLocalPart === undefined && senderName === undefined && patch.enabled === undefined) { + return { ok: true, data: row } + } + + const { data: updated, error } = await supabase + .from('company_sending_domains') + .update({ + sender_local_part: senderLocalPart, + sender_name: senderName, + enabled: patch.enabled, + }) + .eq('id', row.id) + .eq('company_id', companyId) + .select('*') + .single() + + if (error || !updated) { + return { ok: false, status: 500, error: error?.message ?? 'Kunde inte spara inställningen.' } + } + return { ok: true, data: updated as CompanySendingDomain } +} + +/** + * Remove the sending domain: delete it from Resend first, then the row. + * Only Resend domains this feature created (sending-only profile) are ever + * removed; anything else (a legacy row somehow bound to the platform sender + * or to an inbox domain) just drops the DB row and leaves Resend alone. + */ +export async function removeSendingDomain( + supabase: SupabaseClient, + companyId: string, +): Promise> { + const row = await getSendingDomain(supabase, companyId) + if (!row) return { ok: false, status: 404, error: 'Ingen avsändardomän är registrerad.' } + + if (row.resend_domain_id) { + try { + const resend = getResend() + const fetched = await resend.domains.get(row.resend_domain_id) + if (fetched.error && fetched.error.statusCode !== 404) { + return { + ok: false, + status: 502, + error: `Kunde inte kontrollera domänen hos e-postleverantören: ${fetched.error.message}`, + } + } + if ( + fetched.data && + isSendingOnlyProfile(fetched.data.capabilities) && + !validateClaimableSendingDomain(normalizeDomainName(fetched.data.name) ?? fetched.data.name) + ) { + const removed = await resend.domains.remove(row.resend_domain_id) + if (removed.error && removed.error.statusCode !== 404) { + return { + ok: false, + status: 502, + error: `Kunde inte ta bort domänen hos e-postleverantören: ${removed.error.message}`, + } + } + } + } catch (err) { + return { + ok: false, + status: 502, + error: err instanceof Error ? err.message : 'Borttagningen misslyckades.', + } + } + } + + const { error } = await supabase + .from('company_sending_domains') + .delete() + .eq('id', row.id) + .eq('company_id', companyId) + + if (error) return { ok: false, status: 500, error: error.message } + return { ok: true, data: { removed: true } } +} + +/** + * Outcome of applying a domain webhook event. The route maps `error` to an + * HTTP 500 so Resend (Svix) retries; `no_match` is acknowledged with 200 + * because the event belongs to a domain this table does not track (platform + * sender, inbox domains) and retrying would never change that. + */ +export type WebhookApplyOutcome = 'applied' | 'no_match' | 'error' + +/** + * Applies a Resend `domain.updated` webhook event so verification flips + * without the user pressing "Kontrollera igen". + * + * The event's status carries no capability breakdown, so before flipping a + * row to verified the sending capability is confirmed with Resend; on a + * failed lookup the stored status is kept (the manual check remains). + */ +export async function applySendingDomainStatusFromWebhook( + supabase: SupabaseClient, + event: { id: string; status: string; records?: unknown }, +): Promise { + const { data: row, error: lookupError } = await supabase + .from('company_sending_domains') + .select('id, domain, verified_at') + .eq('resend_domain_id', event.id) + .maybeSingle() + + if (lookupError) return 'error' + if (!row) return 'no_match' + const current = row as { id: string; domain: string; verified_at: string | null } + + const status = mapResendSendingStatus(event.status as DomainStatus) + + if (status === 'verified') { + // Confirm with Resend that the domain can send AND is still the domain + // the row claims before flipping to verified (see resendNameMatchesRow). + let sendingConfirmed = false + try { + const fetched = await getResend().domains.get(event.id) + sendingConfirmed = + !fetched.error && + fetched.data?.capabilities?.sending === 'enabled' && + resendNameMatchesRow(fetched.data?.name, current.domain) + } catch { + sendingConfirmed = false + } + if (!sendingConfirmed) { + // Literal payload: an undefined dns_records is dropped by JSON + // serialization, so the stored records survive an event without any. + const { error } = await supabase + .from('company_sending_domains') + .update({ + dns_records: event.records, + last_checked_at: new Date().toISOString(), + }) + .eq('id', current.id) + return error ? 'error' : 'applied' + } + } + + const { error } = await supabase + .from('company_sending_domains') + .update({ + status, + dns_records: event.records, + last_checked_at: new Date().toISOString(), + verified_at: + status === 'verified' ? (current.verified_at ?? new Date().toISOString()) : current.verified_at, + }) + .eq('id', current.id) + + return error ? 'error' : 'applied' +} diff --git a/lib/email/__tests__/domain-name.test.ts b/lib/email/__tests__/domain-name.test.ts new file mode 100644 index 00000000..5a4bdc58 --- /dev/null +++ b/lib/email/__tests__/domain-name.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { + normalizeDomainName, + isValidHostname, + isReservedSenderDomain, + normalizeSenderLocalPart, +} from '@/lib/email/domain-name' + +describe('normalizeDomainName', () => { + it('lowercases and strips trailing dots', () => { + expect(normalizeDomainName('Faktura.HansBolag.SE.')).toBe('faktura.hansbolag.se') + }) + + it('accepts a pasted URL', () => { + expect(normalizeDomainName('https://hansbolag.se/kontakt?x=1')).toBe('hansbolag.se') + }) + + it('accepts a pasted email address', () => { + expect(normalizeDomainName('faktura@hansbolag.se')).toBe('hansbolag.se') + }) + + it('punycodes Swedish IDN domains', () => { + const result = normalizeDomainName('blåbär.se') + expect(result).not.toBeNull() + expect(result!.startsWith('xn--')).toBe(true) + expect(result!.endsWith('.se')).toBe(true) + }) + + it('rejects hostnames without a dot, empty input, and IP addresses', () => { + expect(normalizeDomainName('nodots')).toBeNull() + expect(normalizeDomainName('')).toBeNull() + expect(normalizeDomainName(' ')).toBeNull() + expect(normalizeDomainName('192.168.0.1')).toBeNull() + }) +}) + +describe('isReservedSenderDomain', () => { + it("flags the platform sender domain, the inbound domain, the app host and their subdomains", () => { + const saved = { + from: process.env.RESEND_FROM_EMAIL, + inbound: process.env.RESEND_INBOUND_DOMAIN, + app: process.env.NEXT_PUBLIC_APP_URL, + } + process.env.RESEND_FROM_EMAIL = 'noreply@platform.example' + process.env.RESEND_INBOUND_DOMAIN = 'inbox.platform.example' + process.env.NEXT_PUBLIC_APP_URL = 'https://app.other.example' + try { + expect(isReservedSenderDomain('platform.example')).toBe(true) + expect(isReservedSenderDomain('mail.platform.example')).toBe(true) + expect(isReservedSenderDomain('inbox.platform.example')).toBe(true) + expect(isReservedSenderDomain('app.other.example')).toBe(true) + expect(isReservedSenderDomain('APP.OTHER.EXAMPLE')).toBe(true) + expect(isReservedSenderDomain('hansbolag.example')).toBe(false) + expect(isReservedSenderDomain('notplatform.example')).toBe(false) + } finally { + process.env.RESEND_FROM_EMAIL = saved.from + process.env.RESEND_INBOUND_DOMAIN = saved.inbound + process.env.NEXT_PUBLIC_APP_URL = saved.app + } + }) +}) + +describe('isValidHostname', () => { + it('accepts ordinary hostnames and rejects malformed labels', () => { + expect(isValidHostname('hansbolag.se')).toBe(true) + expect(isValidHostname('-bad.se')).toBe(false) + expect(isValidHostname('bad-.se')).toBe(false) + expect(isValidHostname('a.b')).toBe(false) // too short + }) +}) + +describe('normalizeSenderLocalPart', () => { + it('lowercases and accepts dot, hyphen, underscore', () => { + expect(normalizeSenderLocalPart('Faktura')).toBe('faktura') + expect(normalizeSenderLocalPart('ekonomi.ab_1-x')).toBe('ekonomi.ab_1-x') + }) + + it('rejects trailing and consecutive dots (dot-atom rule)', () => { + expect(normalizeSenderLocalPart('faktura.')).toBeNull() + expect(normalizeSenderLocalPart('fak..tura')).toBeNull() + expect(normalizeSenderLocalPart('fak.tura')).toBe('fak.tura') + }) + + it('rejects header-breaking or out-of-alphabet input', () => { + expect(normalizeSenderLocalPart('')).toBeNull() + expect(normalizeSenderLocalPart('.faktura')).toBeNull() + expect(normalizeSenderLocalPart('fak tura')).toBeNull() + expect(normalizeSenderLocalPart('fak')).toBeNull() + expect(normalizeSenderLocalPart('faktura@x')).toBeNull() + expect(normalizeSenderLocalPart('a'.repeat(65))).toBeNull() + }) +}) diff --git a/lib/email/__tests__/invoice-sender.test.ts b/lib/email/__tests__/invoice-sender.test.ts new file mode 100644 index 00000000..616825bc --- /dev/null +++ b/lib/email/__tests__/invoice-sender.test.ts @@ -0,0 +1,115 @@ +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() + }) +}) diff --git a/lib/email/domain-name.ts b/lib/email/domain-name.ts new file mode 100644 index 00000000..c3a00c1c --- /dev/null +++ b/lib/email/domain-name.ts @@ -0,0 +1,83 @@ +import { domainToASCII } from 'node:url' + +/** + * Hostname normalization for user-entered email domains. + * + * Accepts what users actually paste ("Faktura.Hansbolag.SE.", a full URL, or + * an email address) and reduces it to a lowercased, punycoded hostname. + * Returns null when no valid hostname can be extracted. Dependency-free so + * both core and extensions can share one definition of "a valid domain". + */ +export function normalizeDomainName(raw: string): string | null { + let value = String(raw ?? '').trim().toLowerCase() + value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '') // strip scheme + value = value.split('/')[0].split('?')[0] + const atIndex = value.lastIndexOf('@') + if (atIndex !== -1) value = value.slice(atIndex + 1) + value = value.replace(/^\.+|\.+$/g, '') + if (!value) return null + + // IDN -> punycode (blåbär.se -> xn--blbr-noab.se). Returns '' when the + // input is not a valid domain. + const ascii = domainToASCII(value) + if (!ascii) return null + + return isValidHostname(ascii) ? ascii : null +} + +export function isValidHostname(domain: string): boolean { + if (domain.length < 4 || domain.length > 253) return false + const labels = domain.split('.') + if (labels.length < 2) return false + if (!labels.every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return false + // TLD must contain a letter: rejects IP addresses and all-numeric TLDs. + return /[a-z]/.test(labels[labels.length - 1]) +} + +function hostnameOf(value: string | undefined): string | null { + if (!value) return null + try { + return new URL(value).hostname.toLowerCase() || null + } catch { + return null + } +} + +/** + * Domains no tenant may ever send as: the platform's own sender domain + * (RESEND_FROM_EMAIL), the shared inbound domain, and the app host, plus + * their subdomains. Read from env on every call (cheap, and tests flip env). + */ +export function reservedSenderDomains(): string[] { + const reserved: string[] = [] + const fromDomain = process.env.RESEND_FROM_EMAIL + ? normalizeDomainName(process.env.RESEND_FROM_EMAIL) + : null + if (fromDomain) reserved.push(fromDomain) + const inbound = process.env.RESEND_INBOUND_DOMAIN?.toLowerCase() + if (inbound) reserved.push(inbound) + const appHost = hostnameOf(process.env.NEXT_PUBLIC_APP_URL) + if (appHost) reserved.push(appHost) + return reserved +} + +/** True when `domain` is a reserved platform domain or a subdomain of one. */ +export function isReservedSenderDomain(domain: string): boolean { + const d = domain.toLowerCase() + return reservedSenderDomains().some((r) => d === r || d.endsWith(`.${r}`)) +} + +/** + * Local part of a sender address: conservative dot-atom subset, lowercase. + * Dots may only separate atoms (RFC 5322 dot-atom): no leading, trailing or + * consecutive dots. Mirrored by the CHECK constraint in + * 20260822130000_company_sending_domains_tenant_guard.sql. + */ +export const SENDER_LOCAL_PART_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/ +const SENDER_LOCAL_PART_MAX_LENGTH = 64 + +export function normalizeSenderLocalPart(raw: string): string | null { + const value = String(raw ?? '').trim().toLowerCase() + if (value.length === 0 || value.length > SENDER_LOCAL_PART_MAX_LENGTH) return null + return SENDER_LOCAL_PART_PATTERN.test(value) ? value : null +} diff --git a/lib/email/invoice-sender.ts b/lib/email/invoice-sender.ts new file mode 100644 index 00000000..863d5a2a --- /dev/null +++ b/lib/email/invoice-sender.ts @@ -0,0 +1,83 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { hasCapability } from '@/lib/entitlements/has-capability' +import type { CompanySendingDomain } from '@/types' +import { isReservedSenderDomain, isValidHostname } from '@/lib/email/domain-name' + +/** + * Sender identity for invoice email: the From header's display name and + * address. Only used when a company has opted in to its own sending domain; + * otherwise invoice mail keeps the platform sender. + */ +export interface InvoiceSenderIdentity { + name: string + address: string +} + +type SenderRow = Pick< + CompanySendingDomain, + 'domain' | 'status' | 'enabled' | 'sender_local_part' | 'sender_name' +> + +/** `@`; pure, so the address shape is unit-testable. */ +export function buildSenderAddress(localPart: string, domain: string): string { + return `${localPart}@${domain}` +} + +/** + * Pure mapping from a sending-domain row to the From identity, or undefined + * when the row must not change the sender (unverified, paused, or missing). + * Falls back to the company name when no explicit sender name is stored. + */ +export function senderFromRow( + row: SenderRow | null | undefined, + companyName: string | null | undefined, +): InvoiceSenderIdentity | undefined { + if (!row || row.status !== 'verified' || !row.enabled) return undefined + // Last line of defense at send time: never send as a platform domain, and + // never trust a row whose domain is not a plain hostname, whatever the DB + // says (the claim/verify paths and the tenant guard trigger enforce this + // earlier; a tampered row must still not reach the From header). + const domain = row.domain.toLowerCase() + if (!isValidHostname(domain) || isReservedSenderDomain(domain)) return undefined + const name = (row.sender_name ?? companyName ?? '').trim() + if (!name) return undefined + return { name, address: buildSenderAddress(row.sender_local_part, row.domain) } +} + +/** + * Resolve the From identity for a company's invoice email. + * + * Returns undefined in every case where the platform sender should be used: + * no sending-domain row, not verified, paused, no capability grant (the + * opt-in can lapse), or any read error. Never throws: a sender lookup + * failure must never stop an invoice from going out. + * + * Order matters for cost: most companies have no row, so the table read + * happens first and the two entitlement queries only run for opted-in + * companies. + */ +export async function resolveInvoiceSender( + supabase: SupabaseClient, + companyId: string, + companyName: string | null | undefined, +): Promise { + try { + const { data, error } = await supabase + .from('company_sending_domains') + .select('domain, status, enabled, sender_local_part, sender_name') + .eq('company_id', companyId) + .eq('status', 'verified') + .eq('enabled', true) + .maybeSingle() + if (error || !data) return undefined + + const sender = senderFromRow(data as SenderRow, companyName) + if (!sender) return undefined + + const entitled = await hasCapability(supabase, companyId, CAPABILITY.custom_sender_domain) + return entitled ? sender : undefined + } catch { + return undefined + } +} diff --git a/lib/email/service.ts b/lib/email/service.ts index 246176d4..a7c18ba0 100644 --- a/lib/email/service.ts +++ b/lib/email/service.ts @@ -15,6 +15,13 @@ export interface SendEmailOptions { text?: string replyTo?: string fromName?: string + /** + * Explicit From identity (company's own verified sending domain). When + * set, the provider sends as "
" instead of the platform + * sender; `fromName` is ignored. Callers obtain it from + * resolveInvoiceSender(): never build one from raw user input. + */ + from?: { name: string; address: string } attachments?: Array<{ filename: string content: Buffer | string diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index cd792bf7..a8027760 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -34,6 +34,14 @@ export const CAPABILITY = { woocommerce_sync: 'woocommerce_sync', /** Shopify store sync: orders/refunds imported as a transaction feed. */ shopify_sync: 'shopify_sync', + /** + * Invoice email from the company's own verified sending domain (Resend + * domain per company). Opt-in: granted manually per company, NOT part of + * PAID_CAPABILITIES, so it is never trial-seeded or written by the Stripe + * subscription sync. Without the grant the settings section is hidden and + * mail keeps leaving from the platform sender. + */ + custom_sender_domain: 'custom_sender_domain', } as const export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY] diff --git a/lib/invoices/__tests__/recurring-schedule-service.test.ts b/lib/invoices/__tests__/recurring-schedule-service.test.ts index d136750b..0bc6c554 100644 --- a/lib/invoices/__tests__/recurring-schedule-service.test.ts +++ b/lib/invoices/__tests__/recurring-schedule-service.test.ts @@ -46,6 +46,10 @@ vi.mock('@/lib/extensions/payment-links', () => ({ 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), diff --git a/lib/invoices/__tests__/reminder-processor.test.ts b/lib/invoices/__tests__/reminder-processor.test.ts index 5f7a7d3d..5e833ae8 100644 --- a/lib/invoices/__tests__/reminder-processor.test.ts +++ b/lib/invoices/__tests__/reminder-processor.test.ts @@ -28,6 +28,10 @@ vi.mock('@supabase/ssr', () => { } }) +vi.mock('@/lib/email/invoice-sender', () => ({ + resolveInvoiceSender: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('@/lib/email/service', () => ({ getEmailService: () => ({ sendEmail: vi.fn().mockResolvedValue({ success: true }), diff --git a/lib/invoices/invoice-deliveries.ts b/lib/invoices/invoice-deliveries.ts index 04ee0728..5a107682 100644 --- a/lib/invoices/invoice-deliveries.ts +++ b/lib/invoices/invoice-deliveries.ts @@ -25,6 +25,8 @@ export interface TrackedInvoiceEmailInput { bcc?: string | string[] replyTo?: string fromName?: string + /** Company's own verified sender (resolveInvoiceSender); platform sender when absent. */ + from?: SendEmailOptions['from'] subject: string html: string text: string @@ -84,6 +86,7 @@ export async function sendTrackedInvoiceEmail( bcc, replyTo, fromName, + from, subject, html, text, @@ -151,6 +154,7 @@ export async function sendTrackedInvoiceEmail( text, replyTo, fromName, + from, attachments: [ { filename, diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index 15fd103f..1f82555b 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -31,6 +31,7 @@ import { } 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 { hasCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' import { isSandboxCompany } from '@/lib/sandbox/guard' @@ -661,6 +662,7 @@ async function sendInvoiceFromSchedule( text, replyTo: company.email || undefined, fromName: company.company_name ?? undefined, + from: await resolveInvoiceSender(supabase, companyId, company.company_name), filename, pdfBuffer, }) diff --git a/lib/invoices/reminder-processor.ts b/lib/invoices/reminder-processor.ts index 83663daf..847e2cdd 100644 --- a/lib/invoices/reminder-processor.ts +++ b/lib/invoices/reminder-processor.ts @@ -1,5 +1,6 @@ import { createServerClient } from '@supabase/ssr' import { getEmailService } from '@/lib/email/service' +import { resolveInvoiceSender, type InvoiceSenderIdentity } from '@/lib/email/invoice-sender' import { generateReminderEmailHtml, generateReminderEmailText, @@ -110,6 +111,7 @@ export async function sendReminder( reminderLevel: 1 | 2 | 3, actionToken: string, surcharges: ReminderSurcharges, + sender?: InvoiceSenderIdentity, ): Promise<{ success: boolean; error?: string }> { const customer = invoice.customer @@ -139,7 +141,8 @@ export async function sendReminder( html: generateReminderEmailHtml(emailData), text: generateReminderEmailText(emailData), replyTo: company.email || undefined, - fromName: company.company_name || undefined + fromName: company.company_name || undefined, + from: sender, }) return result @@ -385,6 +388,7 @@ export async function processOverdueReminders(): Promise interestDays: interest.days, reminderFee, }, + await resolveInvoiceSender(supabase, invoice.company_id, company.company_name), ) if (sendResult.success) { diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index 154584e0..38954068 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -95,6 +95,10 @@ vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) } }) +vi.mock('@/lib/email/invoice-sender', () => ({ + resolveInvoiceSender: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('@/lib/email/service', () => ({ getEmailService: () => ({ isConfigured: () => true, diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index b02833b4..b13c1fe0 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -104,6 +104,7 @@ import { } from '@/lib/pending-operations/skatteverket-commit' import { PartialCommitError } from '@/lib/pending-operations/errors' import { getEmailService } from '@/lib/email/service' +import { resolveInvoiceSender } from '@/lib/email/invoice-sender' import { hasCapability, CAPABILITY_BLOCKED_MESSAGE_SV } from '@/lib/entitlements/has-capability' import { PAID_OPERATION_CAPABILITY_MAP } from '@/lib/entitlements/keys' import { @@ -2489,6 +2490,7 @@ async function commitSendInvoice( text, replyTo: company.email || undefined, fromName: company.company_name, + from: await resolveInvoiceSender(supabase, companyId, company.company_name), filename, pdfBuffer, }) diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 047bc8d5..f6980e8d 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -1041,6 +1041,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record = { company_capability_config: 'entitlement state', company_inbound_domains: 'inbound-mail infrastructure', company_inboxes: 'inbound-mail infrastructure', + company_sending_domains: 'outbound-mail infrastructure (sender domain verification state)', company_invitations: 'membership state, meaningless outside the platform', company_members: 'membership state, meaningless outside the platform', company_subscriptions: 'billing state', diff --git a/messages/en.json b/messages/en.json index 787432db..763bfc5a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2322,6 +2322,58 @@ "saving": "Saving...", "save": "Save recipients" }, + "settings_invoice_sender_domain": { + "heading": "Invoice email sender", + "description": "By default invoice emails are sent from our address with your company name as the sender. To send them from your own domain instead, for example faktura@yourcompany.se, add the domain here and publish the DNS records with your domain provider. The emails are then signed with your domain, and recipients' spam filters see you, not us.", + "domain_label": "Own domain", + "domain_hint": "Enter a domain you own, for example yourcompany.se. Only DKIM and SPF records are added: your regular email is not affected.", + "add_button": "Add", + "fallback_note": "Until the domain is verified, or while it is paused, invoices are sent exactly as before from our address.", + "status_pending": "Waiting for DNS", + "status_verified": "Verified", + "status_failed": "Failed", + "check_again": "Check again", + "remove_aria": "Remove domain", + "enabled_label": "Send from own domain", + "enabled_hint": "Pause to temporarily go back to our address without removing the domain.", + "enabled_on": "On: invoice emails are sent from your domain.", + "enabled_off": "Off: invoice emails are sent from our address.", + "address_label": "Sender address", + "address_hint": "The part before @. Lowercase letters, digits, dot, hyphen or underscore.", + "name_label": "Sender name", + "name_hint": "The name the recipient sees. Leave empty to use the company name.", + "preview_label": "Invoice emails are sent as", + "verified_description": "The domain is verified. Replies still go to the company email address.", + "verified_description_with_date": "The domain has been verified since {date}. Replies still go to the company email address.", + "dns_instructions": "Add the records below with your domain provider, for example Loopia, one.com or Cloudflare, then click Check again. Changes can take up to an hour to propagate.", + "dns_type": "Type", + "dns_name": "Name", + "dns_value": "Value", + "dns_status": "Status", + "dns_empty": "No DNS records are available. Click Check again.", + "copy_record_aria": "Copy the value for {type}", + "copied": "Copied", + "copy_failed_title": "Could not copy", + "copy_failed_description": "Select the value in the table and copy it manually.", + "load_error": "Could not load the sender setting.", + "retry": "Try again", + "claim_success_title": "Domain added", + "claim_success_description": "Add the DNS records below with your domain provider.", + "claim_error_title": "Could not add the domain", + "verify_success_title": "Domain verified", + "verify_success_description": "Invoice emails are now sent from your own domain.", + "verify_pending_title": "Not verified yet", + "verify_pending_description": "DNS changes can take up to an hour to propagate.", + "verify_error_title": "Check failed", + "saved_title": "Sender saved", + "save_error_title": "Could not save the sender", + "saving": "Saving...", + "save": "Save sender", + "remove_confirm": "Remove {domain}? Invoice emails will go from our address again.", + "remove_success_title": "Domain removed", + "remove_error_title": "Removal failed", + "try_again": "Try again." + }, "settings_invoice_payment_accounts": { "heading": "Payment accounts by currency", "description": "The invoice automatically shows the account matching its currency. A foreign-currency account must have an IBAN, or for USD/GBP a bank code, account number and BIC/SWIFT, before the invoice can be sent.", diff --git a/messages/sv.json b/messages/sv.json index 8a975c60..e792c3f2 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2322,6 +2322,58 @@ "saving": "Sparar...", "save": "Spara mottagare" }, + "settings_invoice_sender_domain": { + "heading": "Avsändare vid fakturautskick", + "description": "Som standard skickas fakturamejl från vår adress med ditt företagsnamn som avsändare. Vill du att de i stället går från din egen domän, till exempel faktura@dittbolag.se, lägger du till domänen här och publicerar DNS-posterna hos din domänleverantör. Då signeras mejlen med din domän och mottagarnas skräppostfilter ser dig, inte oss.", + "domain_label": "Egen domän", + "domain_hint": "Ange domänen du äger, till exempel dittbolag.se. Bara DKIM- och SPF-poster läggs till: din vanliga e-post påverkas inte.", + "add_button": "Lägg till", + "fallback_note": "Tills domänen är verifierad, eller om den pausas, skickas fakturor precis som tidigare från vår adress.", + "status_pending": "Väntar på DNS", + "status_verified": "Verifierad", + "status_failed": "Misslyckades", + "check_again": "Kontrollera igen", + "remove_aria": "Ta bort domän", + "enabled_label": "Skicka från egen domän", + "enabled_hint": "Pausa för att tillfälligt gå tillbaka till vår adress utan att ta bort domänen.", + "enabled_on": "På: fakturamejl skickas från din domän.", + "enabled_off": "Av: fakturamejl skickas från vår adress.", + "address_label": "Avsändaradress", + "address_hint": "Delen före @. Små bokstäver, siffror, punkt, bindestreck eller understreck.", + "name_label": "Avsändarnamn", + "name_hint": "Namnet mottagaren ser. Lämna tomt för att använda företagsnamnet.", + "preview_label": "Fakturamejl skickas som", + "verified_description": "Domänen är verifierad. Svar går fortfarande till företagets e-postadress.", + "verified_description_with_date": "Domänen är verifierad sedan {date}. Svar går fortfarande till företagets e-postadress.", + "dns_instructions": "Lägg till posterna nedan hos din domänleverantör, till exempel Loopia, one.com eller Cloudflare, och klicka sedan på Kontrollera igen. Ändringar kan ta upp till någon timme att slå igenom.", + "dns_type": "Typ", + "dns_name": "Namn", + "dns_value": "Värde", + "dns_status": "Status", + "dns_empty": "Inga DNS-poster är tillgängliga. Klicka på Kontrollera igen.", + "copy_record_aria": "Kopiera värdet för {type}", + "copied": "Kopierat", + "copy_failed_title": "Kunde inte kopiera", + "copy_failed_description": "Markera värdet i tabellen och kopiera det manuellt.", + "load_error": "Kunde inte läsa in avsändarinställningen.", + "retry": "Försök igen", + "claim_success_title": "Domän tillagd", + "claim_success_description": "Lägg till DNS-posterna nedan hos din domänleverantör.", + "claim_error_title": "Kunde inte lägga till domänen", + "verify_success_title": "Domänen är verifierad", + "verify_success_description": "Fakturamejl skickas nu från din egen domän.", + "verify_pending_title": "Inte verifierad än", + "verify_pending_description": "DNS-ändringar kan ta upp till någon timme att slå igenom.", + "verify_error_title": "Kontrollen misslyckades", + "saved_title": "Avsändare sparad", + "save_error_title": "Kunde inte spara avsändaren", + "saving": "Sparar...", + "save": "Spara avsändare", + "remove_confirm": "Ta bort {domain}? Fakturamejl går då från vår adress igen.", + "remove_success_title": "Domänen borttagen", + "remove_error_title": "Borttagningen misslyckades", + "try_again": "Försök igen." + }, "settings_invoice_payment_accounts": { "heading": "Betalningskonton per valuta", "description": "Fakturan visar automatiskt kontot som matchar fakturans valuta. Ett utländskt konto måste ha IBAN, eller för USD/GBP bankkod, kontonummer och BIC/SWIFT, för att fakturan ska kunna skickas.", diff --git a/supabase/migrations/20260822120000_company_sending_domains.sql b/supabase/migrations/20260822120000_company_sending_domains.sql new file mode 100644 index 00000000..d9d92c81 --- /dev/null +++ b/supabase/migrations/20260822120000_company_sending_domains.sql @@ -0,0 +1,125 @@ +-- Custom outbound sending domains for invoice email (opt-in per company). +-- +-- Today every invoice email leaves from the platform's shared sender +-- (" via "). This table lets a company verify +-- its own domain via Resend's domain API (sending capability only) and, once +-- status = 'verified' AND enabled, send invoice mail as +-- " " so DKIM/DMARC align with the +-- company's own domain at the recipient's filter. +-- +-- Design notes: +-- * Mirrors company_inbound_domains (20260701090000): same lifecycle +-- (claim -> DNS -> verified), same RLS shape, same audit trigger. Kept as +-- a separate table because the two are different Resend domain profiles +-- (sending-only vs receiving-only) with different failure consequences. +-- * No user_id column: the row is company configuration that must outlive +-- the user who created it. +-- * Global unique on lower(domain): one company owns a sending domain +-- across all tenants. One sending domain per company (v1). +-- * Only rows with status = 'verified' AND enabled = true ever change the +-- From header. Everything else falls back to the platform sender, so a +-- DNS blip can never stop invoice mail. +-- * The feature itself is gated behind a per-company capability grant +-- (CAPABILITY.custom_sender_domain) resolved application-side; the table +-- carries no opinion about who may use it. + +-- ============================================================================= +-- 1. Table +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.company_sending_domains ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + -- Lowercased, punycoded hostname (validated app-side before insert). + domain text NOT NULL, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'verified', 'failed')), + -- Local part of the From address: @. + sender_local_part text NOT NULL DEFAULT 'faktura' + CHECK (sender_local_part ~ '^[a-z0-9][a-z0-9._-]{0,63}$'), + -- Optional display name; NULL means "use the company name". + sender_name text + CHECK (sender_name IS NULL OR (length(sender_name) BETWEEN 1 AND 120)), + -- Pause without removing the domain (DNS stays verified in Resend). + enabled boolean NOT NULL DEFAULT true, + -- Resend's domain id + the DNS records the user must publish (records[] + -- from the Resend API response, rendered verbatim in the UI). + resend_domain_id text, + dns_records jsonb, + verified_at timestamptz, + last_checked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- A domain belongs to exactly one company, across all tenants. +CREATE UNIQUE INDEX IF NOT EXISTS idx_company_sending_domains_domain + ON public.company_sending_domains (lower(domain)); + +-- One sending domain per company (v1). +CREATE UNIQUE INDEX IF NOT EXISTS idx_company_sending_domains_company + ON public.company_sending_domains (company_id); + +-- Webhook lookups resolve rows by Resend's domain id. +CREATE INDEX IF NOT EXISTS idx_company_sending_domains_resend_id + ON public.company_sending_domains (resend_domain_id) + WHERE resend_domain_id IS NOT NULL; + +-- ============================================================================= +-- 2. RLS: SELECT for members, writes for owner/admin only +-- (same shape as company_inbound_domains) +-- ============================================================================= + +ALTER TABLE public.company_sending_domains ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "company_sending_domains_select" ON public.company_sending_domains; +CREATE POLICY "company_sending_domains_select" ON public.company_sending_domains + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +DROP POLICY IF EXISTS "company_sending_domains_insert" ON public.company_sending_domains; +CREATE POLICY "company_sending_domains_insert" ON public.company_sending_domains + FOR INSERT WITH CHECK ( + company_id IN ( + SELECT cm.company_id FROM public.company_members cm + WHERE cm.user_id = auth.uid() + AND cm.role IN ('owner', 'admin') + ) + ); + +DROP POLICY IF EXISTS "company_sending_domains_update" ON public.company_sending_domains; +CREATE POLICY "company_sending_domains_update" ON public.company_sending_domains + FOR UPDATE USING ( + company_id IN ( + SELECT cm.company_id FROM public.company_members cm + WHERE cm.user_id = auth.uid() + AND cm.role IN ('owner', 'admin') + ) + ); + +DROP POLICY IF EXISTS "company_sending_domains_delete" ON public.company_sending_domains; +CREATE POLICY "company_sending_domains_delete" ON public.company_sending_domains + FOR DELETE USING ( + company_id IN ( + SELECT cm.company_id FROM public.company_members cm + WHERE cm.user_id = auth.uid() + AND cm.role IN ('owner', 'admin') + ) + ); + +-- ============================================================================= +-- 3. Triggers +-- ============================================================================= + +DROP TRIGGER IF EXISTS company_sending_domains_updated_at ON public.company_sending_domains; +CREATE TRIGGER company_sending_domains_updated_at + BEFORE UPDATE ON public.company_sending_domains + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- Sending-domain changes alter who a company's invoice mail claims to come +-- from: audit them. +DROP TRIGGER IF EXISTS audit_company_sending_domains ON public.company_sending_domains; +CREATE TRIGGER audit_company_sending_domains + AFTER INSERT OR UPDATE OR DELETE ON public.company_sending_domains + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260822130000_company_sending_domains_tenant_guard.sql b/supabase/migrations/20260822130000_company_sending_domains_tenant_guard.sql new file mode 100644 index 00000000..dacf411f --- /dev/null +++ b/supabase/migrations/20260822130000_company_sending_domains_tenant_guard.sql @@ -0,0 +1,106 @@ +-- Tenant writes to company_sending_domains may only open a pending claim and +-- edit the sender presentation (sender_local_part, sender_name, enabled). +-- Everything that proves domain ownership (domain, status, resend_domain_id, +-- dns_records, verified_at, last_checked_at) is written by the server with the +-- service role after talking to Resend. +-- +-- Without this, an owner/admin holding the opt-in grant could insert +-- {domain: , status: 'verified'} straight +-- through PostgREST (RLS only checks membership), and resolveInvoiceSender() +-- would then send that company's invoice mail as the platform itself with an +-- arbitrary local part and display name. The app-side validation in the +-- claim route is not a security boundary; this trigger is. +-- +-- Trust model (same idiom as 20260807130000 / 20260813162752): a request is +-- trusted when it carries the service_role JWT claim, or when it carries no +-- PostgREST claims at all (migrations, pg-real superuser seeds, direct DB +-- sessions). Anything else is a tenant. + +ALTER TABLE public.company_sending_domains + DROP CONSTRAINT IF EXISTS company_sending_domains_domain_shape; +ALTER TABLE public.company_sending_domains + ADD CONSTRAINT company_sending_domains_domain_shape CHECK ( + length(domain) BETWEEN 4 AND 253 + AND domain = lower(domain) + AND domain ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$' + ); + +-- Local part must be a dot-atom: atoms of [a-z0-9_-] separated by single +-- dots, no leading/trailing/consecutive dots (mirrors SENDER_LOCAL_PART_PATTERN +-- in lib/email/domain-name.ts). Replaces the looser inline CHECK from +-- 20260822120000 under the same auto-generated constraint name. +ALTER TABLE public.company_sending_domains + DROP CONSTRAINT IF EXISTS company_sending_domains_sender_local_part_check; +ALTER TABLE public.company_sending_domains + ADD CONSTRAINT company_sending_domains_sender_local_part_check CHECK ( + length(sender_local_part) BETWEEN 1 AND 64 + AND sender_local_part ~ '^[a-z0-9_-]+(\.[a-z0-9_-]+)*$' + ); + +-- The webhook resolves rows by Resend domain id with maybeSingle(): state the +-- one-row assumption in the schema. +DROP INDEX IF EXISTS public.idx_company_sending_domains_resend_id; +CREATE UNIQUE INDEX IF NOT EXISTS idx_company_sending_domains_resend_id + ON public.company_sending_domains (resend_domain_id) + WHERE resend_domain_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION public.guard_company_sending_domain_tenant_write() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_claims jsonb; + v_role text; +BEGIN + v_claims := nullif(current_setting('request.jwt.claims', true), '')::jsonb; + v_role := coalesce( + nullif(current_setting('request.jwt.claim.role', true), ''), + v_claims->>'role' + ); + + -- Trusted: service role, or no PostgREST context at all. + IF coalesce(v_role, '') = 'service_role' + OR (v_claims IS NULL AND v_role IS NULL) THEN + RETURN NEW; + END IF; + + IF TG_OP = 'INSERT' THEN + IF NEW.status <> 'pending' + OR NEW.resend_domain_id IS NOT NULL + OR NEW.dns_records IS NOT NULL + OR NEW.verified_at IS NOT NULL + OR NEW.last_checked_at IS NOT NULL THEN + RAISE EXCEPTION 'company_sending_domains: a tenant claim starts as pending; verification state is written by the server' + USING ERRCODE = '42501'; + END IF; + RETURN NEW; + END IF; + + -- UPDATE + IF NEW.company_id IS DISTINCT FROM OLD.company_id + OR NEW.domain IS DISTINCT FROM OLD.domain + OR NEW.status IS DISTINCT FROM OLD.status + OR NEW.resend_domain_id IS DISTINCT FROM OLD.resend_domain_id + OR NEW.dns_records IS DISTINCT FROM OLD.dns_records + OR NEW.verified_at IS DISTINCT FROM OLD.verified_at + OR NEW.last_checked_at IS DISTINCT FROM OLD.last_checked_at THEN + RAISE EXCEPTION 'company_sending_domains: domain and verification state are server-managed; tenants may only change sender_local_part, sender_name and enabled' + USING ERRCODE = '42501'; + END IF; + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION public.guard_company_sending_domain_tenant_write() FROM PUBLIC; + +DROP TRIGGER IF EXISTS guard_company_sending_domain_tenant_write ON public.company_sending_domains; +CREATE TRIGGER guard_company_sending_domain_tenant_write + BEFORE INSERT OR UPDATE ON public.company_sending_domains + FOR EACH ROW EXECUTE FUNCTION public.guard_company_sending_domain_tenant_write(); + +COMMENT ON FUNCTION public.guard_company_sending_domain_tenant_write() IS + 'Tenant JWTs may only open a pending sending-domain claim and edit sender presentation; verification state is service-role only.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 39834936..e74cc5e5 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2964,6 +2964,31 @@ export interface CompanyInboundDomain { updated_at: string } +export type CompanySendingDomainStatus = 'pending' | 'verified' | 'failed' + +// A DNS record the user must publish to verify their custom sending domain +// (verbatim from the Resend domains API; same shape as the inbound records). +export type SendingDomainDnsRecord = InboundDomainDnsRecord + +// Opt-in per-company sender identity for invoice email. Only a row with +// status = 'verified' AND enabled = true changes the From header; everything +// else falls back to the platform sender. +export interface CompanySendingDomain { + id: string + company_id: string + domain: string + status: CompanySendingDomainStatus + sender_local_part: string + sender_name: string | null + enabled: boolean + resend_domain_id: string | null + dns_records: SendingDomainDnsRecord[] | null + verified_at: string | null + last_checked_at: string | null + created_at: string + updated_at: string +} + export interface InvoiceInboxItem { id: string user_id: string