0040cadacc
* 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>
62 lines
1.5 KiB
TypeScript
62 lines
1.5 KiB
TypeScript
/**
|
|
* Email Service Interface
|
|
*
|
|
* Core defines the contract. The email extension registers a real
|
|
* implementation (Resend). Without the extension, a no-op service
|
|
* is used: email-dependent features degrade gracefully.
|
|
*/
|
|
|
|
export interface SendEmailOptions {
|
|
to: string | string[]
|
|
cc?: string | string[]
|
|
bcc?: string | string[]
|
|
subject: string
|
|
html: string
|
|
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
|
|
contentType?: string
|
|
}>
|
|
}
|
|
|
|
export interface SendEmailResult {
|
|
success: boolean
|
|
provider?: string
|
|
messageId?: string
|
|
error?: string
|
|
}
|
|
|
|
export interface EmailService {
|
|
sendEmail(options: SendEmailOptions): Promise<SendEmailResult>
|
|
isConfigured(): boolean
|
|
}
|
|
|
|
class NoopEmailService implements EmailService {
|
|
async sendEmail(): Promise<SendEmailResult> {
|
|
return { success: false, error: 'Email service not configured' }
|
|
}
|
|
isConfigured(): boolean {
|
|
return false
|
|
}
|
|
}
|
|
|
|
let emailService: EmailService = new NoopEmailService()
|
|
|
|
export function getEmailService(): EmailService {
|
|
return emailService
|
|
}
|
|
|
|
export function registerEmailService(svc: EmailService): void {
|
|
emailService = svc
|
|
}
|