Files
accounted/extensions/general/email/lib/sending-domains.ts
T
MattssonandClaude Fable 5 0040cadacc 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>
2026-08-23 00:07:30 +02:00

541 lines
19 KiB
TypeScript

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'
}