feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)
* feat(invoicing): opt-in invoice email from the company's own sending domain Companies holding the custom_sender_domain capability grant can register their own domain (Resend sending-only profile), publish DKIM/SPF, and once verified every invoice email (send, reminders, recurring, payment confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>" instead of the platform sender. Reply-To is unchanged. - New table company_sending_domains (RLS: members read, owner/admin write; audit trigger), types, archive-export classification. - New capability key custom_sender_domain: manually granted per company, deliberately outside PAID_CAPABILITIES (never trial-seeded, never written by the Stripe sync). Without the grant the settings section is hidden and nothing changes. - Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify), Resend domain lifecycle without orphan adoption, domain.updated handling on the delivery webhook, explicit From support in the Resend adapter. - Core resolveInvoiceSender(): verified + enabled + entitled, else the platform sender; never throws. - Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en). - Unit tests for the resolver, domain helpers, routes, From header; pg-real test for RLS and constraints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoicing): harden sending-domain writes, sender fallback, review findings Skeptic refutations: - Tenant JWTs could insert/update company_sending_domains with status = 'verified' and an arbitrary domain through PostgREST (RLS only checked membership), then send invoice mail as that domain. New migration 20260822130000 adds a BEFORE trigger: tenants may only open a pending claim and edit sender_local_part/sender_name/enabled; domain and verification state are service-role only. claim/verify helpers now take a service-role writer for those columns; the route's RLS client still does the insert. - A company domain Resend later rejects made every invoice send fail: the Resend adapter retries once as the platform sender when an explicit company From is rejected (nothing was sent, so no double send). Review findings: - domain.updated webhook: discriminated outcome; DB errors answer 500 so Svix retries, unknown domains are acknowledged. - Display names are RFC 5322-quoted only when they carry specials. - Sender local part is a strict dot-atom (no trailing/consecutive dots), in code and in the CHECK constraint; resend_domain_id index is UNIQUE. - IME composition guard on the claim input; event bus reset in tests; settings section skips its request for non-admins. Deferred (needs a product call): persisting the effective From address in the invoice delivery log touches the hardened evidence triggers; recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a tenant could delete and re-insert its pending row under the same id with a reserved domain, and the service-role writer updated by id alone. Now: - the claim's verification-state write filters on (id, company_id, domain, resend_domain_id IS NULL) and rolls back on zero rows; - verify and the domain.updated webhook compare Resend's domain name with the row before writing verified; - resolveInvoiceSender refuses reserved platform domains and non-hostnames at send time (reserved-domain logic moved to lib/email/domain-name.ts and shared with the claim validator). pg-real: the case-insensitive uniqueness assertion now expects the domain_shape CHECK (lowercase enforced) for an uppercase variant and the unique index for a same-case duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
561e64afc7
commit
0040cadacc
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user