Files
accounted/lib/invoices/email-recipients.ts
T
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00

156 lines
4.7 KiB
TypeScript

export const MAX_INVOICE_EMAIL_RECIPIENTS = 20
export const MAX_INVOICE_EMAIL_COPY_RECIPIENTS = MAX_INVOICE_EMAIL_RECIPIENTS - 1
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export interface ResolveInvoiceEmailRecipientsInput {
to: string | readonly string[]
configuredCc?: readonly string[] | null
configuredBcc?: readonly string[] | null
legacyCc?: string | null
additionalCc?: readonly string[]
additionalBcc?: readonly string[]
}
export interface ResolvedInvoiceEmailRecipients {
to: string[]
cc: string[]
bcc: string[]
}
export function invoiceEmailRecipientCount(
recipients: ResolvedInvoiceEmailRecipients,
): number {
return recipients.to.length + recipients.cc.length + recipients.bcc.length
}
export function exceedsInvoiceEmailRecipientLimit(
recipients: ResolvedInvoiceEmailRecipients,
): boolean {
return invoiceEmailRecipientCount(recipients) > MAX_INVOICE_EMAIL_RECIPIENTS
}
export interface InvoiceEmailRecipientCollision {
address: string
field: 'additional_cc' | 'additional_bcc'
conflicts_with:
| 'to'
| 'configured_cc'
| 'configured_bcc'
| 'additional_cc'
| 'additional_bcc'
}
function normalizedKey(address: string): string {
return address.trim().toLocaleLowerCase('en-US')
}
function uniqueAddresses(
addresses: readonly string[],
used: Set<string>,
): string[] {
const result: string[] = []
for (const rawAddress of addresses) {
const address = rawAddress.trim()
const key = normalizedKey(address)
if (!key || used.has(key)) continue
used.add(key)
result.push(address)
}
return result
}
/**
* Build the exact recipient lists submitted to the email provider.
*
* A null company CC list means the company has never configured the new
* setting, so the historical automatic-copy address remains in effect. An
* explicit empty list disables that fallback. Recipients are de-duplicated
* with To taking precedence over CC and CC taking precedence over BCC.
*/
export function resolveInvoiceEmailRecipients(
input: ResolveInvoiceEmailRecipientsInput,
): ResolvedInvoiceEmailRecipients {
const used = new Set<string>()
const rawTo = typeof input.to === 'string' ? [input.to] : input.to
const to = uniqueAddresses(rawTo, used)
const fixedCc = input.configuredCc === null || input.configuredCc === undefined
? input.legacyCc
? [input.legacyCc]
: []
: input.configuredCc
const cc = uniqueAddresses(
[...fixedCc, ...(input.additionalCc ?? [])],
used,
)
const bcc = uniqueAddresses(
[...(input.configuredBcc ?? []), ...(input.additionalBcc ?? [])],
used,
)
return { to, cc, bcc }
}
/**
* Report explicit per-send recipients that would be silently moved or omitted
* by deterministic To, CC, BCC precedence. Company-level configuration keeps
* its historical de-duplication behavior, while caller-supplied collisions are
* rejected before invoice number allocation so the caller can correct them.
*/
export function findAdditionalInvoiceRecipientCollisions(
input: ResolveInvoiceEmailRecipientsInput,
): InvoiceEmailRecipientCollision[] {
const occupied = new Map<string, InvoiceEmailRecipientCollision['conflicts_with']>()
const rawTo = typeof input.to === 'string' ? [input.to] : input.to
for (const address of rawTo) {
const key = normalizedKey(address)
if (key) occupied.set(key, 'to')
}
const fixedCc = input.configuredCc === null || input.configuredCc === undefined
? input.legacyCc
? [input.legacyCc]
: []
: input.configuredCc
for (const address of fixedCc) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'configured_cc')
}
for (const address of input.configuredBcc ?? []) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'configured_bcc')
}
const collisions: InvoiceEmailRecipientCollision[] = []
for (const address of input.additionalCc ?? []) {
const key = normalizedKey(address)
if (!key) continue
const conflict = occupied.get(key)
if (conflict) {
collisions.push({ address: address.trim(), field: 'additional_cc', conflicts_with: conflict })
continue
}
occupied.set(key, 'additional_cc')
}
for (const address of input.additionalBcc ?? []) {
const key = normalizedKey(address)
if (!key) continue
const conflict = occupied.get(key)
if (conflict) {
collisions.push({ address: address.trim(), field: 'additional_bcc', conflicts_with: conflict })
continue
}
occupied.set(key, 'additional_bcc')
}
return collisions
}
export function parseInvoiceRecipientText(value: string): string[] {
const used = new Set<string>()
return uniqueAddresses(value.split(/[\n,;]+/), used)
}