@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 +21 -0
- package/README.md +77 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/layout.d.ts +30 -0
- package/dist/layout.js +46 -0
- package/dist/send.d.ts +20 -0
- package/dist/send.js +26 -0
- package/dist/templates/index.d.ts +12 -0
- package/dist/templates/index.js +10 -0
- package/dist/templates/invitation.d.ts +16 -0
- package/dist/templates/invitation.js +20 -0
- package/dist/transport.d.ts +68 -0
- package/dist/transport.js +64 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +12 -0
- package/package.json +44 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|
package/dist/layout.d.ts
ADDED
|
@@ -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, '&')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|