@wtfalch/mailer 0.3.0 → 0.4.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @wtfalch/mailer
2
2
 
3
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.
4
+ over Lettermint, with a dev transport that writes to disk instead of sending.
5
5
 
6
6
  ## Install
7
7
 
@@ -28,9 +28,9 @@ await send({
28
28
  brand: { name: 'Acme' }, // optional
29
29
  },
30
30
  from: {
31
- fromAddress: 'no-reply@acme.wtfalch.dev',
31
+ fromAddress: 'no-reply@acme.example.com',
32
32
  fromName: 'Acme', // optional
33
- replyTo: 'support@acme.wtfalch.dev', // optional
33
+ replyTo: 'support@acme.example.com', // optional
34
34
  locale: 'nb', // optional, defaults to 'en' -- picks which language's copy renders
35
35
  },
36
36
  attachments: [ // optional
@@ -45,9 +45,9 @@ await send({
45
45
  missing transport fails loudly instead of silently no-op'ing or making a
46
46
  live call with no key.
47
47
 
48
- A held key: however you resolve `apiKey` for `createResendTransport`,
48
+ A held key: however you resolve `apiKey` for `createLettermintTransport`,
49
49
  mailer never fetches or stores it. In the estate, the caller resolves the
50
- org's Resend key as a held key from `@wtfalch/keys` and passes it in on
50
+ org's Lettermint key as a held key from `@wtfalch/keys` and passes it in on
51
51
  every call.
52
52
 
53
53
  ### Locale
@@ -60,22 +60,14 @@ already uses (`WORDS[sender.locale] ?? WORDS.en`).
60
60
 
61
61
  ### Transports
62
62
 
63
- A held key: however you resolve `apiKey` for either transport below, mailer
63
+ A held key: however you resolve `apiKey` for the transport below, mailer
64
64
  never fetches or stores it. In the estate, the caller resolves the org's
65
- Resend or Lettermint key as a held key from `@wtfalch/keys` and passes it
65
+ Lettermint key as a held key from `@wtfalch/keys` and passes it
66
66
  in on every call.
67
67
 
68
68
  - `createDevTransport({ dir })` -- writes each message to
69
69
  `<dir>/<timestamp>-<id>.json` (default `dir`: `.mailer/dev`). Never
70
70
  opens a network connection.
71
- - `createResendTransport({ apiKey })` -- sends over Resend. Needs either
72
- `apiKey` (a plain string -- resolve it yourself, e.g. from
73
- `@wtfalch/keys`) or `client`, a minimal structural `ResendLike` for
74
- injecting a fake in tests. `resend` is an optional peer dependency
75
- (`^4.1.0`) -- install it yourself if you use this transport with `apiKey`;
76
- not needed if you always inject `client`, or if you only use
77
- `createLettermintTransport` below. Attachments passed to `send()` are
78
- forwarded as Resend's own `{ filename, content, content_type }` shape.
79
71
  - `createLettermintTransport({ apiKey })` -- sends over Lettermint's
80
72
  Sending API (EU-hosted). Needs either `apiKey` (the org's Lettermint
81
73
  Project API token, `lm_...` -- resolve it yourself, e.g. from
@@ -126,7 +118,7 @@ await send({
126
118
  template: 'verify',
127
119
  to: 'person@example.com',
128
120
  data: { productName: 'Acme', url: 'https://app.example.com/verify/abc', code: '482913' },
129
- from: { fromAddress: 'no-reply@acme.wtfalch.dev', locale: 'nb' },
121
+ from: { fromAddress: 'no-reply@acme.example.com', locale: 'nb' },
130
122
  });
131
123
  ```
132
124
 
@@ -142,5 +134,5 @@ From `packages/mailer`:
142
134
  pnpm test
143
135
  ```
144
136
 
145
- Tests run against the dev transport and a fake `ResendLike` client --
137
+ Tests run against the dev transport and a fake `LettermintLike` client --
146
138
  never a live key, never the network.
@@ -7,7 +7,7 @@ function isControlCharCode(code) {
7
7
  * True if `value` contains a CR, LF or any other control character. Checked
8
8
  * by character code rather than a regex, since a header-bound field is
9
9
  * built in part from caller-supplied strings (an inviter's name, an org's
10
- * name) and this package must not depend on Resend's own defence against
10
+ * name) and this package must not depend on the mail provider's own defence against
11
11
  * header injection to reject them (see issue #3).
12
12
  */
13
13
  function hasControlCharacter(value) {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type { Attachment, Brand, Locale, MailMessage, SenderIdentity, SendResult, } from './types.js';
2
2
  export { DEFAULT_LOCALE, MailerConfigError } from './types.js';
3
- export type { DevTransportOptions, LettermintAttachment, LettermintFetchLike, LettermintLike, LettermintSendPayload, LettermintTransportOptions, OutgoingMail, ResendLike, ResendTransportOptions, Transport, } from './transport.js';
4
- export { createDevTransport, createLettermintTransport, createResendTransport, defaultTransport, LettermintDomainRefusedError, } from './transport.js';
3
+ export type { DevTransportOptions, LettermintAttachment, LettermintFetchLike, LettermintLike, LettermintSendPayload, LettermintTransportOptions, OutgoingMail, Transport, } from './transport.js';
4
+ export { createDevTransport, createLettermintTransport, defaultTransport, LettermintDomainRefusedError, } from './transport.js';
5
5
  export type { SendOptions } from './send.js';
6
6
  export { send } from './send.js';
7
7
  export type { TemplateData, TemplateName } from './templates/index.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { DEFAULT_LOCALE, MailerConfigError } from './types.js';
2
- export { createDevTransport, createLettermintTransport, createResendTransport, defaultTransport, LettermintDomainRefusedError, } from './transport.js';
2
+ export { createDevTransport, createLettermintTransport, defaultTransport, LettermintDomainRefusedError, } from './transport.js';
3
3
  export { send } from './send.js';
4
4
  export { templates } from './templates/index.js';
5
5
  export { renderInvitation } from './templates/invitation.js';
package/dist/layout.d.ts CHANGED
@@ -23,7 +23,7 @@ export interface LayoutOptions {
23
23
  * given, except for `brand.name`, which it escapes itself.
24
24
  *
25
25
  * `subject` is checked for CR/LF and other control characters before it is
26
- * returned, since it is not HTML-escaped and reaches Resend as a header
26
+ * returned, since it is not HTML-escaped and reaches the mail provider as a header
27
27
  * value -- see `header-safe.ts`.
28
28
  */
29
29
  export declare function layout(options: LayoutOptions): MailMessage;
package/dist/layout.js CHANGED
@@ -20,7 +20,7 @@ const BRAND_STYLE = 'margin:0 0 20px;font-size:13px;color:#545b66;text-transform
20
20
  * given, except for `brand.name`, which it escapes itself.
21
21
  *
22
22
  * `subject` is checked for CR/LF and other control characters before it is
23
- * returned, since it is not HTML-escaped and reaches Resend as a header
23
+ * returned, since it is not HTML-escaped and reaches the mail provider as a header
24
24
  * value -- see `header-safe.ts`.
25
25
  */
26
26
  export function layout(options) {
@@ -2,7 +2,7 @@ import { type Attachment, type MailMessage, type SendResult } from './types.js';
2
2
  /** A rendered message, addressed and ready for a transport. */
3
3
  export interface OutgoingMail extends MailMessage {
4
4
  readonly to: string;
5
- /** Already formatted, e.g. `"Acme <no-reply@acme.wtfalch.dev>"` or a bare address. */
5
+ /** Already formatted, e.g. `"Acme <no-reply@acme.example.com>"` or a bare address. */
6
6
  readonly from: string;
7
7
  readonly replyTo?: string;
8
8
  readonly attachments?: readonly Attachment[];
@@ -16,63 +16,10 @@ export interface DevTransportOptions {
16
16
  }
17
17
  /**
18
18
  * Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
19
- * it. Never opens a network connection -- the gap the estate had nowhere:
20
- * `outbox.ts` and `queue.ts` both call `new Resend()` unconditionally, so
21
- * nothing that sends mail could be tested without a live key.
19
+ * it. Never opens a network connection, so nothing that sends mail needs
20
+ * a live key to be tested.
22
21
  */
23
22
  export declare function createDevTransport(options?: DevTransportOptions): Transport;
24
- /**
25
- * The minimal shape this package calls on a Resend client: `emails.send`
26
- * only, with the fields mailer actually sets. The real `Resend` class
27
- * satisfies this structurally, and so does a fake with no network access,
28
- * which is what this package's own tests inject (never a real key, never a
29
- * real send -- see `transport.test.ts`).
30
- */
31
- export interface ResendLike {
32
- emails: {
33
- send(payload: {
34
- from: string;
35
- to: string;
36
- subject: string;
37
- html: string;
38
- text: string;
39
- reply_to?: string;
40
- /** Resend's own field names: `content` is base64 (or a Buffer), `content_type` is
41
- * optional and derived from `filename` when absent --
42
- * https://resend.com/docs/api-reference/emails/send-email. */
43
- attachments?: Array<{
44
- filename: string;
45
- content: string;
46
- content_type?: string;
47
- }>;
48
- }): Promise<{
49
- data: {
50
- id: string;
51
- } | null;
52
- error: {
53
- message: string;
54
- } | null;
55
- }>;
56
- };
57
- }
58
- export interface ResendTransportOptions {
59
- /** The Resend API key. The caller resolves this from `@wtfalch/keys`; mailer never fetches or stores it. */
60
- readonly apiKey?: string;
61
- /** A client to use instead of constructing one from `apiKey` -- how tests inject a fake. */
62
- readonly client?: ResendLike;
63
- }
64
- /**
65
- * Sends over Resend. The one place in the estate that imports the `resend`
66
- * package (README: "This package is the one place that knows about
67
- * Resend") -- and, since [0006](../docs/adr/0006-lettermint-transport-resend-optional-peer.md),
68
- * it imports it lazily: `resend` is now an optional peer, not a runtime
69
- * dependency, so a consumer that only ever passes `client` (every test in
70
- * this repo, and any caller that only sends over Lettermint) never needs
71
- * the package installed at all. The `import('resend')` below runs at most
72
- * once per transport, on the first `send()` call, only when no `client` was
73
- * given.
74
- */
75
- export declare function createResendTransport(options: ResendTransportOptions): Transport;
76
23
  /**
77
24
  * The minimal shape this package calls on `fetch` to speak Lettermint's
78
25
  * Sending API directly -- the same no-SDK, injectable-`fetch` posture
@@ -93,7 +40,7 @@ export interface LettermintAttachment {
93
40
  readonly filename: string;
94
41
  /**
95
42
  * Snake-case, matching the Sending API's own wire field exactly --
96
- * unlike `ResendLike`, `LettermintSendPayload` has no translation step
43
+ * `LettermintSendPayload` has no translation step
97
44
  * before `JSON.stringify` (`createLettermintClient`'s `send`), so this
98
45
  * interface's own field names ARE the request body's JSON keys.
99
46
  */
@@ -121,7 +68,7 @@ export interface LettermintSendPayload {
121
68
  }
122
69
  /**
123
70
  * The minimal shape this package calls on a Lettermint client: `send`
124
- * only, the same structural-injection pattern `ResendLike` uses. The
71
+ * only, a structural interface so a test can inject a fake. The
125
72
  * default client (built from `apiKey`) satisfies this over plain `fetch`;
126
73
  * a fake with no network access is what this package's own tests inject.
127
74
  */
@@ -159,8 +106,8 @@ export declare class LettermintDomainRefusedError extends Error {
159
106
  constructor(message: string);
160
107
  }
161
108
  /**
162
- * Sends over Lettermint (EU, GDPR -- William's 2026-09-28 choice over
163
- * Resend, see [0006](../docs/adr/0006-lettermint-transport-resend-optional-peer.md)),
109
+ * Sends over Lettermint (EU, GDPR; see
110
+ * [0006](../docs/adr/0006-lettermint-transport.md)),
164
111
  * via plain `fetch` against Lettermint's Sending API. Base URL, auth header
165
112
  * and request/response shapes:
166
113
  * https://lettermint.co/docs/api-reference/sending/0.0.1/lettermint-sending-openapi.json
@@ -177,7 +124,7 @@ export declare function createLettermintTransport(options: LettermintTransportOp
177
124
  /**
178
125
  * `dev`, unconditionally, outside `NODE_ENV=production`. In production
179
126
  * there is no default: `send()` must be given an explicit transport,
180
- * because mailer never holds a Resend API key of its own to fall back to.
127
+ * because mailer never holds a mail API key of its own to fall back to.
181
128
  * Calling `send()` in production with no transport is a configuration bug,
182
129
  * not a silent no-op and not an accidental live send.
183
130
  */
package/dist/transport.js CHANGED
@@ -4,9 +4,8 @@ import { join } from 'node:path';
4
4
  import { MailerConfigError } from './types.js';
5
5
  /**
6
6
  * Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
7
- * it. Never opens a network connection -- the gap the estate had nowhere:
8
- * `outbox.ts` and `queue.ts` both call `new Resend()` unconditionally, so
9
- * nothing that sends mail could be tested without a live key.
7
+ * it. Never opens a network connection, so nothing that sends mail needs
8
+ * a live key to be tested.
10
9
  */
11
10
  export function createDevTransport(options = {}) {
12
11
  const dir = options.dir ?? '.mailer/dev';
@@ -20,56 +19,6 @@ export function createDevTransport(options = {}) {
20
19
  },
21
20
  };
22
21
  }
23
- /**
24
- * Sends over Resend. The one place in the estate that imports the `resend`
25
- * package (README: "This package is the one place that knows about
26
- * Resend") -- and, since [0006](../docs/adr/0006-lettermint-transport-resend-optional-peer.md),
27
- * it imports it lazily: `resend` is now an optional peer, not a runtime
28
- * dependency, so a consumer that only ever passes `client` (every test in
29
- * this repo, and any caller that only sends over Lettermint) never needs
30
- * the package installed at all. The `import('resend')` below runs at most
31
- * once per transport, on the first `send()` call, only when no `client` was
32
- * given.
33
- */
34
- export function createResendTransport(options) {
35
- if (!options.client && !options.apiKey) {
36
- throw new MailerConfigError('createResendTransport needs either "apiKey" or "client".');
37
- }
38
- let resolvedClient;
39
- function resolveClient() {
40
- if (options.client)
41
- return Promise.resolve(options.client);
42
- resolvedClient ??= import('resend').then(({ Resend }) => new Resend(options.apiKey));
43
- return resolvedClient;
44
- }
45
- return {
46
- async send(message) {
47
- const client = await resolveClient();
48
- const { data, error } = await client.emails.send({
49
- from: message.from,
50
- to: message.to,
51
- subject: message.subject,
52
- html: message.html,
53
- text: message.text,
54
- ...(message.replyTo ? { reply_to: message.replyTo } : {}),
55
- ...(message.attachments
56
- ? {
57
- attachments: message.attachments.map((attachment) => ({
58
- filename: attachment.filename,
59
- content: attachment.content,
60
- ...(attachment.contentType ? { content_type: attachment.contentType } : {}),
61
- })),
62
- }
63
- : {}),
64
- });
65
- if (error)
66
- throw new Error(`mailer: resend refused the message: ${error.message}`);
67
- if (!data)
68
- throw new Error('mailer: resend returned no data and no error');
69
- return { id: data.id, transport: 'resend' };
70
- },
71
- };
72
- }
73
22
  /**
74
23
  * Thrown when Lettermint refuses to send because the `from` domain is not
75
24
  * one the sending project may use -- either unverified, or verified but
@@ -128,8 +77,8 @@ function createLettermintClient(apiKey, fetchImpl) {
128
77
  };
129
78
  }
130
79
  /**
131
- * Sends over Lettermint (EU, GDPR -- William's 2026-09-28 choice over
132
- * Resend, see [0006](../docs/adr/0006-lettermint-transport-resend-optional-peer.md)),
80
+ * Sends over Lettermint (EU, GDPR; see
81
+ * [0006](../docs/adr/0006-lettermint-transport.md)),
133
82
  * via plain `fetch` against Lettermint's Sending API. Base URL, auth header
134
83
  * and request/response shapes:
135
84
  * https://lettermint.co/docs/api-reference/sending/0.0.1/lettermint-sending-openapi.json
@@ -174,13 +123,13 @@ export function createLettermintTransport(options) {
174
123
  /**
175
124
  * `dev`, unconditionally, outside `NODE_ENV=production`. In production
176
125
  * there is no default: `send()` must be given an explicit transport,
177
- * because mailer never holds a Resend API key of its own to fall back to.
126
+ * because mailer never holds a mail API key of its own to fall back to.
178
127
  * Calling `send()` in production with no transport is a configuration bug,
179
128
  * not a silent no-op and not an accidental live send.
180
129
  */
181
130
  export function defaultTransport() {
182
131
  if (process.env.NODE_ENV === 'production') {
183
- throw new MailerConfigError('send() needs an explicit transport in production: pass { transport: createResendTransport({ apiKey }) }.');
132
+ throw new MailerConfigError('send() needs an explicit transport in production: pass { transport: createLettermintTransport({ apiKey }) }.');
184
133
  }
185
134
  return createDevTransport();
186
135
  }
package/dist/types.d.ts CHANGED
@@ -42,10 +42,8 @@ export interface SenderIdentity {
42
42
  }
43
43
  /**
44
44
  * One file attached to a send. `content` is base64-encoded bytes -- the
45
- * convention both transports' real APIs use (Resend:
46
- * https://resend.com/docs/api-reference/emails/send-email, "Content of an
47
- * attached file, passed as a buffer or Base64 string"; Lettermint's Sending
48
- * API OpenAPI spec, `attachments[].content`, and its official SDKs, which
45
+ * convention the Lettermint Sending API uses (its
46
+ * OpenAPI spec, `attachments[].content`, and its official SDKs, which
49
47
  * all base64-encode before setting it).
50
48
  */
51
49
  export interface Attachment {
@@ -62,11 +60,11 @@ export interface MailMessage {
62
60
  /** What a transport returns once it has accepted a message. */
63
61
  export interface SendResult {
64
62
  readonly id: string;
65
- readonly transport: 'resend' | 'dev' | 'lettermint';
63
+ readonly transport: 'dev' | 'lettermint';
66
64
  }
67
65
  /**
68
66
  * `send()` has no usable default (no transport given, and either
69
- * production with no explicit transport, or a Resend transport built with
67
+ * production with no explicit transport, or a transport built with
70
68
  * neither `apiKey` nor `client`). Thrown rather than a silent no-op or an
71
69
  * accidental network call.
72
70
  */
package/dist/types.js CHANGED
@@ -2,7 +2,7 @@
2
2
  export const DEFAULT_LOCALE = 'en';
3
3
  /**
4
4
  * `send()` has no usable default (no transport given, and either
5
- * production with no explicit transport, or a Resend transport built with
5
+ * production with no explicit transport, or a transport built with
6
6
  * neither `apiKey` nor `client`). Thrown rather than a silent no-op or an
7
7
  * accidental network call.
8
8
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wtfalch/mailer",
3
- "version": "0.3.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.",
3
+ "version": "0.4.0",
4
+ "description": "One send(template, to, data) call for the estate's transactional email, over Lettermint, with a dev transport that writes to disk instead of sending.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/wtfalch/mailer",
@@ -33,17 +33,8 @@
33
33
  "dependencies": {
34
34
  "@wtfalch/utils": "0.1.0"
35
35
  },
36
- "peerDependencies": {
37
- "resend": "^4.1.0"
38
- },
39
- "peerDependenciesMeta": {
40
- "resend": {
41
- "optional": true
42
- }
43
- },
44
36
  "devDependencies": {
45
37
  "@types/node": "^22",
46
- "resend": "^4.1.0",
47
38
  "typescript": "^5.9.0",
48
39
  "vitest": "^4.1.6"
49
40
  }