@wtfalch/mailer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @wtfalch/mailer
2
+
3
+ One `send(template, to, data)` call for the estate's transactional email,
4
+ over Resend, with a dev transport that writes to disk instead of sending.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ pnpm add @wtfalch/mailer
10
+ ```
11
+
12
+ ## Use
13
+
14
+ Call `send()` with a template name, the recipient, that template's data,
15
+ and who the message is from:
16
+
17
+ ```ts
18
+ import { createDevTransport, send } from '@wtfalch/mailer';
19
+
20
+ await send({
21
+ template: 'invitation',
22
+ to: 'person@example.com',
23
+ data: {
24
+ organisationName: 'Acme',
25
+ inviterName: 'Jordan',
26
+ acceptUrl: 'https://app.example.com/invite/abc123',
27
+ roleName: 'Editor', // optional
28
+ brand: { name: 'Acme' }, // optional
29
+ },
30
+ from: {
31
+ fromAddress: 'no-reply@acme.wtfalch.dev',
32
+ fromName: 'Acme', // optional
33
+ replyTo: 'support@acme.wtfalch.dev', // optional
34
+ },
35
+ transport: createDevTransport(), // writes to .mailer/dev instead of sending
36
+ });
37
+ ```
38
+
39
+ `transport` is optional. Left out, `send()` uses the dev transport outside
40
+ `NODE_ENV=production` and throws `MailerConfigError` in production -- a
41
+ missing transport fails loudly instead of silently no-op'ing or making a
42
+ live call with no key.
43
+
44
+ ### Transports
45
+
46
+ - `createDevTransport({ dir })` -- writes each message to
47
+ `<dir>/<timestamp>-<id>.json` (default `dir`: `.mailer/dev`). Never
48
+ opens a network connection.
49
+ - `createResendTransport({ apiKey })` -- sends over Resend. Needs either
50
+ `apiKey` (a plain string -- resolve it yourself, e.g. from
51
+ `@wtfalch/keys`) or `client`, a minimal structural `ResendLike` for
52
+ injecting a fake in tests.
53
+
54
+ ### Templates
55
+
56
+ `invitation` is the only template shipped so far:
57
+
58
+ ```ts
59
+ interface InvitationData {
60
+ organisationName: string;
61
+ inviterName: string;
62
+ acceptUrl: string;
63
+ roleName?: string;
64
+ brand?: { name: string; logoUrl?: string; primaryColor?: string };
65
+ }
66
+ ```
67
+
68
+ ## Tests
69
+
70
+ From `packages/mailer`:
71
+
72
+ ```sh
73
+ pnpm test
74
+ ```
75
+
76
+ Tests run against the dev transport and a fake `ResendLike` client --
77
+ never a live key, never the network.
@@ -0,0 +1,10 @@
1
+ export type { Brand, MailMessage, SenderIdentity, SendResult } from './types.js';
2
+ export { MailerConfigError } from './types.js';
3
+ export type { DevTransportOptions, OutgoingMail, ResendLike, ResendTransportOptions, Transport, } from './transport.js';
4
+ export { createDevTransport, createResendTransport, defaultTransport } from './transport.js';
5
+ export type { SendOptions } from './send.js';
6
+ export { send } from './send.js';
7
+ export type { TemplateData, TemplateName } from './templates/index.js';
8
+ export { templates } from './templates/index.js';
9
+ export type { InvitationData } from './templates/invitation.js';
10
+ export { renderInvitation } from './templates/invitation.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { MailerConfigError } from './types.js';
2
+ export { createDevTransport, createResendTransport, defaultTransport } from './transport.js';
3
+ export { send } from './send.js';
4
+ export { templates } from './templates/index.js';
5
+ export { renderInvitation } from './templates/invitation.js';
@@ -0,0 +1,30 @@
1
+ import type { Brand, MailMessage } from './types.js';
2
+ /**
3
+ * The one rule. Ampersand first, or it double-escapes everything after it.
4
+ * Same rule and reason as app-template's `src/lib/marketing/render.ts`.
5
+ */
6
+ export declare function escapeHtml(value: string): string;
7
+ export interface LayoutOptions {
8
+ readonly subject: string;
9
+ /** Already-escaped HTML for the message body. */
10
+ readonly bodyHtml: string;
11
+ readonly bodyText: string;
12
+ readonly brand?: Brand;
13
+ }
14
+ /**
15
+ * Every template's shared wrapper.
16
+ *
17
+ * **Inline styles only, no `<style>` block.** A mail client will not load a
18
+ * stylesheet and several ignore a `<style>` tag outright -- the same reason
19
+ * app-template's hand-rolled renderer gives for skipping both. No template
20
+ * engine either: the whole output is a handful of tags with inline styles,
21
+ * a library to produce it would be more surface than the thing it produces.
22
+ *
23
+ * **A text part is always produced.** A message with no plain alternative
24
+ * scores worse with every spam filter there is.
25
+ *
26
+ * Every template is responsible for escaping its own interpolated values
27
+ * with `escapeHtml` before calling this -- the layout only wraps what it is
28
+ * given, except for `brand.name`, which it escapes itself.
29
+ */
30
+ export declare function layout(options: LayoutOptions): MailMessage;
package/dist/layout.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The one rule. Ampersand first, or it double-escapes everything after it.
3
+ * Same rule and reason as app-template's `src/lib/marketing/render.ts`.
4
+ */
5
+ export function escapeHtml(value) {
6
+ return value
7
+ .replace(/&/g, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;');
11
+ }
12
+ const BODY_STYLE = 'margin:0;padding:24px;background:#f4f6f8;font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;color:#15181d;line-height:1.6';
13
+ const CARD_STYLE = 'max-width:600px;margin:0 auto;background:#ffffff;padding:28px;border-radius:4px';
14
+ const BRAND_STYLE = 'margin:0 0 20px;font-size:13px;color:#545b66;text-transform:uppercase;letter-spacing:0.04em';
15
+ /**
16
+ * Every template's shared wrapper.
17
+ *
18
+ * **Inline styles only, no `<style>` block.** A mail client will not load a
19
+ * stylesheet and several ignore a `<style>` tag outright -- the same reason
20
+ * app-template's hand-rolled renderer gives for skipping both. No template
21
+ * engine either: the whole output is a handful of tags with inline styles,
22
+ * a library to produce it would be more surface than the thing it produces.
23
+ *
24
+ * **A text part is always produced.** A message with no plain alternative
25
+ * scores worse with every spam filter there is.
26
+ *
27
+ * Every template is responsible for escaping its own interpolated values
28
+ * with `escapeHtml` before calling this -- the layout only wraps what it is
29
+ * given, except for `brand.name`, which it escapes itself.
30
+ */
31
+ export function layout(options) {
32
+ const header = options.brand?.name
33
+ ? `<p style="${BRAND_STYLE}">${escapeHtml(options.brand.name)}</p>`
34
+ : '';
35
+ const html = [
36
+ '<!doctype html><html><body style="',
37
+ BODY_STYLE,
38
+ '"><div style="',
39
+ CARD_STYLE,
40
+ '">',
41
+ header,
42
+ options.bodyHtml,
43
+ '</div></body></html>',
44
+ ].join('');
45
+ return { subject: options.subject, html, text: options.bodyText };
46
+ }
package/dist/send.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { type TemplateData, type TemplateName } from './templates/index.js';
2
+ import { type Transport } from './transport.js';
3
+ import type { SendResult, SenderIdentity } from './types.js';
4
+ export interface SendOptions<T extends TemplateName> {
5
+ readonly template: T;
6
+ readonly to: string;
7
+ readonly data: TemplateData<T>;
8
+ readonly from: SenderIdentity;
9
+ /** Defaults to the dev transport outside production; see `defaultTransport`. */
10
+ readonly transport?: Transport;
11
+ }
12
+ /**
13
+ * One template, rendered and handed to a transport.
14
+ *
15
+ * `transport` defaults to the dev transport outside production and throws
16
+ * `MailerConfigError` in production, so a missing config fails loudly
17
+ * instead of a silent no-op or an accidental live send (see
18
+ * `transport.ts`, `defaultTransport`).
19
+ */
20
+ export declare function send<T extends TemplateName>(options: SendOptions<T>): Promise<SendResult>;
package/dist/send.js ADDED
@@ -0,0 +1,26 @@
1
+ import { templates } from './templates/index.js';
2
+ import { defaultTransport } from './transport.js';
3
+ function formatFrom(identity) {
4
+ return identity.fromName
5
+ ? `${identity.fromName} <${identity.fromAddress}>`
6
+ : identity.fromAddress;
7
+ }
8
+ /**
9
+ * One template, rendered and handed to a transport.
10
+ *
11
+ * `transport` defaults to the dev transport outside production and throws
12
+ * `MailerConfigError` in production, so a missing config fails loudly
13
+ * instead of a silent no-op or an accidental live send (see
14
+ * `transport.ts`, `defaultTransport`).
15
+ */
16
+ export async function send(options) {
17
+ const render = templates[options.template];
18
+ const message = render(options.data);
19
+ const transport = options.transport ?? defaultTransport();
20
+ return transport.send({
21
+ ...message,
22
+ to: options.to,
23
+ from: formatFrom(options.from),
24
+ replyTo: options.from.replyTo,
25
+ });
26
+ }
@@ -0,0 +1,12 @@
1
+ import { type InvitationData, renderInvitation } from './invitation.js';
2
+ /**
3
+ * Every template this package ships, by name. Adding one is additive to
4
+ * this map and to the surface lock; v1 ships one, `invitation` -- the
5
+ * README names sign-in links, alerts and forum notifications as future
6
+ * templates, not built here (see .claude/campaign/2026-09-22-v1.md, Findings).
7
+ */
8
+ export declare const templates: {
9
+ readonly invitation: typeof renderInvitation;
10
+ };
11
+ export type TemplateName = keyof typeof templates;
12
+ export type TemplateData<T extends TemplateName> = T extends 'invitation' ? InvitationData : never;
@@ -0,0 +1,10 @@
1
+ import { renderInvitation } from './invitation.js';
2
+ /**
3
+ * Every template this package ships, by name. Adding one is additive to
4
+ * this map and to the surface lock; v1 ships one, `invitation` -- the
5
+ * README names sign-in links, alerts and forum notifications as future
6
+ * templates, not built here (see .claude/campaign/2026-09-22-v1.md, Findings).
7
+ */
8
+ export const templates = {
9
+ invitation: renderInvitation,
10
+ };
@@ -0,0 +1,16 @@
1
+ import type { Brand, MailMessage } from '../types.js';
2
+ /**
3
+ * The estate's real first consumer: app-template's invite action creates
4
+ * the invitation row and returns "Invitation sent" without ever sending
5
+ * anything (`src/app/org/[tenantId]/members/actions.ts:52-54`,
6
+ * `src/lib/authz/invitations.ts:246-247`). Wiring `send('invitation', ...)`
7
+ * in at that call site is what this template is for.
8
+ */
9
+ export interface InvitationData {
10
+ readonly organisationName: string;
11
+ readonly inviterName: string;
12
+ readonly acceptUrl: string;
13
+ readonly roleName?: string;
14
+ readonly brand?: Brand;
15
+ }
16
+ export declare function renderInvitation(data: InvitationData): MailMessage;
@@ -0,0 +1,20 @@
1
+ import { escapeHtml, layout } from '../layout.js';
2
+ export function renderInvitation(data) {
3
+ const subject = `${data.inviterName} invited you to ${data.organisationName}`;
4
+ const roleLine = data.roleName ? ` as ${escapeHtml(data.roleName)}` : '';
5
+ const bodyHtml = [
6
+ `<p style="margin:0 0 14px">${escapeHtml(data.inviterName)} invited you to join `,
7
+ `<strong>${escapeHtml(data.organisationName)}</strong>${roleLine}.</p>`,
8
+ `<p style="margin:0 0 20px"><a href="${escapeHtml(data.acceptUrl)}" `,
9
+ 'style="display:inline-block;background:#0e6c70;color:#ffffff;padding:10px 20px;',
10
+ 'border-radius:4px;text-decoration:none">Accept invitation</a></p>',
11
+ '<p style="margin:0;font-size:13px;color:#545b66">Or paste this link into your browser: ',
12
+ `${escapeHtml(data.acceptUrl)}</p>`,
13
+ ].join('');
14
+ const text = [
15
+ `${data.inviterName} invited you to join ${data.organisationName}${data.roleName ? ` as ${data.roleName}` : ''}.`,
16
+ '',
17
+ `Accept: ${data.acceptUrl}`,
18
+ ].join('\n');
19
+ return layout({ subject, bodyHtml, bodyText: text, brand: data.brand });
20
+ }
@@ -0,0 +1,68 @@
1
+ import { type MailMessage, type SendResult } from './types.js';
2
+ /** A rendered message, addressed and ready for a transport. */
3
+ export interface OutgoingMail extends MailMessage {
4
+ readonly to: string;
5
+ /** Already formatted, e.g. `"Acme <no-reply@acme.wtfalch.dev>"` or a bare address. */
6
+ readonly from: string;
7
+ readonly replyTo?: string;
8
+ }
9
+ export interface Transport {
10
+ send(message: OutgoingMail): Promise<SendResult>;
11
+ }
12
+ export interface DevTransportOptions {
13
+ /** Directory messages are written to. Default: `.mailer/dev`. */
14
+ readonly dir?: string;
15
+ }
16
+ /**
17
+ * Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
18
+ * it. Never opens a network connection -- the gap the estate had nowhere:
19
+ * `outbox.ts` and `queue.ts` both call `new Resend()` unconditionally, so
20
+ * nothing that sends mail could be tested without a live key.
21
+ */
22
+ export declare function createDevTransport(options?: DevTransportOptions): Transport;
23
+ /**
24
+ * The minimal shape this package calls on a Resend client: `emails.send`
25
+ * only, with the fields mailer actually sets. The real `Resend` class
26
+ * satisfies this structurally, and so does a fake with no network access,
27
+ * which is what this package's own tests inject (never a real key, never a
28
+ * real send -- see `transport.test.ts`).
29
+ */
30
+ export interface ResendLike {
31
+ emails: {
32
+ send(payload: {
33
+ from: string;
34
+ to: string;
35
+ subject: string;
36
+ html: string;
37
+ text: string;
38
+ reply_to?: string;
39
+ }): Promise<{
40
+ data: {
41
+ id: string;
42
+ } | null;
43
+ error: {
44
+ message: string;
45
+ } | null;
46
+ }>;
47
+ };
48
+ }
49
+ export interface ResendTransportOptions {
50
+ /** The Resend API key. The caller resolves this from `@wtfalch/keys`; mailer never fetches or stores it. */
51
+ readonly apiKey?: string;
52
+ /** A client to use instead of constructing one from `apiKey` -- how tests inject a fake. */
53
+ readonly client?: ResendLike;
54
+ }
55
+ /**
56
+ * Sends over Resend. The one place in the estate that imports the `resend`
57
+ * package (README: "This package is the one place that knows about
58
+ * Resend").
59
+ */
60
+ export declare function createResendTransport(options: ResendTransportOptions): Transport;
61
+ /**
62
+ * `dev`, unconditionally, outside `NODE_ENV=production`. In production
63
+ * there is no default: `send()` must be given an explicit transport,
64
+ * because mailer never holds a Resend API key of its own to fall back to.
65
+ * Calling `send()` in production with no transport is a configuration bug,
66
+ * not a silent no-op and not an accidental live send.
67
+ */
68
+ export declare function defaultTransport(): Transport;
@@ -0,0 +1,64 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { Resend } from 'resend';
5
+ import { MailerConfigError } from './types.js';
6
+ /**
7
+ * Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
8
+ * it. Never opens a network connection -- the gap the estate had nowhere:
9
+ * `outbox.ts` and `queue.ts` both call `new Resend()` unconditionally, so
10
+ * nothing that sends mail could be tested without a live key.
11
+ */
12
+ export function createDevTransport(options = {}) {
13
+ const dir = options.dir ?? '.mailer/dev';
14
+ return {
15
+ async send(message) {
16
+ mkdirSync(dir, { recursive: true });
17
+ const id = randomUUID();
18
+ const path = join(dir, `${Date.now()}-${id}.json`);
19
+ writeFileSync(path, JSON.stringify(message, null, 2));
20
+ return { id, transport: 'dev' };
21
+ },
22
+ };
23
+ }
24
+ /**
25
+ * Sends over Resend. The one place in the estate that imports the `resend`
26
+ * package (README: "This package is the one place that knows about
27
+ * Resend").
28
+ */
29
+ export function createResendTransport(options) {
30
+ if (!options.client && !options.apiKey) {
31
+ throw new MailerConfigError('createResendTransport needs either "apiKey" or "client".');
32
+ }
33
+ const client = options.client ?? new Resend(options.apiKey);
34
+ return {
35
+ async send(message) {
36
+ const { data, error } = await client.emails.send({
37
+ from: message.from,
38
+ to: message.to,
39
+ subject: message.subject,
40
+ html: message.html,
41
+ text: message.text,
42
+ ...(message.replyTo ? { reply_to: message.replyTo } : {}),
43
+ });
44
+ if (error)
45
+ throw new Error(`mailer: resend refused the message: ${error.message}`);
46
+ if (!data)
47
+ throw new Error('mailer: resend returned no data and no error');
48
+ return { id: data.id, transport: 'resend' };
49
+ },
50
+ };
51
+ }
52
+ /**
53
+ * `dev`, unconditionally, outside `NODE_ENV=production`. In production
54
+ * there is no default: `send()` must be given an explicit transport,
55
+ * because mailer never holds a Resend API key of its own to fall back to.
56
+ * Calling `send()` in production with no transport is a configuration bug,
57
+ * not a silent no-op and not an accidental live send.
58
+ */
59
+ export function defaultTransport() {
60
+ if (process.env.NODE_ENV === 'production') {
61
+ throw new MailerConfigError('send() needs an explicit transport in production: pass { transport: createResendTransport({ apiKey }) }.');
62
+ }
63
+ return createDevTransport();
64
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The org's brand for the shared layout: plain data, resolved by the
3
+ * caller (from `@wtfalch/design`, `@wtfalch/house`, or an org's own theme
4
+ * row) and passed in. No dependency on either package here -- v1 ships no
5
+ * second template that would need more than a name, so a deeper
6
+ * integration is a README follow-up, not built now.
7
+ */
8
+ export interface Brand {
9
+ readonly name: string;
10
+ readonly logoUrl?: string;
11
+ readonly primaryColor?: string;
12
+ }
13
+ /**
14
+ * Who a message is from, per organisation. The host resolves the domain,
15
+ * from address and reply-to (its own org settings, or a default); mailer
16
+ * never stores or looks one up -- it has no store (see README, "No store").
17
+ */
18
+ export interface SenderIdentity {
19
+ readonly fromAddress: string;
20
+ readonly fromName?: string;
21
+ readonly replyTo?: string;
22
+ }
23
+ /** One rendered message: subject, HTML, and a required plain-text part. */
24
+ export interface MailMessage {
25
+ readonly subject: string;
26
+ readonly html: string;
27
+ readonly text: string;
28
+ }
29
+ /** What a transport returns once it has accepted a message. */
30
+ export interface SendResult {
31
+ readonly id: string;
32
+ readonly transport: 'resend' | 'dev';
33
+ }
34
+ /**
35
+ * `send()` has no usable default (no transport given, and either
36
+ * production with no explicit transport, or a Resend transport built with
37
+ * neither `apiKey` nor `client`). Thrown rather than a silent no-op or an
38
+ * accidental network call.
39
+ */
40
+ export declare class MailerConfigError extends Error {
41
+ constructor(message: string);
42
+ }
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `send()` has no usable default (no transport given, and either
3
+ * production with no explicit transport, or a Resend transport built with
4
+ * neither `apiKey` nor `client`). Thrown rather than a silent no-op or an
5
+ * accidental network call.
6
+ */
7
+ export class MailerConfigError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'MailerConfigError';
11
+ }
12
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@wtfalch/mailer",
3
+ "version": "0.1.0",
4
+ "description": "One send(template, to, data) call for the estate's transactional email, over Resend, with a dev transport that writes to disk instead of sending.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/wtfalch/mailer",
8
+ "directory": "packages/mailer"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "sideEffects": false,
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "node": ">=22.0.0"
30
+ },
31
+ "dependencies": {
32
+ "resend": "^4.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22",
36
+ "typescript": "^5.9.0",
37
+ "vitest": "^4.1.6"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "vitest run"
43
+ }
44
+ }