@nxgt/mail 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 +341 -0
- package/dist/chunks/index-0f7kdb8k.js +114 -0
- package/dist/chunks/index-0f7kdb8k.js.map +11 -0
- package/dist/chunks/index-vq4e9n8f.js +39 -0
- package/dist/chunks/index-vq4e9n8f.js.map +10 -0
- package/dist/chunks/index-we4n5yfz.js +28 -0
- package/dist/chunks/index-we4n5yfz.js.map +10 -0
- package/dist/conformance/assert.d.ts +12 -0
- package/dist/conformance/assert.d.ts.map +1 -0
- package/dist/conformance/cases/failure.d.ts +4 -0
- package/dist/conformance/cases/failure.d.ts.map +1 -0
- package/dist/conformance/cases/index.d.ts +6 -0
- package/dist/conformance/cases/index.d.ts.map +1 -0
- package/dist/conformance/cases/send.d.ts +4 -0
- package/dist/conformance/cases/send.d.ts.map +1 -0
- package/dist/conformance/describe.d.ts +37 -0
- package/dist/conformance/describe.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +20 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +297 -0
- package/dist/conformance/index.js.map +16 -0
- package/dist/conformance/reference.d.ts +7 -0
- package/dist/conformance/reference.d.ts.map +1 -0
- package/dist/conformance/sample.d.ts +4 -0
- package/dist/conformance/sample.d.ts.map +1 -0
- package/dist/conformance/types.d.ts +74 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/errors.d.ts +68 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +9 -0
- package/dist/locale.d.ts +33 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/memory.d.ts +34 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/message.d.ts +23 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/renderer.d.ts +80 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +169 -0
- package/dist/renderer.js.map +10 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/locales.md +141 -0
- package/docs/guide/rendering.md +523 -0
- package/docs/guide/sending.md +325 -0
- package/docs/guide/testing.md +175 -0
- package/docs/guide/transports.md +451 -0
- package/docs/roadmap.md +112 -0
- package/docs/troubleshooting.md +1330 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Tsala
|
|
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,341 @@
|
|
|
1
|
+
# @nxgt/mail
|
|
2
|
+
|
|
3
|
+
The run-time side of transactional e-mail: the renderer that fills a Maizzle
|
|
4
|
+
build made with `@nxgt/mail-i18n`, the `Mailer` port a transport implements,
|
|
5
|
+
the shape it sends, the two errors it throws, a memory transport for tests, and
|
|
6
|
+
locale selection. **No dependency.**
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createMemoryMailer } from '@nxgt/mail';
|
|
10
|
+
import { createMailRenderer } from '@nxgt/mail/renderer';
|
|
11
|
+
|
|
12
|
+
const mails = createMailRenderer({ dir: 'dist' }); // the folder `maizzle build` wrote
|
|
13
|
+
const mailer = createMemoryMailer(); // in production, a transport's mailer — see below
|
|
14
|
+
|
|
15
|
+
const { messageId } = await mailer.send({
|
|
16
|
+
to: { name: 'Ada Lovelace', address: 'ada@example.com' },
|
|
17
|
+
from: 'noreply@example.com',
|
|
18
|
+
...mails.render('verify-email', { name: 'Ada', link: 'https://app.example.com/verify?token=abc' }),
|
|
19
|
+
}); // 'memory-1' — or it throws
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
In production, `mailer` comes from a transport:
|
|
23
|
+
[`@nxgt/mail-smtp`](https://github.com/softistx/nxgt-mail/tree/develop/packages/mail-smtp)
|
|
24
|
+
on your nodemailer, or
|
|
25
|
+
[`@nxgt/mail-resend`](https://github.com/softistx/nxgt-mail/tree/develop/packages/mail-resend)
|
|
26
|
+
over `fetch`.
|
|
27
|
+
|
|
28
|
+
> **0.x.** A minor version may still change the surface; the changelog says how.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
bun add @nxgt/mail
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
No runtime dependency. `typescript` (6) is a required peer. Your tsconfig
|
|
37
|
+
resolves as a bundler does (`"moduleResolution": "bundler"`): the declarations
|
|
38
|
+
import without extensions, so `nodenext` is not supported.
|
|
39
|
+
|
|
40
|
+
## Subpaths
|
|
41
|
+
|
|
42
|
+
| Import | What it holds |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `@nxgt/mail` | The port (`Mailer`, `MailMessage`, `Rendered`, `SentMail`, `Address`), the errors (`MailError`, `MailFailure`, `MailRefused`), `createMemoryMailer`, `pickLocale` and `parseAcceptLanguage`, and what a transport calls first: `checkMessage`, `recipientsOf`, `addressOf`. No Node built-in: it runs anywhere |
|
|
45
|
+
| `@nxgt/mail/renderer` | The renderer: `createMailRenderer`, `MailRenderer`, `MailRendererOptions`, `RenderOptions`, `MailVariables`, and the types that type it with a build's `MailEmails` (`MailEmailsOf`, `AnyMailEmails`, `RenderArguments`). Reads the build with `node:fs` |
|
|
46
|
+
| `@nxgt/mail/conformance` | **For transport authors**: `describeMailer`, its cases as data, `runMailerCase`, and the memory mailer's harness as a worked example |
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Rendering — `createMailRenderer`
|
|
51
|
+
|
|
52
|
+
Build the project with `maizzle build` and the `i18n()` plugin of
|
|
53
|
+
`@nxgt/mail-i18n`, deploy its output folder with your server, and create one
|
|
54
|
+
renderer at start-up. It reads `mail-manifest.json` and every built file once,
|
|
55
|
+
so a missing build fails there, not at the first send:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { type Mailer, pickLocale } from '@nxgt/mail';
|
|
59
|
+
import { createMailRenderer } from '@nxgt/mail/renderer';
|
|
60
|
+
import type { MailEmails } from './generated/mail'; // written by the build, committed
|
|
61
|
+
|
|
62
|
+
export const mails = createMailRenderer<MailEmails>({ dir: 'dist' }); // throws now if dist/ is missing
|
|
63
|
+
|
|
64
|
+
export async function sendVerification(
|
|
65
|
+
mailer: Mailer,
|
|
66
|
+
user: { email: string; name: string; locale: string | null },
|
|
67
|
+
link: string,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const locale = pickLocale(user.locale, mails.locales, 'en'); // 'fr-CA' renders 'fr'
|
|
70
|
+
await mailer.send({ to: user.email, ...mails.render('verify-email', { name: user.name, link }, { locale }) });
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each value is HTML-escaped in `html` and written as is in `text` and the
|
|
75
|
+
subject; line breaks in the subject become a space. A variable that starts an
|
|
76
|
+
`href` or a `src` must be an `http:`, `https:` or `mailto:` URL, or `render`
|
|
77
|
+
throws `MailRefused`. A missing or unknown variable, e-mail or locale throws an
|
|
78
|
+
`Error`.
|
|
79
|
+
|
|
80
|
+
`<MailEmails>` is optional. `@nxgt/mail-i18n` writes it after each build, in
|
|
81
|
+
`generated/mail.ts`, from the manifest; commit it. With it, the compiler
|
|
82
|
+
refuses what `render` would throw: an e-mail the build does not have, a
|
|
83
|
+
variable missing or unknown, and a number for a URL variable, as
|
|
84
|
+
`Argument of type '"verify-emial"' is not assignable to parameter of type
|
|
85
|
+
'"sign-in-code" | "verify-email"'`. Without it, any name and any
|
|
86
|
+
`MailVariables` compile, and the same mistakes throw at run time. See
|
|
87
|
+
[Rendering](docs/guide/rendering.md) for the options, typing the renderer,
|
|
88
|
+
the locale chosen through `getLanguage`, and every error.
|
|
89
|
+
|
|
90
|
+
### Sending — the port and `MailMessage`
|
|
91
|
+
|
|
92
|
+
A `MailMessage` is a rendered e-mail — `subject`, `html`, `text` — plus its
|
|
93
|
+
addresses. `Rendered` is what `mails.render(…)` answers, and any function
|
|
94
|
+
answering the same shape fits, so an e-mail can also be written by hand:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import type { Mailer, Rendered, SentMail } from '@nxgt/mail';
|
|
98
|
+
|
|
99
|
+
function passwordChanged(): Rendered {
|
|
100
|
+
return {
|
|
101
|
+
subject: 'Your password was changed',
|
|
102
|
+
html: '<p>Your password was changed. If it was not you, reset it now.</p>',
|
|
103
|
+
text: 'Your password was changed. If it was not you, reset it now.',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function notifyPasswordChanged(mailer: Mailer, to: string): Promise<SentMail> {
|
|
108
|
+
return mailer.send({
|
|
109
|
+
...passwordChanged(),
|
|
110
|
+
to,
|
|
111
|
+
replyTo: { name: 'Support', address: 'support@example.com' },
|
|
112
|
+
headers: { 'X-Entity-Ref-ID': 'password-changed' },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`SentMail` is `{ messageId: string | null }`: `null` when the transport gives no
|
|
118
|
+
id — an absence, not a failure. See [Sending](docs/guide/sending.md).
|
|
119
|
+
|
|
120
|
+
### Errors — switch on `code`
|
|
121
|
+
|
|
122
|
+
Both errors extend `MailError`, whose `code` is a union a `switch` exhausts.
|
|
123
|
+
`MailError` is abstract: catch it, but throw `MailFailure` or `MailRefused`.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { MailError, type MailErrorCode, type Mailer, type MailMessage } from '@nxgt/mail';
|
|
127
|
+
|
|
128
|
+
function statusOf(code: MailErrorCode): number {
|
|
129
|
+
switch (code) {
|
|
130
|
+
case 'MAIL_FAILED':
|
|
131
|
+
return 503; // nothing is known to have been sent: retry later, or say it failed
|
|
132
|
+
case 'MAIL_REFUSED':
|
|
133
|
+
return 422; // the e-mail itself is malformed: sending it again fails again
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function sendOrRespond(mailer: Mailer, message: MailMessage): Promise<Response> {
|
|
138
|
+
try {
|
|
139
|
+
await mailer.send(message);
|
|
140
|
+
return new Response(null, { status: 202 });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (!(error instanceof MailError)) throw error;
|
|
143
|
+
return Response.json({ code: error.code }, { status: statusOf(error.code) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`MailFailure` carries the transport's own error as `cause`. An error's `message`
|
|
149
|
+
names **where** the problem is, never the value: never an address, a subject or
|
|
150
|
+
a link. See [Sending — errors](docs/guide/sending.md#errors).
|
|
151
|
+
|
|
152
|
+
### Testing — the memory mailer
|
|
153
|
+
|
|
154
|
+
`createMemoryMailer()` refuses what every transport refuses, keeps an outbox,
|
|
155
|
+
and can be told to fail:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { expect, it } from 'bun:test';
|
|
159
|
+
import { createMemoryMailer, MailFailure } from '@nxgt/mail';
|
|
160
|
+
|
|
161
|
+
it('says so when the e-mail could not be sent', async () => {
|
|
162
|
+
const mailer = createMemoryMailer();
|
|
163
|
+
mailer.failNext(); // the next send rejects with MailFailure, as an outage would
|
|
164
|
+
|
|
165
|
+
const error = await mailer
|
|
166
|
+
.send({ to: 'ada@example.com', subject: 'Hi', html: '<p>Hi</p>', text: 'Hi' })
|
|
167
|
+
.then(() => null, (e: unknown) => e);
|
|
168
|
+
|
|
169
|
+
expect(error).toBeInstanceOf(MailFailure);
|
|
170
|
+
expect(mailer.sent).toEqual([]); // the outbox
|
|
171
|
+
expect(mailer.attempts).toBe(1); // nothing retried in secret
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
See [Testing](docs/guide/testing.md).
|
|
176
|
+
|
|
177
|
+
### Locales — `pickLocale` and `parseAcceptLanguage`
|
|
178
|
+
|
|
179
|
+
The locale of an e-mail is the **recipient's**: their stored preference first,
|
|
180
|
+
then, if the recipient is the visitor, their `Accept-Language`:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { parseAcceptLanguage, pickLocale } from '@nxgt/mail';
|
|
184
|
+
|
|
185
|
+
const locale = pickLocale(
|
|
186
|
+
['fr-CA', ...parseAcceptLanguage('de;q=0.9,en;q=0.8')],
|
|
187
|
+
['en', 'fr'],
|
|
188
|
+
'en',
|
|
189
|
+
); // 'fr' — typed 'en' | 'fr'
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`fr-CA` matches `fr`; nothing matching answers the fallback. See
|
|
193
|
+
[Locales](docs/guide/locales.md).
|
|
194
|
+
|
|
195
|
+
### Writing a transport — `checkMessage` and `describeMailer`
|
|
196
|
+
|
|
197
|
+
A transport calls `checkMessage` first, quotes or encodes a recipient's name
|
|
198
|
+
itself (a name is free text), throws the classes imported from its `@nxgt/mail`
|
|
199
|
+
peer, and passes the conformance suite:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { checkMessage, MailFailure, MailRefused, type Mailer, recipientsOf } from '@nxgt/mail';
|
|
203
|
+
|
|
204
|
+
export function createHttpMailer(endpoint: string, apiKey: string): Mailer {
|
|
205
|
+
return {
|
|
206
|
+
async send(message) {
|
|
207
|
+
checkMessage(message); // MailRefused, naming where, never the value
|
|
208
|
+
const response = await fetch(endpoint, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
|
211
|
+
body: JSON.stringify({ ...message, to: recipientsOf(message) }),
|
|
212
|
+
}).catch((cause: unknown) => {
|
|
213
|
+
throw new MailFailure('send: the provider could not be reached', { cause });
|
|
214
|
+
});
|
|
215
|
+
const cause = new Error(`the provider answered HTTP ${response.status}`);
|
|
216
|
+
if (response.status === 400 || response.status === 422) {
|
|
217
|
+
throw new MailRefused('send: the provider refused the message', { cause });
|
|
218
|
+
}
|
|
219
|
+
if (!response.ok) throw new MailFailure('send: the provider failed', { cause });
|
|
220
|
+
return { messageId: response.headers.get('x-message-id') }; // null when absent
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { describe, it } from 'bun:test';
|
|
228
|
+
import { describeMailer, referenceMailerHarness } from '@nxgt/mail/conformance';
|
|
229
|
+
|
|
230
|
+
describeMailer({
|
|
231
|
+
name: 'the memory mailer',
|
|
232
|
+
harness: referenceMailerHarness(), // yours: open() a fresh mailer, read back what it delivered
|
|
233
|
+
runner: { describe, it },
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
See [Writing a transport](docs/guide/transports.md) for the harness, faults and
|
|
238
|
+
skips.
|
|
239
|
+
|
|
240
|
+
## Traps
|
|
241
|
+
|
|
242
|
+
**A failure throws; never map it to "sent".** A mailer that could not hand an
|
|
243
|
+
e-mail over rejects with `MailFailure`; it never answers `false`, and it never
|
|
244
|
+
logs and resolves. A caller that reports a failed send as sent has told a user
|
|
245
|
+
to check an inbox that will stay empty. The conformance suite fails a transport
|
|
246
|
+
that breaks the rule.
|
|
247
|
+
|
|
248
|
+
**Deploy the build with the server, and point `dir` at it.** `dir` is read
|
|
249
|
+
from the working directory; resolve it from the module when the process may
|
|
250
|
+
start elsewhere: `fileURLToPath(new URL('../mails/dist', import.meta.url))`.
|
|
251
|
+
|
|
252
|
+
**The renderer needs a file system.** `@nxgt/mail/renderer` imports
|
|
253
|
+
`node:fs`: it runs on Node, Bun and Deno, not on an edge runtime without `fs`.
|
|
254
|
+
`@nxgt/mail` itself imports no Node built-in.
|
|
255
|
+
|
|
256
|
+
**Create the renderer once.** It reads the whole build when created; one per
|
|
257
|
+
request reads it every time, and a rebuild is only seen by a new renderer.
|
|
258
|
+
|
|
259
|
+
**`{ locale }` must be one of `mails.locales`, spelled the same.** `'fr-CA'`
|
|
260
|
+
throws where the build has `fr`; pass
|
|
261
|
+
`pickLocale(user.locale, mails.locales, 'en')`.
|
|
262
|
+
|
|
263
|
+
**A URL variable is refused unless it is `http:`, `https:` or `mailto:`.**
|
|
264
|
+
`render` throws `MailRefused` before anything is sent, so keep it inside the
|
|
265
|
+
`try` that handles `MailError`.
|
|
266
|
+
|
|
267
|
+
**A string address is only an address.** `'Ada <ada@example.com>'` is refused
|
|
268
|
+
with `MailRefused`; write `{ name: 'Ada', address: 'ada@example.com' }`.
|
|
269
|
+
|
|
270
|
+
**Never fire and forget a send.** `void mailer.send(message)` turns a failure
|
|
271
|
+
into an unhandled rejection and the user into someone waiting for an e-mail
|
|
272
|
+
that never comes. `await` it, or hand it to a queue that does.
|
|
273
|
+
|
|
274
|
+
**A custom header cannot set an address.** `headers: { Bcc: '…' }` would add
|
|
275
|
+
a recipient no check saw: `checkMessage` refuses `To`, `Cc`, `Bcc`, `From`,
|
|
276
|
+
`Sender`, `Reply-To`, `Return-Path`, `Subject`, `MIME-Version` and
|
|
277
|
+
`Content-*` among `headers`, in any case. Use `to`, `from` and `replyTo`.
|
|
278
|
+
|
|
279
|
+
**A refusal is not worth retrying; a failure may be.** `MAIL_REFUSED` fails
|
|
280
|
+
again unchanged. Nothing in this package retries a `MAIL_FAILED`: a retry is
|
|
281
|
+
your decision, made where you can see it.
|
|
282
|
+
|
|
283
|
+
**The locale is the recipient's, not the request's.** An administrator who
|
|
284
|
+
invites a user sends the invitation in the *user's* locale:
|
|
285
|
+
`pickLocale(invitee.locale, supported, fallback)`.
|
|
286
|
+
|
|
287
|
+
**A transport defines no error class.** It throws `MailFailure` and
|
|
288
|
+
`MailRefused` from its `@nxgt/mail` peer; its own copy fails `instanceof`, and
|
|
289
|
+
the conformance suite with it.
|
|
290
|
+
|
|
291
|
+
**Under `bun test`, pass `runner: { describe, it }` to `describeMailer`.** Bun
|
|
292
|
+
gives a test file `describe` and `it` as bare identifiers, not on `globalThis`.
|
|
293
|
+
|
|
294
|
+
## Type safety, counted
|
|
295
|
+
|
|
296
|
+
**17 plausible mistakes, 17 refused** at compile time, each measured by a
|
|
297
|
+
`@ts-expect-error` in
|
|
298
|
+
[`test/types/refusals.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/test/types/refusals.ts)
|
|
299
|
+
that fails the typecheck the moment it stops holding:
|
|
300
|
+
|
|
301
|
+
1. A `MailMessage` without `to`.
|
|
302
|
+
2. A `MailMessage` without a `text` part.
|
|
303
|
+
3. An address object without its `address`.
|
|
304
|
+
4. A `Mailer` without `send`.
|
|
305
|
+
5. A `Mailer` whose `send` answers a boolean.
|
|
306
|
+
6. A `SentMail` whose `messageId` is `undefined` rather than `null`.
|
|
307
|
+
7. A `pickLocale` fallback that is not one of the supported locales.
|
|
308
|
+
8. A `MailErrorCode` the union does not declare.
|
|
309
|
+
9. A bare `new MailError(…)` — it is abstract, so nothing throws an error that
|
|
310
|
+
passes a `code` check and fails `instanceof MailFailure`.
|
|
311
|
+
10. A `createMailRenderer` without `dir`, the build's output folder.
|
|
312
|
+
11. A `getLanguage` given as a locale rather than a function answering one.
|
|
313
|
+
12. A `render` variable that is neither a string nor a number (a `URL`).
|
|
314
|
+
|
|
315
|
+
With the renderer given the build's `MailEmails`
|
|
316
|
+
(`createMailRenderer<MailEmails>(…)`):
|
|
317
|
+
|
|
318
|
+
The name written as a literal and the variables at the call, as usual:
|
|
319
|
+
|
|
320
|
+
13. An e-mail the build does not have (`render('verify-emial', …)`).
|
|
321
|
+
14. A variable the e-mail does not take.
|
|
322
|
+
15. A variable the e-mail takes, left out.
|
|
323
|
+
16. The variables left out altogether, for an e-mail that takes some.
|
|
324
|
+
17. A number for a URL variable: a URL is a string.
|
|
325
|
+
|
|
326
|
+
The same file holds the calls that must keep compiling: a refusal that refuses
|
|
327
|
+
the correct call is a bug.
|
|
328
|
+
|
|
329
|
+
## Documentation
|
|
330
|
+
|
|
331
|
+
- [The guides](docs/README.md) — one page per area, with every option and error.
|
|
332
|
+
- [Troubleshooting](docs/troubleshooting.md) — an error message, its cause and
|
|
333
|
+
its fix.
|
|
334
|
+
- [Roadmap](docs/roadmap.md) — what is next, and what is deliberately not
|
|
335
|
+
planned.
|
|
336
|
+
- [Vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md)
|
|
337
|
+
— the words these pages use, defined once.
|
|
338
|
+
|
|
339
|
+
## Licence
|
|
340
|
+
|
|
341
|
+
MIT
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MailFailure2,
|
|
3
|
+
MailRefused2
|
|
4
|
+
} from "./index-we4n5yfz.js";
|
|
5
|
+
|
|
6
|
+
// src/message.ts
|
|
7
|
+
var LINE_BREAK = /[\r\n]/;
|
|
8
|
+
var ADDRESS = /^[^\s@<>,;:]+@[^\s@<>,;:]+$/;
|
|
9
|
+
var HEADER_NAME = /^[A-Za-z0-9-]+$/;
|
|
10
|
+
var RESERVED_HEADER = /^(?:to|cc|bcc|from|sender|reply-to|return-path|subject|mime-version|content-.*)$/i;
|
|
11
|
+
function recipientsOf2(message) {
|
|
12
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
13
|
+
return to.map(addressOf2);
|
|
14
|
+
}
|
|
15
|
+
function addressOf2(address) {
|
|
16
|
+
return typeof address === "string" ? address : address.address;
|
|
17
|
+
}
|
|
18
|
+
function checkAddress(address, where) {
|
|
19
|
+
if (typeof address === "string") {
|
|
20
|
+
if (!ADDRESS.test(address)) {
|
|
21
|
+
throw new MailRefused2(`send: ${where} is not an e-mail address`);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (typeof address !== "object" || address === null) {
|
|
26
|
+
throw new MailRefused2(`send: ${where} is not an e-mail address`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof address.address !== "string" || !ADDRESS.test(address.address)) {
|
|
29
|
+
throw new MailRefused2(`send: ${where}.address is not an e-mail address`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof address.name !== "string" || LINE_BREAK.test(address.name)) {
|
|
32
|
+
throw new MailRefused2(`send: ${where}.name must be a string without a line break`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function checkMessage2(message) {
|
|
36
|
+
if (typeof message !== "object" || message === null) {
|
|
37
|
+
throw new MailRefused2("send: the message must be an object");
|
|
38
|
+
}
|
|
39
|
+
if (message.to === undefined || message.to === null) {
|
|
40
|
+
throw new MailRefused2("send: to must hold at least one address");
|
|
41
|
+
}
|
|
42
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
43
|
+
if (to.length === 0) {
|
|
44
|
+
throw new MailRefused2("send: to must hold at least one address");
|
|
45
|
+
}
|
|
46
|
+
to.forEach((address, index) => {
|
|
47
|
+
checkAddress(address, Array.isArray(message.to) ? `to[${index}]` : "to");
|
|
48
|
+
});
|
|
49
|
+
if (message.from !== undefined)
|
|
50
|
+
checkAddress(message.from, "from");
|
|
51
|
+
if (message.replyTo !== undefined)
|
|
52
|
+
checkAddress(message.replyTo, "replyTo");
|
|
53
|
+
for (const part of ["subject", "html", "text"]) {
|
|
54
|
+
if (typeof message[part] !== "string") {
|
|
55
|
+
throw new MailRefused2(`send: ${part} must be a string`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (LINE_BREAK.test(message.subject)) {
|
|
59
|
+
throw new MailRefused2("send: subject must not hold a line break");
|
|
60
|
+
}
|
|
61
|
+
for (const [name, value] of Object.entries(message.headers ?? {})) {
|
|
62
|
+
if (!HEADER_NAME.test(name)) {
|
|
63
|
+
throw new MailRefused2("send: a header name must be letters, digits and hyphens");
|
|
64
|
+
}
|
|
65
|
+
if (RESERVED_HEADER.test(name)) {
|
|
66
|
+
throw new MailRefused2(`send: header ${name} is reserved — addresses, the subject and the MIME structure are never custom headers`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof value !== "string" || LINE_BREAK.test(value)) {
|
|
69
|
+
throw new MailRefused2(`send: header ${name} must be a string without a line break`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/memory.ts
|
|
75
|
+
function createMemoryMailer2() {
|
|
76
|
+
let sent = [];
|
|
77
|
+
let failures = [];
|
|
78
|
+
let attempts = 0;
|
|
79
|
+
let counter = 0;
|
|
80
|
+
return {
|
|
81
|
+
get sent() {
|
|
82
|
+
return sent.map((mail) => structuredClone(mail));
|
|
83
|
+
},
|
|
84
|
+
get attempts() {
|
|
85
|
+
return attempts;
|
|
86
|
+
},
|
|
87
|
+
failNext(error) {
|
|
88
|
+
failures.push(error ?? new MailFailure2("send: the memory mailer was told to fail this send", {
|
|
89
|
+
cause: new Error("memory mailer: failNext")
|
|
90
|
+
}));
|
|
91
|
+
},
|
|
92
|
+
clear() {
|
|
93
|
+
sent = [];
|
|
94
|
+
failures = [];
|
|
95
|
+
attempts = 0;
|
|
96
|
+
},
|
|
97
|
+
async send(message) {
|
|
98
|
+
checkMessage2(message);
|
|
99
|
+
attempts += 1;
|
|
100
|
+
const failure = failures.shift();
|
|
101
|
+
if (failure !== undefined)
|
|
102
|
+
throw failure;
|
|
103
|
+
counter += 1;
|
|
104
|
+
const messageId = `memory-${counter}`;
|
|
105
|
+
sent.push({ ...structuredClone(message), messageId });
|
|
106
|
+
return { messageId };
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { recipientsOf2, addressOf2, checkMessage2, createMemoryMailer2 };
|
|
112
|
+
|
|
113
|
+
//# debugId=44B7D62837EFA8BB64756E2164756E21
|
|
114
|
+
//# sourceMappingURL=index-0f7kdb8k.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/message.ts", "../src/memory.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { MailRefused } from './errors';\nimport type { Address, MailMessage } from './types';\n\nconst LINE_BREAK = /[\\r\\n]/;\n// Deliberately loose: one `@`, something on each side, and none of what an\n// address list parser reads as structure — whitespace, `<` `>` (a display\n// name), `,` `;` (a second address), `:` (a group). A provider that parses\n// the string then finds one mailbox, the one checked. Whether the mailbox\n// exists is the receiving server's question.\nconst ADDRESS = /^[^\\s@<>,;:]+@[^\\s@<>,;:]+$/;\nconst HEADER_NAME = /^[A-Za-z0-9-]+$/;\n// The headers a transport writes from the message: the addresses, the subject\n// and the MIME structure. Set through `headers`, a Bcc reaches an SMTP\n// envelope unchecked, and a Content-Type rewrites how the parts are read.\nconst RESERVED_HEADER =\n\t/^(?:to|cc|bcc|from|sender|reply-to|return-path|subject|mime-version|content-.*)$/i;\n\n/** Every recipient of a message, as bare addresses, in order. */\nexport function recipientsOf(message: MailMessage): string[] {\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\treturn to.map(addressOf);\n}\n\n/** The bare address of an {@link Address}. */\nexport function addressOf(address: Address): string {\n\treturn typeof address === 'string' ? address : address.address;\n}\n\n/** Refuses `address` unless it is an {@link Address}. `undefined` is refused too. */\nfunction checkAddress(address: Address | undefined, where: string): void {\n\tif (typeof address === 'string') {\n\t\tif (!ADDRESS.test(address)) {\n\t\t\tthrow new MailRefused(`send: ${where} is not an e-mail address`);\n\t\t}\n\t\treturn;\n\t}\n\tif (typeof address !== 'object' || address === null) {\n\t\tthrow new MailRefused(`send: ${where} is not an e-mail address`);\n\t}\n\tif (typeof address.address !== 'string' || !ADDRESS.test(address.address)) {\n\t\tthrow new MailRefused(`send: ${where}.address is not an e-mail address`);\n\t}\n\tif (typeof address.name !== 'string' || LINE_BREAK.test(address.name)) {\n\t\tthrow new MailRefused(\n\t\t\t`send: ${where}.name must be a string without a line break`,\n\t\t);\n\t}\n}\n\n/**\n * Refuses a message no transport should hand over, with a {@link MailRefused}\n * that names **where** the problem is and never the value.\n *\n * A transport calls it first thing in `send`, so the refusals are the same\n * whichever transport is wired. It checks:\n *\n * - at least one recipient, each one an address;\n * - `from` and `replyTo`, when present, are addresses;\n * - `subject`, `html` and `text` are strings, and `subject` holds no line\n * break — a line break in a subject is a header injection;\n * - every header name is letters, digits and hyphens, none names what the\n * transport writes from the message (`To`, `Cc`, `Bcc`, `From`, `Sender`,\n * `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in\n * any case), and no header value holds a line break.\n */\nexport function checkMessage(message: MailMessage): void {\n\tif (typeof message !== 'object' || message === null) {\n\t\tthrow new MailRefused('send: the message must be an object');\n\t}\n\tif (message.to === undefined || message.to === null) {\n\t\tthrow new MailRefused('send: to must hold at least one address');\n\t}\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\tif (to.length === 0) {\n\t\tthrow new MailRefused('send: to must hold at least one address');\n\t}\n\tto.forEach((address, index) => {\n\t\tcheckAddress(address, Array.isArray(message.to) ? `to[${index}]` : 'to');\n\t});\n\tif (message.from !== undefined) checkAddress(message.from, 'from');\n\tif (message.replyTo !== undefined) checkAddress(message.replyTo, 'replyTo');\n\n\tfor (const part of ['subject', 'html', 'text'] as const) {\n\t\tif (typeof message[part] !== 'string') {\n\t\t\tthrow new MailRefused(`send: ${part} must be a string`);\n\t\t}\n\t}\n\tif (LINE_BREAK.test(message.subject)) {\n\t\tthrow new MailRefused('send: subject must not hold a line break');\n\t}\n\n\tfor (const [name, value] of Object.entries(message.headers ?? {})) {\n\t\tif (!HEADER_NAME.test(name)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t'send: a header name must be letters, digits and hyphens',\n\t\t\t);\n\t\t}\n\t\tif (RESERVED_HEADER.test(name)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`send: header ${name} is reserved — addresses, the subject and the MIME structure are never custom headers`,\n\t\t\t);\n\t\t}\n\t\tif (typeof value !== 'string' || LINE_BREAK.test(value)) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`send: header ${name} must be a string without a line break`,\n\t\t\t);\n\t\t}\n\t}\n}\n",
|
|
6
|
+
"import { type MailError, MailFailure } from './errors';\nimport { checkMessage } from './message';\nimport type { Mailer, MailMessage, SentMail } from './types';\n\n/** One message the memory mailer accepted, with the id it gave it. */\nexport interface MemoryMail extends MailMessage {\n\treadonly messageId: string;\n}\n\n/**\n * The reference transport: it keeps what it sends in memory, for tests.\n *\n * It refuses exactly what every transport refuses (it calls\n * {@link checkMessage}), and it can be told to fail, so a test can prove what\n * the application does when a send throws.\n */\nexport interface MemoryMailer extends Mailer {\n\t/** Every message accepted so far, oldest first. A copy: mutating it changes nothing. */\n\treadonly sent: readonly MemoryMail[];\n\t/**\n\t * How many sends reached the hand-over, failed ones included. A message\n\t * refused as malformed never reaches it. A caller that retries in secret\n\t * shows up here.\n\t */\n\treadonly attempts: number;\n\t/**\n\t * Makes the next send that reaches the hand-over reject with `error`, by\n\t * default a {@link MailFailure} as an outage would. Calls queue: two calls\n\t * fail the next two sends.\n\t */\n\tfailNext(error?: MailError): void;\n\t/** Forgets what was sent, the attempts, and any queued failure. */\n\tclear(): void;\n}\n\n/** Creates a {@link MemoryMailer}. Message ids are `memory-1`, `memory-2`, … */\nexport function createMemoryMailer(): MemoryMailer {\n\tlet sent: MemoryMail[] = [];\n\tlet failures: MailError[] = [];\n\tlet attempts = 0;\n\tlet counter = 0;\n\n\treturn {\n\t\tget sent() {\n\t\t\treturn sent.map((mail) => structuredClone(mail));\n\t\t},\n\t\tget attempts() {\n\t\t\treturn attempts;\n\t\t},\n\t\tfailNext(error) {\n\t\t\tfailures.push(\n\t\t\t\terror ??\n\t\t\t\t\tnew MailFailure(\n\t\t\t\t\t\t'send: the memory mailer was told to fail this send',\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcause: new Error('memory mailer: failNext'),\n\t\t\t\t\t\t},\n\t\t\t\t\t),\n\t\t\t);\n\t\t},\n\t\tclear() {\n\t\t\tsent = [];\n\t\t\tfailures = [];\n\t\t\tattempts = 0;\n\t\t},\n\t\tasync send(message): Promise<SentMail> {\n\t\t\tcheckMessage(message);\n\t\t\tattempts += 1;\n\t\t\tconst failure = failures.shift();\n\t\t\tif (failure !== undefined) throw failure;\n\n\t\t\tcounter += 1;\n\t\t\tconst messageId = `memory-${counter}`;\n\t\t\tsent.push({ ...structuredClone(message), messageId });\n\t\t\treturn { messageId };\n\t\t},\n\t};\n}\n"
|
|
7
|
+
],
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "44B7D62837EFA8BB64756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/locale.ts
|
|
2
|
+
var normalise = (locale) => locale.trim().replace(/_/g, "-").toLowerCase();
|
|
3
|
+
var languageOf = (locale) => normalise(locale).split("-")[0] ?? "";
|
|
4
|
+
function pickLocale2(wanted, supported, fallback) {
|
|
5
|
+
if (supported.length === 0) {
|
|
6
|
+
throw new TypeError("pickLocale: supported must hold at least one locale");
|
|
7
|
+
}
|
|
8
|
+
if (!supported.includes(fallback)) {
|
|
9
|
+
throw new TypeError("pickLocale: fallback must be one of supported");
|
|
10
|
+
}
|
|
11
|
+
const list = Array.isArray(wanted) ? wanted : [wanted];
|
|
12
|
+
for (const locale of list) {
|
|
13
|
+
if (typeof locale !== "string" || locale.trim() === "")
|
|
14
|
+
continue;
|
|
15
|
+
const exact = supported.find((s) => normalise(s) === normalise(locale));
|
|
16
|
+
if (exact !== undefined)
|
|
17
|
+
return exact;
|
|
18
|
+
const language = languageOf(locale);
|
|
19
|
+
const sameLanguage = supported.find((s) => normalise(s) === language) ?? supported.find((s) => languageOf(s) === language);
|
|
20
|
+
if (sameLanguage !== undefined)
|
|
21
|
+
return sameLanguage;
|
|
22
|
+
}
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
function parseAcceptLanguage2(header) {
|
|
26
|
+
if (typeof header !== "string")
|
|
27
|
+
return [];
|
|
28
|
+
return header.split(",").map((entry, index) => {
|
|
29
|
+
const [tag = "", ...params] = entry.split(";").map((p) => p.trim());
|
|
30
|
+
const q = params.map((p) => /^q=([0-9.]+)$/i.exec(p)?.[1]).find((v) => v !== undefined);
|
|
31
|
+
const weight = q === undefined ? 1 : Number(q);
|
|
32
|
+
return { tag, weight: Number.isNaN(weight) ? 0 : weight, index };
|
|
33
|
+
}).filter(({ tag, weight }) => tag !== "" && tag !== "*" && weight > 0).sort((a, b) => b.weight - a.weight || a.index - b.index).map(({ tag }) => tag);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { pickLocale2, parseAcceptLanguage2 };
|
|
37
|
+
|
|
38
|
+
//# debugId=17ED638EE74DEAA764756E2164756E21
|
|
39
|
+
//# sourceMappingURL=index-vq4e9n8f.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/locale.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/** What {@link pickLocale} accepts as the wanted locales. */\nexport type WantedLocales =\n\t| string\n\t| null\n\t| undefined\n\t| readonly (string | null | undefined)[];\n\nconst normalise = (locale: string) =>\n\tlocale.trim().replace(/_/g, '-').toLowerCase();\nconst languageOf = (locale: string) => normalise(locale).split('-')[0] ?? '';\n\n/**\n * The locale to render an e-mail in: the first wanted locale this build\n * supports, or `fallback`.\n *\n * `wanted` is in order of preference — typically the recipient's stored\n * locale, then their `Accept-Language` (see {@link parseAcceptLanguage}).\n * For each wanted locale in turn, an exact match wins (case and `_` or `-`\n * do not matter), then a match on the language alone: `fr-CA` picks `fr`,\n * and `fr` picks `fr-CA` when that is the only French supported. Nothing\n * matching, or nothing wanted, answers `fallback`.\n *\n * Pure, with no request context: **the locale of an e-mail is the\n * recipient's**, usually a field of the user, and not the language of the\n * request that triggered the send.\n *\n * Throws a `TypeError` when `supported` is empty or does not hold `fallback`.\n * That is a wiring mistake, not a request's.\n */\nexport function pickLocale<const L extends string>(\n\twanted: WantedLocales,\n\tsupported: readonly L[],\n\tfallback: NoInfer<L>,\n): L {\n\tif (supported.length === 0) {\n\t\tthrow new TypeError('pickLocale: supported must hold at least one locale');\n\t}\n\tif (!supported.includes(fallback)) {\n\t\tthrow new TypeError('pickLocale: fallback must be one of supported');\n\t}\n\n\tconst list = Array.isArray(wanted) ? wanted : [wanted];\n\tfor (const locale of list) {\n\t\tif (typeof locale !== 'string' || locale.trim() === '') continue;\n\t\tconst exact = supported.find((s) => normalise(s) === normalise(locale));\n\t\tif (exact !== undefined) return exact;\n\t\tconst language = languageOf(locale);\n\t\tconst sameLanguage =\n\t\t\tsupported.find((s) => normalise(s) === language) ??\n\t\t\tsupported.find((s) => languageOf(s) === language);\n\t\tif (sameLanguage !== undefined) return sameLanguage;\n\t}\n\treturn fallback;\n}\n\n/**\n * The locales of an `Accept-Language` header, most wanted first.\n *\n * Entries are ordered by their `q` weight, ties keeping the header's order;\n * `q=0` entries and `*` are dropped. A missing or empty header answers `[]`.\n *\n * ```ts\n * parseAcceptLanguage('fr-CA,fr;q=0.9,en;q=0.8'); // ['fr-CA', 'fr', 'en']\n * ```\n */\nexport function parseAcceptLanguage(\n\theader: string | null | undefined,\n): string[] {\n\tif (typeof header !== 'string') return [];\n\treturn header\n\t\t.split(',')\n\t\t.map((entry, index) => {\n\t\t\tconst [tag = '', ...params] = entry.split(';').map((p) => p.trim());\n\t\t\tconst q = params\n\t\t\t\t.map((p) => /^q=([0-9.]+)$/i.exec(p)?.[1])\n\t\t\t\t.find((v) => v !== undefined);\n\t\t\tconst weight = q === undefined ? 1 : Number(q);\n\t\t\treturn { tag, weight: Number.isNaN(weight) ? 0 : weight, index };\n\t\t})\n\t\t.filter(({ tag, weight }) => tag !== '' && tag !== '*' && weight > 0)\n\t\t.sort((a, b) => b.weight - a.weight || a.index - b.index)\n\t\t.map(({ tag }) => tag);\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";AAOA,IAAM,YAAY,CAAC,WAClB,OAAO,KAAK,EAAE,QAAQ,MAAM,GAAG,EAAE,YAAY;AAC9C,IAAM,aAAa,CAAC,WAAmB,UAAU,MAAM,EAAE,MAAM,GAAG,EAAE,MAAM;AAoBnE,SAAS,WAAkC,CACjD,QACA,WACA,UACI;AAAA,EACJ,IAAI,UAAU,WAAW,GAAG;AAAA,IAC3B,MAAM,IAAI,UAAU,qDAAqD;AAAA,EAC1E;AAAA,EACA,IAAI,CAAC,UAAU,SAAS,QAAQ,GAAG;AAAA,IAClC,MAAM,IAAI,UAAU,+CAA+C;AAAA,EACpE;AAAA,EAEA,MAAM,OAAO,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC,MAAM;AAAA,EACrD,WAAW,UAAU,MAAM;AAAA,IAC1B,IAAI,OAAO,WAAW,YAAY,OAAO,KAAK,MAAM;AAAA,MAAI;AAAA,IACxD,MAAM,QAAQ,UAAU,KAAK,CAAC,MAAM,UAAU,CAAC,MAAM,UAAU,MAAM,CAAC;AAAA,IACtE,IAAI,UAAU;AAAA,MAAW,OAAO;AAAA,IAChC,MAAM,WAAW,WAAW,MAAM;AAAA,IAClC,MAAM,eACL,UAAU,KAAK,CAAC,MAAM,UAAU,CAAC,MAAM,QAAQ,KAC/C,UAAU,KAAK,CAAC,MAAM,WAAW,CAAC,MAAM,QAAQ;AAAA,IACjD,IAAI,iBAAiB;AAAA,MAAW,OAAO;AAAA,EACxC;AAAA,EACA,OAAO;AAAA;AAaD,SAAS,oBAAmB,CAClC,QACW;AAAA,EACX,IAAI,OAAO,WAAW;AAAA,IAAU,OAAO,CAAC;AAAA,EACxC,OAAO,OACL,MAAM,GAAG,EACT,IAAI,CAAC,OAAO,UAAU;AAAA,IACtB,OAAO,MAAM,OAAO,UAAU,MAAM,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC;AAAA,IAClE,MAAM,IAAI,OACR,IAAI,CAAC,MAAM,iBAAiB,KAAK,CAAC,IAAI,EAAE,EACxC,KAAK,CAAC,MAAM,MAAM,SAAS;AAAA,IAC7B,MAAM,SAAS,MAAM,YAAY,IAAI,OAAO,CAAC;AAAA,IAC7C,OAAO,EAAE,KAAK,QAAQ,OAAO,MAAM,MAAM,IAAI,IAAI,QAAQ,MAAM;AAAA,GAC/D,EACA,OAAO,GAAG,KAAK,aAAa,QAAQ,MAAM,QAAQ,OAAO,SAAS,CAAC,EACnE,KAAK,CAAC,GAAG,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,EAAE,KAAK,EACvD,IAAI,GAAG,UAAU,GAAG;AAAA;",
|
|
8
|
+
"debugId": "17ED638EE74DEAA764756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
class MailError2 extends Error {
|
|
3
|
+
constructor(message, options) {
|
|
4
|
+
super(message, { cause: options?.cause });
|
|
5
|
+
this.name = "MailError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class MailFailure2 extends MailError2 {
|
|
10
|
+
constructor() {
|
|
11
|
+
super(...arguments);
|
|
12
|
+
this.name = "MailFailure";
|
|
13
|
+
this.code = "MAIL_FAILED";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class MailRefused2 extends MailError2 {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(...arguments);
|
|
20
|
+
this.name = "MailRefused";
|
|
21
|
+
this.code = "MAIL_REFUSED";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { MailError2, MailFailure2, MailRefused2 };
|
|
26
|
+
|
|
27
|
+
//# debugId=5753AA1D988C668964756E2164756E21
|
|
28
|
+
//# sourceMappingURL=index-we4n5yfz.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/errors.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * What a transport refuses, as a string a caller can switch on.\n *\n * Every code is a **refusal at call time**. A refusal that can only come from\n * how the application was wired — a bad option passed to a factory — is a\n * bare `TypeError` instead: no handler should ever answer one.\n *\n * The codes are `SCREAMING_SNAKE` because they are data values, not API\n * identifiers. Every key in this package is `camelCase`.\n */\nexport type MailErrorCode =\n\t/**\n\t * The transport could not hand the message over: a refused connection, a\n\t * timeout, a 5xx from the provider, an expired credential. The transport's\n\t * own error is the `cause`.\n\t *\n\t * **Nothing is known to have been sent** — after a timeout or a dropped\n\t * connection, the provider may have taken it all the same. Retry later,\n\t * or tell the user it failed. Never report it as sent.\n\t */\n\t| 'MAIL_FAILED'\n\t/**\n\t * The message itself was refused, before or by the transport: no\n\t * recipient, something that is not an address, a line break in the\n\t * subject or a header, a provider answering that the message is\n\t * malformed, or — from `@nxgt/mail/renderer` — a URL variable that is not\n\t * an `http:`, `https:` or `mailto:` URL. Sending it again unchanged fails\n\t * again.\n\t */\n\t| 'MAIL_REFUSED';\n\n/** Options every error of this package accepts. */\nexport interface MailErrorOptions {\n\t/** The error that caused this one, typically the transport's. */\n\treadonly cause?: unknown;\n}\n\n/**\n * The base class of every error this package throws at call time. It is\n * abstract: a transport throws {@link MailFailure} or {@link MailRefused}.\n *\n * **There is exactly one definition of this class.** A transport defines no\n * error class of its own and throws these, imported from its `@nxgt/mail`\n * peer, so `error instanceof MailFailure` holds whatever transport threw it.\n *\n * A message reports **a shape, never a value**: never a recipient address,\n * never a subject, never a link — the link in a verification e-mail is a\n * credential.\n */\nexport abstract class MailError extends Error {\n\toverride name = 'MailError';\n\t/**\n\t * Abstract, so a transport cannot throw a bare `MailError` that passes a\n\t * `code` check and fails `instanceof MailFailure`: it throws one of the two\n\t * subclasses.\n\t */\n\tabstract readonly code: MailErrorCode;\n\n\tconstructor(message: string, options?: MailErrorOptions) {\n\t\tsuper(message, { cause: options?.cause });\n\t}\n}\n\n/** The transport could not hand the message over. Code `MAIL_FAILED`. */\nexport class MailFailure extends MailError {\n\toverride name = 'MailFailure';\n\toverride readonly code = 'MAIL_FAILED' as const;\n}\n\n/** The message was refused as malformed. Code `MAIL_REFUSED`. */\nexport class MailRefused extends MailError {\n\toverride name = 'MailRefused';\n\toverride readonly code = 'MAIL_REFUSED' as const;\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";AAiDO,MAAe,mBAAkB,MAAM;AAAA,EAS7C,WAAW,CAAC,SAAiB,SAA4B;AAAA,IACxD,MAAM,SAAS,EAAE,OAAO,SAAS,MAAM,CAAC;AAAA,IAThC,YAAO;AAAA;AAWjB;AAAA;AAGO,MAAM,qBAAoB,WAAU;AAAA;AAAA;AAAA,IACjC,YAAO;AAAA,IACE,YAAO;AAAA;AAC1B;AAAA;AAGO,MAAM,qBAAoB,WAAU;AAAA;AAAA;AAAA,IACjC,YAAO;AAAA,IACE,YAAO;AAAA;AAC1B;",
|
|
8
|
+
"debugId": "5753AA1D988C668964756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MailerCaseContext } from './types';
|
|
2
|
+
/** Throws when `condition` is false. The suite depends on no assertion library. */
|
|
3
|
+
export declare function check(condition: boolean, what: string): asserts condition;
|
|
4
|
+
export declare const same: (a: unknown, b: unknown) => boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Settles an expected rejection where it is created, and answers the error —
|
|
7
|
+
* or throws when the promise resolved.
|
|
8
|
+
*/
|
|
9
|
+
export declare function rejection(promise: Promise<unknown>, what: string): Promise<unknown>;
|
|
10
|
+
/** Throws when the receiving end got anything. */
|
|
11
|
+
export declare function nothingDelivered(context: MailerCaseContext, what: string): Promise<void>;
|
|
12
|
+
//# sourceMappingURL=assert.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assert.d.ts","sourceRoot":"","sources":["../../src/conformance/assert.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEjD,mFAAmF;AACnF,wBAAgB,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAEzE;AAED,eAAO,MAAM,IAAI,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,YACH,CAAC;AAEzC;;;GAGG;AACH,wBAAsB,SAAS,CAC9B,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,EACzB,IAAI,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,kDAAkD;AAClD,wBAAsB,gBAAgB,CACrC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,MAAM,iBAMZ"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"failure.d.ts","sourceRoot":"","sources":["../../../src/conformance/cases/failure.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C,gFAAgF;AAChF,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAqF7C,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MailerCase } from '../types';
|
|
2
|
+
export { failureCases } from './failure';
|
|
3
|
+
export { sendCases } from './send';
|
|
4
|
+
/** Every case, in the order they are described. */
|
|
5
|
+
export declare const allMailerCases: readonly MailerCase[];
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/conformance/cases/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAI3C,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAEnC,mDAAmD;AACnD,eAAO,MAAM,cAAc,EAAE,SAAS,UAAU,EAG/C,CAAC"}
|