feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)

* feat(invoicing): opt-in invoice email from the company's own sending domain

Companies holding the custom_sender_domain capability grant can register
their own domain (Resend sending-only profile), publish DKIM/SPF, and once
verified every invoice email (send, reminders, recurring, payment
confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>"
instead of the platform sender. Reply-To is unchanged.

- New table company_sending_domains (RLS: members read, owner/admin write;
  audit trigger), types, archive-export classification.
- New capability key custom_sender_domain: manually granted per company,
  deliberately outside PAID_CAPABILITIES (never trial-seeded, never written
  by the Stripe sync). Without the grant the settings section is hidden and
  nothing changes.
- Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify),
  Resend domain lifecycle without orphan adoption, domain.updated handling
  on the delivery webhook, explicit From support in the Resend adapter.
- Core resolveInvoiceSender(): verified + enabled + entitled, else the
  platform sender; never throws.
- Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en).
- Unit tests for the resolver, domain helpers, routes, From header; pg-real
  test for RLS and constraints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoicing): harden sending-domain writes, sender fallback, review findings

Skeptic refutations:
- Tenant JWTs could insert/update company_sending_domains with status =
  'verified' and an arbitrary domain through PostgREST (RLS only checked
  membership), then send invoice mail as that domain. New migration
  20260822130000 adds a BEFORE trigger: tenants may only open a pending
  claim and edit sender_local_part/sender_name/enabled; domain and
  verification state are service-role only. claim/verify helpers now take
  a service-role writer for those columns; the route's RLS client still
  does the insert.
- A company domain Resend later rejects made every invoice send fail: the
  Resend adapter retries once as the platform sender when an explicit
  company From is rejected (nothing was sent, so no double send).

Review findings:
- domain.updated webhook: discriminated outcome; DB errors answer 500 so
  Svix retries, unknown domains are acknowledged.
- Display names are RFC 5322-quoted only when they carry specials.
- Sender local part is a strict dot-atom (no trailing/consecutive dots),
  in code and in the CHECK constraint; resend_domain_id index is UNIQUE.
- IME composition guard on the claim input; event bus reset in tests;
  settings section skips its request for non-admins.

Deferred (needs a product call): persisting the effective From address in
the invoice delivery log touches the hardened evidence triggers; recorded
in DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test

Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a
tenant could delete and re-insert its pending row under the same id with a
reserved domain, and the service-role writer updated by id alone. Now:
- the claim's verification-state write filters on (id, company_id, domain,
  resend_domain_id IS NULL) and rolls back on zero rows;
- verify and the domain.updated webhook compare Resend's domain name with
  the row before writing verified;
- resolveInvoiceSender refuses reserved platform domains and non-hostnames
  at send time (reserved-domain logic moved to lib/email/domain-name.ts and
  shared with the claim validator).

