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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user