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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-23 00:07:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 561e64afc7
commit 0040cadacc
35 changed files with 3104 additions and 14 deletions
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest'
import {
normalizeDomainName,
isValidHostname,
isReservedSenderDomain,
normalizeSenderLocalPart,
} from '@/lib/email/domain-name'
describe('normalizeDomainName', () => {
it('lowercases and strips trailing dots', () => {
expect(normalizeDomainName('Faktura.HansBolag.SE.')).toBe('faktura.hansbolag.se')
})
it('accepts a pasted URL', () => {
expect(normalizeDomainName('https://hansbolag.se/kontakt?x=1')).toBe('hansbolag.se')
})
it('accepts a pasted email address', () => {
expect(normalizeDomainName('faktura@hansbolag.se')).toBe('hansbolag.se')
})
it('punycodes Swedish IDN domains', () => {
const result = normalizeDomainName('blåbär.se')
expect(result).not.toBeNull()
expect(result!.startsWith('xn--')).toBe(true)
expect(result!.endsWith('.se')).toBe(true)
})
it('rejects hostnames without a dot, empty input, and IP addresses', () => {
expect(normalizeDomainName('nodots')).toBeNull()
expect(normalizeDomainName('')).toBeNull()
expect(normalizeDomainName(' ')).toBeNull()
expect(normalizeDomainName('192.168.0.1')).toBeNull()
})
})
describe('isReservedSenderDomain', () => {
it("flags the platform sender domain, the inbound domain, the app host and their subdomains", () => {
const saved = {
from: process.env.RESEND_FROM_EMAIL,
inbound: process.env.RESEND_INBOUND_DOMAIN,
app: process.env.NEXT_PUBLIC_APP_URL,
}
process.env.RESEND_FROM_EMAIL = 'noreply@platform.example'
process.env.RESEND_INBOUND_DOMAIN = 'inbox.platform.example'
process.env.NEXT_PUBLIC_APP_URL = 'https://app.other.example'
try {
expect(isReservedSenderDomain('platform.example')).toBe(true)
expect(isReservedSenderDomain('mail.platform.example')).toBe(true)
expect(isReservedSenderDomain('inbox.platform.example')).toBe(true)
expect(isReservedSenderDomain('app.other.example')).toBe(true)
expect(isReservedSenderDomain('APP.OTHER.EXAMPLE')).toBe(true)
expect(isReservedSenderDomain('hansbolag.example')).toBe(false)
expect(isReservedSenderDomain('notplatform.example')).toBe(false)
} finally {
process.env.RESEND_FROM_EMAIL = saved.from
process.env.RESEND_INBOUND_DOMAIN = saved.inbound
process.env.NEXT_PUBLIC_APP_URL = saved.app
}
})
})
describe('isValidHostname', () => {
it('accepts ordinary hostnames and rejects malformed labels', () => {
expect(isValidHostname('hansbolag.se')).toBe(true)
expect(isValidHostname('-bad.se')).toBe(false)
expect(isValidHostname('bad-.se')).toBe(false)
expect(isValidHostname('a.b')).toBe(false) // too short
})
})
describe('normalizeSenderLocalPart', () => {
it('lowercases and accepts dot, hyphen, underscore', () => {
expect(normalizeSenderLocalPart('Faktura')).toBe('faktura')
expect(normalizeSenderLocalPart('ekonomi.ab_1-x')).toBe('ekonomi.ab_1-x')
})
it('rejects trailing and consecutive dots (dot-atom rule)', () => {
expect(normalizeSenderLocalPart('faktura.')).toBeNull()
expect(normalizeSenderLocalPart('fak..tura')).toBeNull()
expect(normalizeSenderLocalPart('fak.tura')).toBe('fak.tura')
})
it('rejects header-breaking or out-of-alphabet input', () => {
expect(normalizeSenderLocalPart('')).toBeNull()
expect(normalizeSenderLocalPart('.faktura')).toBeNull()
expect(normalizeSenderLocalPart('fak tura')).toBeNull()
expect(normalizeSenderLocalPart('fak<tura>')).toBeNull()
expect(normalizeSenderLocalPart('faktura@x')).toBeNull()
expect(normalizeSenderLocalPart('a'.repeat(65))).toBeNull()
})
})
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
buildSenderAddress,
senderFromRow,
resolveInvoiceSender,
} from '@/lib/email/invoice-sender'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
const hasCapabilityMock = vi.fn()
vi.mock('@/lib/entitlements/has-capability', () => ({
hasCapability: (...args: unknown[]) => hasCapabilityMock(...args),
}))
const VERIFIED_ROW = {
domain: 'hansbolag.example',
status: 'verified' as const,
enabled: true,
sender_local_part: 'faktura',
sender_name: null,
}
describe('buildSenderAddress', () => {
it('joins local part and domain', () => {
expect(buildSenderAddress('faktura', 'hansbolag.example')).toBe('faktura@hansbolag.example')
})
})
describe('senderFromRow', () => {
it('uses the company name when no sender name is stored', () => {
expect(senderFromRow(VERIFIED_ROW, 'Hans Bolag AB')).toEqual({
name: 'Hans Bolag AB',
address: 'faktura@hansbolag.example',
})
})
it('prefers an explicit sender name', () => {
expect(senderFromRow({ ...VERIFIED_ROW, sender_name: 'Hans Bolag Ekonomi' }, 'Hans Bolag AB')).toEqual({
name: 'Hans Bolag Ekonomi',
address: 'faktura@hansbolag.example',
})
})
it('never sends as a reserved platform domain or a malformed domain, even from a verified row', () => {
const previous = process.env.RESEND_FROM_EMAIL
process.env.RESEND_FROM_EMAIL = 'noreply@platform.example'
try {
expect(senderFromRow({ ...VERIFIED_ROW, domain: 'platform.example' }, 'X')).toBeUndefined()
expect(senderFromRow({ ...VERIFIED_ROW, domain: 'mail.platform.example' }, 'X')).toBeUndefined()
expect(senderFromRow({ ...VERIFIED_ROW, domain: 'not a host' }, 'X')).toBeUndefined()
expect(senderFromRow(VERIFIED_ROW, 'X')).toEqual({ name: 'X', address: 'faktura@hansbolag.example' })
} finally {
if (previous === undefined) delete process.env.RESEND_FROM_EMAIL
else process.env.RESEND_FROM_EMAIL = previous
}
})
it('returns undefined for missing, unverified, paused, or nameless rows', () => {
expect(senderFromRow(null, 'X')).toBeUndefined()
expect(senderFromRow({ ...VERIFIED_ROW, status: 'pending' }, 'X')).toBeUndefined()
expect(senderFromRow({ ...VERIFIED_ROW, status: 'failed' }, 'X')).toBeUndefined()
expect(senderFromRow({ ...VERIFIED_ROW, enabled: false }, 'X')).toBeUndefined()
expect(senderFromRow(VERIFIED_ROW, ' ')).toBeUndefined()
expect(senderFromRow(VERIFIED_ROW, null)).toBeUndefined()
})
})
describe('resolveInvoiceSender', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns undefined and skips the entitlement check when the company has no verified row', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: null })
const result = await resolveInvoiceSender(supabase as unknown as SupabaseClient, 'company-1', 'Hans Bolag AB')
expect(result).toBeUndefined()
expect(hasCapabilityMock).not.toHaveBeenCalled()
// Only verified + enabled rows are ever read.
const eqArgs = findCall('company_sending_domains', 'eq')
expect(eqArgs).toEqual(['company_id', 'company-1'])
})
it('returns the sender when the row is verified and the company holds the grant', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: VERIFIED_ROW })
hasCapabilityMock.mockResolvedValue(true)
const result = await resolveInvoiceSender(supabase as unknown as SupabaseClient, 'company-1', 'Hans Bolag AB')
expect(result).toEqual({ name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' })
expect(hasCapabilityMock).toHaveBeenCalledWith(expect.anything(), 'company-1', 'custom_sender_domain')
})
it('falls back to the platform sender when the grant has lapsed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: VERIFIED_ROW })
hasCapabilityMock.mockResolvedValue(false)
const result = await resolveInvoiceSender(supabase as unknown as SupabaseClient, 'company-1', 'Hans Bolag AB')
expect(result).toBeUndefined()
})
it('never throws: a read error or a thrown entitlement check yields undefined', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'boom' } })
await expect(
resolveInvoiceSender(supabase as unknown as SupabaseClient, 'company-1', 'X'),
).resolves.toBeUndefined()
const second = createQueuedMockSupabase()
second.enqueue({ data: VERIFIED_ROW })
hasCapabilityMock.mockRejectedValue(new Error('network'))
await expect(
resolveInvoiceSender(second.supabase as unknown as SupabaseClient, 'company-1', 'X'),
).resolves.toBeUndefined()
})
})
+83
View File
@@ -0,0 +1,83 @@
import { domainToASCII } from 'node:url'
/**
* Hostname normalization for user-entered email domains.
*
* Accepts what users actually paste ("Faktura.Hansbolag.SE.", a full URL, or
* an email address) and reduces it to a lowercased, punycoded hostname.
* Returns null when no valid hostname can be extracted. Dependency-free so
* both core and extensions can share one definition of "a valid domain".
*/
export function normalizeDomainName(raw: string): string | null {
let value = String(raw ?? '').trim().toLowerCase()
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '') // strip scheme
value = value.split('/')[0].split('?')[0]
const atIndex = value.lastIndexOf('@')
if (atIndex !== -1) value = value.slice(atIndex + 1)
value = value.replace(/^\.+|\.+$/g, '')
if (!value) return null
// IDN -> punycode (blåbär.se -> xn--blbr-noab.se). Returns '' when the
// input is not a valid domain.
const ascii = domainToASCII(value)
if (!ascii) return null
return isValidHostname(ascii) ? ascii : null
}
export function isValidHostname(domain: string): boolean {
if (domain.length < 4 || domain.length > 253) return false
const labels = domain.split('.')
if (labels.length < 2) return false
if (!labels.every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return false
// TLD must contain a letter: rejects IP addresses and all-numeric TLDs.
return /[a-z]/.test(labels[labels.length - 1])
}
function hostnameOf(value: string | undefined): string | null {
if (!value) return null
try {
return new URL(value).hostname.toLowerCase() || null
} catch {
return null
}
}
/**
* Domains no tenant may ever send as: the platform's own sender domain
* (RESEND_FROM_EMAIL), the shared inbound domain, and the app host, plus
* their subdomains. Read from env on every call (cheap, and tests flip env).
*/
export function reservedSenderDomains(): string[] {
const reserved: string[] = []
const fromDomain = process.env.RESEND_FROM_EMAIL
? normalizeDomainName(process.env.RESEND_FROM_EMAIL)
: null
if (fromDomain) reserved.push(fromDomain)
const inbound = process.env.RESEND_INBOUND_DOMAIN?.toLowerCase()
if (inbound) reserved.push(inbound)
const appHost = hostnameOf(process.env.NEXT_PUBLIC_APP_URL)
if (appHost) reserved.push(appHost)
return reserved
}
/** True when `domain` is a reserved platform domain or a subdomain of one. */
export function isReservedSenderDomain(domain: string): boolean {
const d = domain.toLowerCase()
return reservedSenderDomains().some((r) => d === r || d.endsWith(`.${r}`))
}
/**
* Local part of a sender address: conservative dot-atom subset, lowercase.
* Dots may only separate atoms (RFC 5322 dot-atom): no leading, trailing or
* consecutive dots. Mirrored by the CHECK constraint in
* 20260822130000_company_sending_domains_tenant_guard.sql.
*/
export const SENDER_LOCAL_PART_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/
const SENDER_LOCAL_PART_MAX_LENGTH = 64
export function normalizeSenderLocalPart(raw: string): string | null {
const value = String(raw ?? '').trim().toLowerCase()
if (value.length === 0 || value.length > SENDER_LOCAL_PART_MAX_LENGTH) return null
return SENDER_LOCAL_PART_PATTERN.test(value) ? value : null
}
+83
View File
@@ -0,0 +1,83 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { hasCapability } from '@/lib/entitlements/has-capability'
import type { CompanySendingDomain } from '@/types'
import { isReservedSenderDomain, isValidHostname } from '@/lib/email/domain-name'
/**
* Sender identity for invoice email: the From header's display name and
* address. Only used when a company has opted in to its own sending domain;
* otherwise invoice mail keeps the platform sender.
*/
export interface InvoiceSenderIdentity {
name: string
address: string
}
type SenderRow = Pick<
CompanySendingDomain,
'domain' | 'status' | 'enabled' | 'sender_local_part' | 'sender_name'
>
/** `<local>@<domain>`; pure, so the address shape is unit-testable. */
export function buildSenderAddress(localPart: string, domain: string): string {
return `${localPart}@${domain}`
}
/**
* Pure mapping from a sending-domain row to the From identity, or undefined
* when the row must not change the sender (unverified, paused, or missing).
* Falls back to the company name when no explicit sender name is stored.
*/
export function senderFromRow(
row: SenderRow | null | undefined,
companyName: string | null | undefined,
): InvoiceSenderIdentity | undefined {
if (!row || row.status !== 'verified' || !row.enabled) return undefined
// Last line of defense at send time: never send as a platform domain, and
// never trust a row whose domain is not a plain hostname, whatever the DB
// says (the claim/verify paths and the tenant guard trigger enforce this
// earlier; a tampered row must still not reach the From header).
const domain = row.domain.toLowerCase()
if (!isValidHostname(domain) || isReservedSenderDomain(domain)) return undefined
const name = (row.sender_name ?? companyName ?? '').trim()
if (!name) return undefined
return { name, address: buildSenderAddress(row.sender_local_part, row.domain) }
}
/**
* Resolve the From identity for a company's invoice email.
*
* Returns undefined in every case where the platform sender should be used:
* no sending-domain row, not verified, paused, no capability grant (the
* opt-in can lapse), or any read error. Never throws: a sender lookup
* failure must never stop an invoice from going out.
*
* Order matters for cost: most companies have no row, so the table read
* happens first and the two entitlement queries only run for opted-in
* companies.
*/
export async function resolveInvoiceSender(
supabase: SupabaseClient,
companyId: string,
companyName: string | null | undefined,
): Promise<InvoiceSenderIdentity | undefined> {
try {
const { data, error } = await supabase
.from('company_sending_domains')
.select('domain, status, enabled, sender_local_part, sender_name')
.eq('company_id', companyId)
.eq('status', 'verified')
.eq('enabled', true)
.maybeSingle()
if (error || !data) return undefined
const sender = senderFromRow(data as SenderRow, companyName)
if (!sender) return undefined
const entitled = await hasCapability(supabase, companyId, CAPABILITY.custom_sender_domain)
return entitled ? sender : undefined
} catch {
return undefined
}
}
+7
View File
@@ -15,6 +15,13 @@ export interface SendEmailOptions {
text?: string
replyTo?: string
fromName?: string
/**
* Explicit From identity (company's own verified sending domain). When
* set, the provider sends as "<name> <address>" instead of the platform
* sender; `fromName` is ignored. Callers obtain it from
* resolveInvoiceSender(): never build one from raw user input.
*/
from?: { name: string; address: string }
attachments?: Array<{
filename: string
content: Buffer | string
+8
View File
@@ -34,6 +34,14 @@ export const CAPABILITY = {
woocommerce_sync: 'woocommerce_sync',
/** Shopify store sync: orders/refunds imported as a transaction feed. */
shopify_sync: 'shopify_sync',
/**
* Invoice email from the company's own verified sending domain (Resend
* domain per company). Opt-in: granted manually per company, NOT part of
* PAID_CAPABILITIES, so it is never trial-seeded or written by the Stripe
* subscription sync. Without the grant the settings section is hidden and
* mail keeps leaving from the platform sender.
*/
custom_sender_domain: 'custom_sender_domain',
} as const
export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY]
@@ -46,6 +46,10 @@ vi.mock('@/lib/extensions/payment-links', () => ({
const mockSendEmail = vi.fn()
const mockIsConfigured = vi.fn()
vi.mock('@/lib/email/invoice-sender', () => ({
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
@@ -28,6 +28,10 @@ vi.mock('@supabase/ssr', () => {
}
})
vi.mock('@/lib/email/invoice-sender', () => ({
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
+4
View File
@@ -25,6 +25,8 @@ export interface TrackedInvoiceEmailInput {
bcc?: string | string[]
replyTo?: string
fromName?: string
/** Company's own verified sender (resolveInvoiceSender); platform sender when absent. */
from?: SendEmailOptions['from']
subject: string
html: string
text: string
@@ -84,6 +86,7 @@ export async function sendTrackedInvoiceEmail(
bcc,
replyTo,
fromName,
from,
subject,
html,
text,
@@ -151,6 +154,7 @@ export async function sendTrackedInvoiceEmail(
text,
replyTo,
fromName,
from,
attachments: [
{
filename,
@@ -31,6 +31,7 @@ import {
} from '@/lib/invoices/pdf-render-helpers'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import { getEmailService } from '@/lib/email/service'
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { isSandboxCompany } from '@/lib/sandbox/guard'
@@ -661,6 +662,7 @@ async function sendInvoiceFromSchedule(
text,
replyTo: company.email || undefined,
fromName: company.company_name ?? undefined,
from: await resolveInvoiceSender(supabase, companyId, company.company_name),
filename,
pdfBuffer,
})
+5 -1
View File
@@ -1,5 +1,6 @@
import { createServerClient } from '@supabase/ssr'
import { getEmailService } from '@/lib/email/service'
import { resolveInvoiceSender, type InvoiceSenderIdentity } from '@/lib/email/invoice-sender'
import {
generateReminderEmailHtml,
generateReminderEmailText,
@@ -110,6 +111,7 @@ export async function sendReminder(
reminderLevel: 1 | 2 | 3,
actionToken: string,
surcharges: ReminderSurcharges,
sender?: InvoiceSenderIdentity,
): Promise<{ success: boolean; error?: string }> {
const customer = invoice.customer
@@ -139,7 +141,8 @@ export async function sendReminder(
html: generateReminderEmailHtml(emailData),
text: generateReminderEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name || undefined
fromName: company.company_name || undefined,
from: sender,
})
return result
@@ -385,6 +388,7 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
interestDays: interest.days,
reminderFee,
},
await resolveInvoiceSender(supabase, invoice.company_id, company.company_name),
)
if (sendResult.success) {
@@ -95,6 +95,10 @@ vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
vi.mock('@/lib/email/invoice-sender', () => ({
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
isConfigured: () => true,
+2
View File
@@ -104,6 +104,7 @@ import {
} from '@/lib/pending-operations/skatteverket-commit'
import { PartialCommitError } from '@/lib/pending-operations/errors'
import { getEmailService } from '@/lib/email/service'
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
import { hasCapability, CAPABILITY_BLOCKED_MESSAGE_SV } from '@/lib/entitlements/has-capability'
import { PAID_OPERATION_CAPABILITY_MAP } from '@/lib/entitlements/keys'
import {
@@ -2489,6 +2490,7 @@ async function commitSendInvoice(
text,
replyTo: company.email || undefined,
fromName: company.company_name,
from: await resolveInvoiceSender(supabase, companyId, company.company_name),
filename,
pdfBuffer,
})
+1
View File
@@ -1041,6 +1041,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
company_capability_config: 'entitlement state',
company_inbound_domains: 'inbound-mail infrastructure',
company_inboxes: 'inbound-mail infrastructure',
company_sending_domains: 'outbound-mail infrastructure (sender domain verification state)',
company_invitations: 'membership state, meaningless outside the platform',
company_members: 'membership state, meaningless outside the platform',
company_subscriptions: 'billing state',