pg-real: the case-insensitive uniqueness assertion now expects the
domain_shape CHECK (lowercase enforced) for an uppercase variant and the
unique index for a same-case duplicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-23 00:07:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 561e64afc7
commit 0040cadacc
35 changed files with 3104 additions and 14 deletions
+3
View File
@@ -1167,3 +1167,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -46,6 +46,10 @@ import { InvoicePDF } from '@/lib/invoices/pdf-template'
const mockSendEmail = vi.fn()
const mockIsConfigured = vi.fn()
vi.mock('@/lib/email/invoice-sender', () => ({
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
@@ -9,6 +9,7 @@ import {
buildPaymentLinkQrDataUrl,
} from '@/lib/invoices/pdf-render-helpers'
import { getEmailService } from '@/lib/email/service'
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
import {
generatePaymentConfirmationEmailHtml,
generatePaymentConfirmationEmailSubject,
@@ -181,6 +182,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
text: generatePaymentConfirmationEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name,
from: await resolveInvoiceSender(supabase, companyId, company.company_name),
attachments: [
{
filename,
@@ -47,6 +47,12 @@ import { InvoicePDF } from '@/lib/invoices/pdf-template'
const mockSendEmail = vi.fn()
const mockIsConfigured = vi.fn()
// The sender resolver reads company_sending_domains; keep it out of the
// queued-mock sequence (its own tests live in lib/email/__tests__).
vi.mock('@/lib/email/invoice-sender', () => ({
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
+2
View File
@@ -5,6 +5,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { getEmailService } from '@/lib/email/service'
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
import {
generateInvoiceEmailHtml,
generateInvoiceEmailText,
@@ -481,6 +482,7 @@ export const POST = withRouteContext(
text,
replyTo: company.email || undefined,
fromName: company.company_name,
from: await resolveInvoiceSender(supabase, companyId!, company.company_name),
filename,
pdfBuffer,
})
@@ -53,6 +53,10 @@ vi.mock('@react-pdf/renderer', () => ({
// Email service mock: configurable per test
const mockSendEmail = vi.fn()
const mockIsConfigured = vi.fn().mockReturnValue(true)
vi.mock('@/lib/email/invoice-sender', () => ({
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/email/service', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/email/service')>()
return {
@@ -50,6 +50,7 @@ import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import { getEmailService } from '@/lib/email/service'
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
import {
generateInvoiceEmailHtml,
generateInvoiceEmailSubject,
@@ -622,6 +623,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
text,
replyTo: settings.email ?? undefined,
fromName: settings.company_name ?? undefined,
from: await resolveInvoiceSender(ctx.supabase, ctx.companyId!, settings.company_name),
filename,
pdfBuffer,
})
@@ -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<CompanySendingDomain['status'], 'secondary' | 'success' | 'destructive'> = {
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<CompanySendingDomain | null>(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<CompanySendingDomain['status'], string> = {
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 (
<SettingsGroup label={t('heading')} help={t('description')}>
{isLoading ? (
<div className="space-y-3 px-1 py-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : loadFailed ? (
<div role="status" className="flex items-center justify-between gap-4 px-1 py-3 text-sm">
<p className="text-muted-foreground">{t('load_error')}</p>
<Button variant="outline" size="sm" onClick={() => void fetchDomain()}>
{t('retry')}
</Button>
</div>
) : !domain ? (
<>
<SettingsRow label={t('domain_label')} htmlFor="invoice-sender-domain" help={t('domain_hint')}>
<Input
id="invoice-sender-domain"
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="dittbolag.se"
className="max-w-xs"
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return
if (e.key === 'Enter') void handleClaim()
}}
/>
<Button size="sm" onClick={() => void handleClaim()} disabled={isClaiming || !domainInput.trim()}>
{isClaiming ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('add_button')}
</Button>
</SettingsRow>
<SettingsRowNote className="block px-1 pt-2">{t('fallback_note')}</SettingsRowNote>
</>
) : (
<>
<SettingsRow label={t('domain_label')}>
<code className="truncate font-mono text-sm">{domain.domain}</code>
<Badge variant={STATUS_VARIANT[domain.status]}>{statusLabels[domain.status]}</Badge>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleVerify()} disabled={isChecking}>
{isChecking ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
{t('check_again')}
</Button>
<Button
variant="outline"
size="icon"
onClick={() => void handleRemove()}
disabled={isRemoving}
aria-label={t('remove_aria')}
>
{isRemoving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
</div>
</SettingsRow>
{domain.status === 'verified' ? (
<>
<SettingsRow label={t('enabled_label')} help={t('enabled_hint')}>
<Switch
checked={domain.enabled}
disabled={isSaving}
onCheckedChange={(checked) => void patch({ enabled: checked })}
aria-label={t('enabled_label')}
/>
<SettingsRowNote>{domain.enabled ? t('enabled_on') : t('enabled_off')}</SettingsRowNote>
</SettingsRow>
<SettingsRow label={t('address_label')} htmlFor="invoice-sender-local-part" help={t('address_hint')}>
<Input
id="invoice-sender-local-part"
value={localPart}
onChange={(e) => setLocalPart(e.target.value)}
className="max-w-[10rem] font-mono"
/>
<span className="font-mono text-sm text-muted-foreground">@{domain.domain}</span>
</SettingsRow>
<SettingsRow label={t('name_label')} htmlFor="invoice-sender-name" help={t('name_hint')}>
<Input
id="invoice-sender-name"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
placeholder={companyName ?? ''}
className="max-w-xs"
/>
</SettingsRow>
<div className="flex items-start gap-3 rounded-lg border border-border p-4 text-sm">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="space-y-1">
<p className="font-medium">
{t('preview_label')}{' '}
<code className="font-mono text-xs">
{effectiveName ? `${effectiveName} <${previewAddress}>` : previewAddress}
</code>
</p>
<p className="text-muted-foreground">
{domain.verified_at
? t('verified_description_with_date', { date: formatDateLong(domain.verified_at) })
: t('verified_description')}
</p>
</div>
</div>
<div className="flex justify-end px-1 pt-4">
<Button type="button" size="sm" onClick={handleSaveSender} disabled={isSaving}>
{isSaving ? t('saving') : t('save')}
</Button>
</div>
</>
) : (
<div className="space-y-3 px-1 py-3">
<p className="text-sm text-muted-foreground">{t('dns_instructions')}</p>
{records.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">{t('dns_type')}</th>
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">{t('dns_name')}</th>
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">{t('dns_value')}</th>
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">{t('dns_status')}</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr key={`${r.type}-${r.name}-${i}`} className="border-b border-border last:border-0">
<td className="px-3 py-2 font-mono text-xs">{r.type}</td>
<td className="px-3 py-2 font-mono text-xs break-all">{r.name}</td>
<td className="px-3 py-2 font-mono text-xs break-all">{r.value}</td>
<td className="px-3 py-2 font-mono text-xs">{r.status}</td>
<td className="px-3 py-2 text-right">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => void handleCopy(r.value)}
aria-label={t('copy_record_aria', { type: r.type })}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-muted-foreground">{t('dns_empty')}</p>
)}
<SettingsRowNote className="block">{t('fallback_note')}</SettingsRowNote>
</div>
)}
</>
)}
</SettingsGroup>
)
}
@@ -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) */}
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
<InvoiceSenderDomainSettings companyName={settings.company_name ?? null} />
{/* Invoice email texts: autosaves on blur */}
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
</div>
@@ -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/)
})
})
@@ -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 <noreply@platform.example>',
)
})
it('renders the bare app sender without a company name', () => {
expect(buildFromHeader({})).toBe('Accounted <noreply@platform.example>')
})
it('renders an explicit sender as "<name> <address>" with no "via"', () => {
expect(
buildFromHeader({ fromName: 'ignored', from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' } }),
).toBe('Hans Bolag AB <faktura@hansbolag.example>')
})
it('strips header-injection characters from the explicit name', () => {
expect(
buildFromHeader({ from: { name: 'Hans <Bolag>\r\nBcc: x', address: 'faktura@hansbolag.example' } }),
).toBe('"Hans BolagBcc: x" <faktura@hansbolag.example>') // CR/LF/<> stripped; ':' forces quoting
})
it('quotes a display name only when it carries RFC 5322 specials, escaping quotes and backslashes', () => {
expect(encodeDisplayName('Hans Bolag AB')).toBe('Hans Bolag AB')
expect(encodeDisplayName('Hans "Bolag", AB')).toBe('"Hans \\"Bolag\\", AB"')
expect(encodeDisplayName('Back\\slash')).toBe('"Back\\\\slash"')
expect(buildFromHeader({ from: { name: 'Hans Bolag, AB', address: 'faktura@hansbolag.example' } })).toBe(
'"Hans Bolag, AB" <faktura@hansbolag.example>',
)
// Platform path: a comma in the company name used to yield an ambiguous
// mailbox list; plain names are byte-identical to before.
expect(buildFromHeader({ fromName: 'Hans Bolag, AB' })).toBe(
'"Hans Bolag, AB via Accounted" <noreply@platform.example>',
)
})
it('falls back to the platform sender when the explicit address is malformed', () => {
expect(buildFromHeader({ fromName: 'Hans Bolag AB', from: { name: 'Hans', address: 'not an address' } })).toBe(
'Hans Bolag AB via Accounted <noreply@platform.example>',
)
expect(buildFromHeader({ fromName: 'Hans Bolag AB', from: { name: ' ', address: 'faktura@hansbolag.example' } })).toBe(
'Hans Bolag AB via Accounted <noreply@platform.example>',
)
})
})
describe('ResendEmailService.sendEmail', () => {
const service = new ResendEmailService()
const base = { to: 'kund@example.com', subject: 'Faktura 1', html: '<p>x</p>', fromName: 'Hans Bolag AB' }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
sendMock.mockReset()
})
it('sends once as the platform sender when no explicit From is given', async () => {
sendMock.mockResolvedValue({ data: { id: 'msg_1' }, error: null })
const result = await service.sendEmail(base)
expect(result).toEqual({ success: true, provider: 'resend', messageId: 'msg_1' })
expect(sendMock).toHaveBeenCalledTimes(1)
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB via Accounted <noreply@platform.example>')
})
it('sends as the company sender when Resend accepts it', async () => {
sendMock.mockResolvedValue({ data: { id: 'msg_2' }, error: null })
const result = await service.sendEmail({
...base,
from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' },
})
expect(result.success).toBe(true)
expect(sendMock).toHaveBeenCalledTimes(1)
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB <faktura@hansbolag.example>')
})
it('retries once as the platform sender when Resend rejects the company sender', async () => {
sendMock
.mockResolvedValueOnce({ data: null, error: { message: 'The hansbolag.example domain is not verified' } })
.mockResolvedValueOnce({ data: { id: 'msg_3' }, error: null })
const result = await service.sendEmail({
...base,
from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' },
})
expect(result).toEqual({ success: true, provider: 'resend', messageId: 'msg_3' })
expect(sendMock).toHaveBeenCalledTimes(2)
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB <faktura@hansbolag.example>')
expect(sendMock.mock.calls[1][0].from).toBe('Hans Bolag AB via Accounted <noreply@platform.example>')
// Same recipients and content on the retry.
expect(sendMock.mock.calls[1][0].to).toEqual(['kund@example.com'])
expect(sendMock.mock.calls[1][0].subject).toBe('Faktura 1')
})
it('does not retry a platform-sender failure (nothing to fall back to)', async () => {
sendMock.mockResolvedValue({ data: null, error: { message: 'invalid recipient' } })
const result = await service.sendEmail(base)
expect(result).toEqual({ success: false, provider: 'resend', error: 'invalid recipient' })
expect(sendMock).toHaveBeenCalledTimes(1)
})
it('reports the platform-sender error when the fallback also fails', async () => {
sendMock
.mockResolvedValueOnce({ data: null, error: { message: 'domain not verified' } })
.mockResolvedValueOnce({ data: null, error: { message: 'rate limited' } })
const result = await service.sendEmail({
...base,
from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' },
})
expect(result).toEqual({ success: false, provider: 'resend', error: 'rate limited' })
expect(sendMock).toHaveBeenCalledTimes(2)
})
})
@@ -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<typeof import('@/lib/entitlements/has-capability')>(
'@/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<typeof import('@/extensions/general/email/lib/delivery-webhook')>(
'@/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> = {}): 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)
})
})
@@ -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<typeof createQueuedMockSupabase> & { client: SupabaseClient } {
const m = createQueuedMockSupabase()
return Object.assign(m, { client: m.supabase as unknown as SupabaseClient })
}
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_API_KEY = 'test-key'
process.env.RESEND_FROM_EMAIL = 'noreply@platform.example'
process.env.RESEND_INBOUND_DOMAIN = 'inbox.platform.example'
process.env.NEXT_PUBLIC_APP_URL = 'https://app.platform.example'
})
afterEach(() => {
delete process.env.RESEND_INBOUND_DOMAIN
})
describe('validateClaimableSendingDomain', () => {
it('blocks public mailbox providers', () => {
expect(validateClaimableSendingDomain('gmail.com')).toMatch(/Publika/)
})
it("blocks the platform's own sender domain, the inbound domain and the app host (and subdomains)", () => {
expect(validateClaimableSendingDomain('platform.example')).toMatch(/reserverad/)
expect(validateClaimableSendingDomain('mail.platform.example')).toMatch(/reserverad/)
expect(validateClaimableSendingDomain('inbox.platform.example')).toMatch(/reserverad/)
expect(validateClaimableSendingDomain('app.platform.example')).toMatch(/reserverad/)
})
it('allows an ordinary company domain', () => {
expect(validateClaimableSendingDomain('hansbolag.example')).toBeNull()
})
})
describe('mapResendSendingStatus', () => {
it('maps verified and temporary_failure to verified, failures to failed, the rest to pending', () => {
expect(mapResendSendingStatus('verified')).toBe('verified')
expect(mapResendSendingStatus('temporary_failure')).toBe('verified')
expect(mapResendSendingStatus('failed')).toBe('failed')
expect(mapResendSendingStatus('partially_failed')).toBe('failed')
expect(mapResendSendingStatus('pending')).toBe('pending')
expect(mapResendSendingStatus('not_started')).toBe('pending')
expect(mapResendSendingStatus('partially_verified')).toBe('pending')
})
})
describe('isSendingOnlyProfile', () => {
it('accepts sending-only and rejects receiving or unknown', () => {
expect(isSendingOnlyProfile(SENDING_ONLY)).toBe(true)
expect(isSendingOnlyProfile({ sending: 'enabled', receiving: 'enabled' })).toBe(false)
expect(isSendingOnlyProfile({ sending: 'disabled', receiving: 'enabled' })).toBe(false)
expect(isSendingOnlyProfile(null)).toBe(false)
})
})
describe('claimSendingDomain', () => {
it('rejects an invalid domain without touching the DB or Resend', async () => {
const { client, calls } = sb()
const result = await claimSendingDomain(client, client, 'company-1', 'nodots')
expect(result).toEqual({ ok: false, status: 400, error: expect.stringMatching(/Ogiltig/) })
expect(calls).toHaveLength(0)
expect(domainsMock.create).not.toHaveBeenCalled()
})
it('rejects a reserved domain', async () => {
const { client } = sb()
const result = await claimSendingDomain(client, client, 'company-1', 'platform.example')
expect(result.ok).toBe(false)
expect(domainsMock.create).not.toHaveBeenCalled()
})
it('inserts the row, registers a sending-only domain in Resend, and stores the DNS records', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { ...ROW, resend_domain_id: null, dns_records: null } }) // insert
enqueue({ data: ROW }) // update
domainsMock.create.mockResolvedValue({ data: { id: 'rd_1' }, error: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', status: 'not_started', records: [DKIM_RECORD], capabilities: SENDING_ONLY },
error: null,
})
const result = await claimSendingDomain(client, client, 'company-1', 'HansBolag.example')
expect(result.ok).toBe(true)
expect(domainsMock.create).toHaveBeenCalledWith({
name: 'hansbolag.example',
region: 'eu-west-1',
capabilities: { sending: 'enabled', receiving: 'disabled' },
})
const insertArgs = findCall('company_sending_domains', 'insert')
expect(insertArgs?.[0]).toEqual({ company_id: 'company-1', domain: 'hansbolag.example', status: 'pending' })
const updateArgs = findCall('company_sending_domains', 'update')
expect(updateArgs?.[0]).toMatchObject({ resend_domain_id: 'rd_1', dns_records: [DKIM_RECORD], status: 'pending' })
})
it('writes the verification state through the service-role writer, not the tenant client', async () => {
const tenant = sb()
const writer = sb()
tenant.enqueue({ data: { ...ROW, resend_domain_id: null, dns_records: null } }) // insert (RLS client)
writer.enqueue({ data: ROW }) // update (service role)
domainsMock.create.mockResolvedValue({ data: { id: 'rd_1' }, error: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', status: 'not_started', records: [DKIM_RECORD], capabilities: SENDING_ONLY },
error: null,
})
const result = await claimSendingDomain(tenant.client, writer.client, 'company-1', 'hansbolag.example')
expect(result.ok).toBe(true)
expect(tenant.findCall('company_sending_domains', 'update')).toBeUndefined()
expect(writer.findCall('company_sending_domains', 'update')?.[0]).toMatchObject({ resend_domain_id: 'rd_1' })
// Still scoped to the company on the service-role path, and bound only to
// the row that still carries the registered domain and no Resend id (a
// concurrent tenant delete + re-insert under the same id matches nothing).
expect(writer.findCalls('company_sending_domains', 'eq')).toEqual(
expect.arrayContaining([
['id', 'row-1'],
['company_id', 'company-1'],
['domain', 'hansbolag.example'],
]),
)
expect(writer.findCall('company_sending_domains', 'is')).toEqual(['resend_domain_id', null])
})
it('maps a unique-violation on company_id to a 409 about the existing domain', async () => {
const { client, enqueue } = sb()
enqueue({ data: null, error: { code: '23505', message: 'duplicate key idx_company_sending_domains_company' } })
const result = await claimSendingDomain(client, client, 'company-1', 'hansbolag.example')
expect(result).toEqual({ ok: false, status: 409, error: expect.stringMatching(/redan en avsändardomän/) })
})
it('never adopts an existing Resend domain: "already exists" rolls back and returns 409', async () => {
const { client, enqueue, findCalls } = sb()
enqueue({ data: { ...ROW, resend_domain_id: null } }) // insert
enqueue({ data: null }) // rollback delete
domainsMock.create.mockResolvedValue({ data: null, error: { message: 'Domain already exists' } })
const result = await claimSendingDomain(client, client, 'company-1', 'hansbolag.example')
expect(result).toEqual({ ok: false, status: 409, error: expect.stringMatching(/finns redan/) })
expect(domainsMock.list).not.toHaveBeenCalled()
expect(findCalls('company_sending_domains', 'delete')).toHaveLength(1)
})
it('removes the Resend domain it just created when persisting the DNS records fails', async () => {
const { client, enqueue } = sb()
enqueue({ data: { ...ROW, resend_domain_id: null } }) // insert
enqueue({ data: null, error: { message: 'db down' } }) // update fails
enqueue({ data: null }) // rollback delete
domainsMock.create.mockResolvedValue({ data: { id: 'rd_1' }, error: null })
domainsMock.get.mockResolvedValue({ data: { id: 'rd_1', status: 'pending', records: [], capabilities: SENDING_ONLY }, error: null })
domainsMock.remove.mockResolvedValue({ data: null, error: null })
const result = await claimSendingDomain(client, client, 'company-1', 'hansbolag.example')
expect(result.ok).toBe(false)
expect(domainsMock.remove).toHaveBeenCalledWith('rd_1')
})
})
describe('checkSendingDomainVerification', () => {
it('404s without a row', async () => {
const { client, enqueue } = sb()
enqueue({ data: null })
const result = await checkSendingDomainVerification(client, client, 'company-1')
expect(result).toMatchObject({ ok: false, status: 404 })
})
it('verifies, then persists verified + verified_at', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: ROW })
enqueue({ data: { ...ROW, status: 'verified' } })
domainsMock.verify.mockResolvedValue({ data: {}, error: null })
domainsMock.get.mockResolvedValue({
data: {
id: 'rd_1',
name: 'hansbolag.example',
status: 'verified',
records: [{ ...DKIM_RECORD, status: 'verified' }],
capabilities: SENDING_ONLY,
},
error: null,
})
const result = await checkSendingDomainVerification(client, client, 'company-1')
expect(result.ok).toBe(true)
expect(domainsMock.verify).toHaveBeenCalledWith('rd_1')
const updateArgs = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(updateArgs.status).toBe('verified')
expect(typeof updateArgs.verified_at).toBe('string')
})
it('refuses to flip when Resend reports a different domain name than the row (swapped row)', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { ...ROW, domain: 'platform.example' } })
domainsMock.verify.mockResolvedValue({ data: {}, error: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', status: 'verified', records: [], capabilities: SENDING_ONLY },
error: null,
})
const result = await checkSendingDomainVerification(client, client, 'company-1')
expect(result).toMatchObject({ ok: false, status: 409 })
expect(findCall('company_sending_domains', 'update')).toBeUndefined()
})
it('refuses to flip a domain without the sending capability', async () => {
const { client, enqueue } = sb()
enqueue({ data: ROW })
domainsMock.verify.mockResolvedValue({ data: {}, error: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', status: 'verified', records: [], capabilities: { sending: 'disabled', receiving: 'enabled' } },
error: null,
})
const result = await checkSendingDomainVerification(client, client, 'company-1')
expect(result).toMatchObject({ ok: false, status: 409 })
})
})
describe('updateSendingDomainSettings', () => {
it('normalizes the local part, strips header characters from the name, and toggles enabled', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: ROW })
enqueue({ data: { ...ROW, sender_local_part: 'ekonomi', sender_name: 'Hans Bolag', enabled: false } })
const result = await updateSendingDomainSettings(client, 'company-1', {
sender_local_part: 'Ekonomi',
sender_name: 'Hans <Bolag>\r\n',
enabled: false,
})
expect(result.ok).toBe(true)
expect(findCall('company_sending_domains', 'update')?.[0]).toEqual({
sender_local_part: 'ekonomi',
sender_name: 'Hans Bolag',
enabled: false,
})
})
it('rejects an invalid local part with 400', async () => {
const { client, enqueue } = sb()
enqueue({ data: ROW })
const result = await updateSendingDomainSettings(client, 'company-1', { sender_local_part: 'no spaces' })
expect(result).toMatchObject({ ok: false, status: 400 })
})
it('clears the sender name with null', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { ...ROW, sender_name: 'Old' } })
enqueue({ data: ROW })
const result = await updateSendingDomainSettings(client, 'company-1', { sender_name: null })
expect(result.ok).toBe(true)
expect(findCall('company_sending_domains', 'update')?.[0]).toEqual({ sender_name: null })
})
})
describe('removeSendingDomain', () => {
it('deletes a sending-only Resend domain, then the row', async () => {
const { client, enqueue, findCalls } = sb()
enqueue({ data: ROW })
enqueue({ data: null }) // delete
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', capabilities: SENDING_ONLY },
error: null,
})
domainsMock.remove.mockResolvedValue({ data: null, error: null })
const result = await removeSendingDomain(client, 'company-1')
expect(result).toEqual({ ok: true, data: { removed: true } })
expect(domainsMock.remove).toHaveBeenCalledWith('rd_1')
expect(findCalls('company_sending_domains', 'delete')).toHaveLength(1)
})
it("never deletes the platform's own sender domain from Resend, even when a row points at it", async () => {
const { client, enqueue } = sb()
enqueue({ data: { ...ROW, domain: 'platform.example', resend_domain_id: 'rd_platform' } })
enqueue({ data: null }) // delete row
domainsMock.get.mockResolvedValue({
data: { id: 'rd_platform', name: 'platform.example', capabilities: SENDING_ONLY },
error: null,
})
const result = await removeSendingDomain(client, 'company-1')
expect(result.ok).toBe(true)
expect(domainsMock.remove).not.toHaveBeenCalled()
})
it('leaves a receiving-capable Resend domain alone', async () => {
const { client, enqueue } = sb()
enqueue({ data: ROW })
enqueue({ data: null })
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', capabilities: { sending: 'enabled', receiving: 'enabled' } },
error: null,
})
const result = await removeSendingDomain(client, 'company-1')
expect(result.ok).toBe(true)
expect(domainsMock.remove).not.toHaveBeenCalled()
})
})
describe('applySendingDomainStatusFromWebhook', () => {
it('returns no_match for an unknown Resend domain id', async () => {
const { client, enqueue } = sb()
enqueue({ data: null })
await expect(applySendingDomainStatusFromWebhook(client, { id: 'rd_other', status: 'verified' })).resolves.toBe(
'no_match',
)
expect(domainsMock.get).not.toHaveBeenCalled()
})
it('returns error (so the webhook is retried) when the lookup or the update fails', async () => {
const lookupFail = sb()
lookupFail.enqueue({ data: null, error: { message: 'db down' } })
await expect(
applySendingDomainStatusFromWebhook(lookupFail.client, { id: 'rd_1', status: 'failed' }),
).resolves.toBe('error')
const updateFail = sb()
updateFail.enqueue({ data: { id: 'row-1', verified_at: null } })
updateFail.enqueue({ data: null, error: { message: 'db down' } })
await expect(
applySendingDomainStatusFromWebhook(updateFail.client, { id: 'rd_1', status: 'failed' }),
).resolves.toBe('error')
})
it('keeps the stored status when Resend names a different domain than the row (swapped row)', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { id: 'row-1', domain: 'platform.example', verified_at: null } })
enqueue({ data: null }) // update (records/last_checked only)
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', capabilities: SENDING_ONLY },
error: null,
})
const ok = await applySendingDomainStatusFromWebhook(client, { id: 'rd_1', status: 'verified' })
expect(ok).toBe('applied')
const update = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(update.status).toBeUndefined()
})
it('flips to verified only after confirming the sending capability with Resend', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { id: 'row-1', domain: 'hansbolag.example', verified_at: null } })
enqueue({ data: null }) // update
domainsMock.get.mockResolvedValue({
data: { id: 'rd_1', name: 'hansbolag.example', capabilities: SENDING_ONLY },
error: null,
})
const ok = await applySendingDomainStatusFromWebhook(client, { id: 'rd_1', status: 'verified', records: [DKIM_RECORD] })
expect(ok).toBe('applied')
const update = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(update.status).toBe('verified')
expect(update.dns_records).toEqual([DKIM_RECORD])
expect(typeof update.verified_at).toBe('string')
})
it('keeps the stored status when Resend cannot confirm sending', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { id: 'row-1', verified_at: null } })
enqueue({ data: null })
domainsMock.get.mockResolvedValue({ data: null, error: { message: 'nope' } })
const ok = await applySendingDomainStatusFromWebhook(client, { id: 'rd_1', status: 'verified' })
expect(ok).toBe('applied')
const update = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(update.status).toBeUndefined()
})
it('records a failed status without calling Resend', async () => {
const { client, enqueue, findCall } = sb()
enqueue({ data: { id: 'row-1', verified_at: '2026-08-01T00:00:00Z' } })
enqueue({ data: null })
const ok = await applySendingDomainStatusFromWebhook(client, { id: 'rd_1', status: 'failed' })
expect(ok).toBe('applied')
expect(domainsMock.get).not.toHaveBeenCalled()
const update = findCall('company_sending_domains', 'update')?.[0] as Record<string, unknown>
expect(update.status).toBe('failed')
expect(update.verified_at).toBe('2026-08-01T00:00:00Z')
})
})
+197 -1
View File
@@ -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<boolean> {
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<NextResponse | null> {
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<typeof ClaimSendingDomainSchema>
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<typeof PatchSendingDomainSchema>
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' } })
+69 -12
View File
@@ -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 "<name> <address>" and the platform
* sender is not involved at all. Otherwise the platform default:
* "<fromName> via <App> <RESEND_FROM_EMAIL>" or "<App> <RESEND_FROM_EMAIL>".
*
* 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)
@@ -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<T> =
| { 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<CompanySendingDomain | null> {
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<SendingDomainResult<CompanySendingDomain>> {
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<SendingDomainResult<CompanySendingDomain>> {
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<SendingDomainResult<CompanySendingDomain>> {
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<SendingDomainResult<{ removed: true }>> {
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<WebhookApplyOutcome> {
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'
}
+92
View File
@@ -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<tura>')).toBeNull()
expect(normalizeSenderLocalPart('faktura@x')).toBeNull()
expect(normalizeSenderLocalPart('a'.repeat(65))).toBeNull()
})
})
+115
View File
@@ -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()
})
})
+83
View File
@@ -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
}
+83
View File
@@ -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'
>
/** `<local>@<domain>`; 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<InvoiceSenderIdentity | undefined> {
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
}
}
+7
View File
@@ -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 "<name> <address>" 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
+8
View File
@@ -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]
@@ -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),
@@ -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 }),
+4
View File
@@ -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,
@@ -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,
})
+5 -1
View File
@@ -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<ProcessRemindersResult>
interestDays: interest.days,
reminderFee,
},
await resolveInvoiceSender(supabase, invoice.company_id, company.company_name),
)
if (sendResult.success) {
@@ -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,
+2
View File
@@ -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,
})
+1
View File
@@ -1041,6 +1041,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
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',
+52
View File
@@ -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.",
+52
View File
@@ -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.",
@@ -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
-- ("<Company> via <App> <noreply@platform>"). 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
-- "<sender_name> <sender_local_part@domain>" 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>@<domain>.
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';
@@ -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: <the platform's own sender 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';
+25
View File
@@ -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