import type { PolicyConfig } from "./config.js";
import { ToolError } from "./errors.js";
import { normalizeAddress, validateEmailAddress } from "./utils/email.js";
/**
* Normalized and validated recipient lists for an email message.
*
* All addresses are validated as email addresses and normalized to
* lowercase, trimmed format. This structure represents the final
* validated recipient state after policy checks.
*/
export interface NormalizedRecipients {
/** Primary recipients (required). */
readonly to: readonly string[];
/** Carbon copy recipients (optional). */
readonly cc: readonly string[];
/** Blind carbon copy recipients (optional). */
readonly bcc: readonly string[];
}
/**
* Normalize and validate recipient lists.
*
* Accepts strings or arrays of strings for each recipient type (to, cc, bcc).
* Validates that at least one recipient is provided and that all email addresses
* are valid. Normalizes addresses to lowercase, trimmed format.
*
* @param input - The recipient lists to normalize.
* @throws ToolError if no recipients provided or if any address is invalid.
* @returns Normalized recipient lists with validated email addresses.
*/
export function normalizeRecipients(input: {
to: string | readonly string[];
cc?: string | readonly string[];
bcc?: string | readonly string[];
}): NormalizedRecipients {
// Helper to convert single string or array into normalized array
const normalizeList = (value?: string | readonly string[]): string[] => {
if (!value) {
return [];
}
const list: readonly string[] = Array.isArray(value) ? value : [value];
return list.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
};
// Normalize each recipient type
const toList = normalizeList(input.to);
const ccList = normalizeList(input.cc);
const bccList = normalizeList(input.bcc);
// Combine all recipients for validation
const all = [...toList, ...ccList, ...bccList];
if (all.length === 0) {
throw new ToolError("VALIDATION_ERROR", "At least one recipient is required.");
}
// Validate each email address before normalizing
all.forEach((address) => {
if (!validateEmailAddress(address)) {
throw new ToolError("VALIDATION_ERROR", `Invalid email address: ${address}`);
}
});
// Return normalized recipients (lowercase, trimmed)
return {
to: toList.map(normalizeAddress),
cc: ccList.map(normalizeAddress),
bcc: bccList.map(normalizeAddress),
};
}
/**
* Enforce recipient limits and allowlist policy.
*
* Checks that the total number of recipients does not exceed the policy maximum.
* If an allowlist is configured (either addresses or domains), verifies that
* all recipients are permitted. An empty allowlist means all recipients are allowed.
*
* @param policy - The policy configuration containing limits and allowlists.
* @param recipients - The normalized recipient lists to validate.
* @throws ToolError if recipient limit is exceeded or if recipient is blocked by allowlist.
*/
export function enforceRecipientPolicy(
policy: PolicyConfig,
recipients: NormalizedRecipients,
): void {
// Combine all recipients for total count validation
const all = [...recipients.to, ...recipients.cc, ...recipients.bcc];
if (all.length > policy.maxRecipients) {
throw new ToolError(
"POLICY_VIOLATION",
`Recipient limit exceeded (max ${policy.maxRecipients}).`,
);
}
// Check if allowlist is configured (if not, allow all recipients)
const hasAllowlist = policy.allowlistAddresses.length > 0 || policy.allowlistDomains.length > 0;
if (!hasAllowlist) {
return;
}
// Validate each recipient against allowlist
all.forEach((address) => {
const [localPart, domain] = address.split("@");
if (!localPart || !domain) {
throw new ToolError("POLICY_VIOLATION", `Invalid email address: ${address}`);
}
// Recipient is allowed if it matches either the full address or its domain
const allowedByAddress = policy.allowlistAddresses.includes(address);
const allowedByDomain = policy.allowlistDomains.includes(domain);
if (!allowedByAddress && !allowedByDomain) {
throw new ToolError("POLICY_VIOLATION", `Recipient blocked by allowlist: ${address}`);
}
});
}