@beignet/core
Version:
Core framework primitives for Beignet
428 lines (391 loc) • 9.8 kB
text/typescript
/**
* @beignet/core/mail
*
* Shared mail port and test adapters for Beignet applications.
*/
import {
createProvider,
createProviderInstrumentation,
} from "../providers/index.js";
/**
* Value or promise of that value.
*/
export type MaybePromise<T> = T | Promise<T>;
/**
* Email address accepted by Beignet mail helpers.
*/
export type MailAddress =
| string
| {
/**
* Email address.
*/
email: string;
/**
* Optional display name.
*/
name?: string;
};
/**
* Single address or address list.
*/
export type MailAddressList = MailAddress | readonly MailAddress[];
/**
* Common mail message fields shared by all send options.
*/
export interface MailBaseMessage {
/**
* Required recipients.
*/
to: MailAddressList;
/**
* Message subject.
*/
subject: string;
/**
* Sender address. Providers or adapters may supply a default.
*/
from?: MailAddress;
/**
* Carbon-copy recipients.
*/
cc?: MailAddressList;
/**
* Blind-carbon-copy recipients.
*/
bcc?: MailAddressList;
/**
* Reply-to recipients.
*/
replyTo?: MailAddressList;
/**
* Provider-specific message headers.
*/
headers?: Record<string, string>;
}
/**
* Mail send options.
*
* A message must include at least one body format: `text` or `html`.
*/
export type SendMailOptions = MailBaseMessage &
(
| {
text: string;
html?: string;
}
| {
html: string;
text?: string;
}
);
/**
* Normalized mail message with address fields converted to arrays.
*/
export interface NormalizedMailMessage extends MailBaseMessage {
/**
* Normalized recipients.
*/
to: readonly MailAddress[];
/**
* Sender address after applying any default.
*/
from?: MailAddress;
/**
* Normalized carbon-copy recipients.
*/
cc?: readonly MailAddress[];
/**
* Normalized blind-carbon-copy recipients.
*/
bcc?: readonly MailAddress[];
/**
* Normalized reply-to recipients.
*/
replyTo?: readonly MailAddress[];
/**
* Plain text body.
*/
text?: string;
/**
* HTML body.
*/
html?: string;
}
/**
* Result returned by a mail provider.
*/
export interface SendMailResult {
/**
* Provider message ID when available.
*/
id?: string;
/**
* Provider name.
*/
provider?: string;
}
/**
* App-facing mailer port.
*/
export interface MailerPort {
/**
* Send one mail message.
*/
send(message: SendMailOptions): Promise<SendMailResult>;
}
/**
* Delivery captured by the memory mailer.
*/
export interface MemoryMailDelivery {
/**
* Generated delivery ID.
*/
id: string;
/**
* Normalized message that would have been sent.
*/
message: NormalizedMailMessage;
/**
* Timestamp assigned by the memory mailer.
*/
sentAt: Date;
}
/**
* In-memory mailer port for tests and local examples.
*/
export interface MemoryMailerPort extends MailerPort {
/**
* Captured deliveries.
*/
readonly deliveries: readonly MemoryMailDelivery[];
/**
* Clear captured deliveries.
*/
clear(): void;
}
/**
* Options for `createMemoryMailer(...)`.
*/
export interface CreateMemoryMailerOptions {
/**
* Sender used when a message does not specify `from`.
*/
defaultFrom?: MailAddress;
/**
* Clock used for captured deliveries.
*/
now?: () => Date;
/**
* ID factory used for captured deliveries.
*/
id?: () => string;
/**
* Observer called after a delivery is captured.
*/
onSend?: (delivery: MemoryMailDelivery) => MaybePromise<void>;
}
/**
* Error thrown by mail helpers and provider adapters.
*/
export class MailDeliveryError extends Error {
/**
* Provider name when known.
*/
readonly provider?: string;
/**
* Original provider error when available.
*/
readonly cause?: unknown;
constructor(args: { provider?: string; message: string; cause?: unknown }) {
super(args.message);
this.name = "MailDeliveryError";
this.provider = args.provider;
this.cause = args.cause;
}
}
/**
* Normalize a single address or address list into an array.
*
* This helper does not validate email syntax.
*/
export function normalizeMailAddressList(
addresses: MailAddressList | undefined,
): readonly MailAddress[] | undefined {
if (addresses === undefined) return undefined;
return Array.isArray(addresses) ? [...addresses] : [addresses as MailAddress];
}
function normalizeOptionalMailAddressList(
addresses: MailAddressList | undefined,
): readonly MailAddress[] | undefined {
const normalized = normalizeMailAddressList(addresses);
return normalized && normalized.length > 0 ? normalized : undefined;
}
/**
* Format one address for providers that accept RFC-like address strings.
*
* Rejects carriage returns and line feeds so address values cannot inject
* additional mail header lines. This helper does not validate email syntax.
*/
export function formatMailAddress(address: MailAddress): string {
if (typeof address === "string") {
assertMailAddressLineSafe(address);
return address;
}
assertMailAddressLineSafe(address.email);
if (!address.name) return address.email;
assertMailAddressLineSafe(address.name);
const escapedName = address.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `"${escapedName}" <${address.email}>`;
}
function assertMailAddressLineSafe(value: string): void {
if (/[\r\n]/.test(value)) {
throw new MailDeliveryError({
message:
"Mail addresses must not include carriage returns or line feeds.",
});
}
}
/**
* Format one or more addresses for providers that accept string address fields.
*/
export function formatMailAddressList(
addresses: MailAddressList,
): string | string[] {
const normalized = normalizeMailAddressList(addresses) ?? [];
const formatted = normalized.map(formatMailAddress);
return formatted.length === 1 ? formatted[0] : formatted;
}
/**
* Normalize a mail message and apply a default sender.
*
* Throws when the message has no recipients.
*/
export function normalizeMailMessage(
message: SendMailOptions,
options: { defaultFrom?: MailAddress } = {},
): NormalizedMailMessage {
const to = normalizeMailAddressList(message.to) ?? [];
if (to.length === 0) {
throw new MailDeliveryError({
message: "Cannot send email without at least one recipient.",
});
}
return {
...message,
to,
from: message.from ?? options.defaultFrom,
cc: normalizeOptionalMailAddressList(message.cc),
bcc: normalizeOptionalMailAddressList(message.bcc),
replyTo: normalizeOptionalMailAddressList(message.replyTo),
text: "text" in message ? message.text : undefined,
html: "html" in message ? message.html : undefined,
};
}
/**
* Create an in-memory mailer for tests, local development, and examples.
*
* The memory mailer does not send real email or validate address syntax.
*/
export function createMemoryMailer(
options: CreateMemoryMailerOptions = {},
): MemoryMailerPort {
return createMemoryMailerInternal(options);
}
function createMemoryMailerInternal(
options: CreateMemoryMailerOptions,
onDelivery?: (delivery: MemoryMailDelivery, durationMs: number) => void,
): MemoryMailerPort {
const deliveries: MemoryMailDelivery[] = [];
const now = options.now ?? (() => new Date());
const id = options.id ?? (() => crypto.randomUUID());
return {
get deliveries() {
return deliveries;
},
async send(message) {
const startedAt = Date.now();
const delivery: MemoryMailDelivery = {
id: id(),
message: normalizeMailMessage(message, {
defaultFrom: options.defaultFrom,
}),
sentAt: now(),
};
deliveries.push(delivery);
onDelivery?.(delivery, Date.now() - startedAt);
await options.onSend?.(delivery);
return {
id: delivery.id,
provider: "memory",
};
},
clear() {
deliveries.length = 0;
},
};
}
/**
* Options for the memory mailer provider.
*/
export interface MemoryMailerProviderOptions extends CreateMemoryMailerOptions {
/**
* Provider name. Defaults to "memory-mailer".
*/
name?: string;
}
/**
* Ports contributed by the memory mailer provider.
*/
export interface MemoryMailerProviderPorts {
/**
* Beignet mailer port.
*/
mailer: MailerPort;
}
/**
* Create a provider that contributes an in-memory mailer.
*
* Use it as the dev-default `mailer` port in `server/providers.ts` until a
* real mail provider such as Resend or SMTP takes over. Deliveries are
* captured in memory and recorded as `mail.sent` devtools events through the
* `mail` watcher when an instrumentation port is installed.
*/
export function createMemoryMailerProvider(
options: MemoryMailerProviderOptions = {},
) {
const { name = "memory-mailer", onSend, ...mailerOptions } = options;
return createProvider({
name,
setup({ ports }) {
const instrumentation = createProviderInstrumentation(ports, {
providerName: name,
watcher: "mail",
});
const mailer: MailerPort = createMemoryMailerInternal(
{
...mailerOptions,
onSend,
},
(delivery, durationMs) => {
instrumentation.custom({
name: "mail.sent",
label: "Mail sent",
summary: delivery.message.subject,
details: {
to: delivery.message.to,
subject: delivery.message.subject,
id: delivery.id,
durationMs,
},
});
},
);
return {
ports: {
mailer,
} satisfies MemoryMailerProviderPorts,
};
},
});
}