ajo-kit-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 +15 -0
- package/README.md +170 -0
- package/dist/capture.js +82 -0
- package/dist/chunks/errors-CMUvhZvz.js +97 -0
- package/dist/chunks/seal-BORkLlJV.js +117 -0
- package/dist/http.js +65 -0
- package/dist/index.js +236 -0
- package/dist/smtp.js +195 -0
- package/package.json +77 -0
- package/src/adapter.ts +18 -0
- package/src/capture.ts +121 -0
- package/src/errors.ts +164 -0
- package/src/http.ts +98 -0
- package/src/index.ts +384 -0
- package/src/seal.ts +242 -0
- package/src/smtp.ts +315 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026, Cristian Falcone
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
|
|
14
|
+
OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# ajo-kit-mail
|
|
2
|
+
|
|
3
|
+
Validated mail contract and pluggable transports for `ajo-kit` apps.
|
|
4
|
+
|
|
5
|
+
Includes:
|
|
6
|
+
|
|
7
|
+
- one validation boundary: every message is sealed before any transport sees it
|
|
8
|
+
- delivery with a hard deadline, bounded concurrency and a single attempt
|
|
9
|
+
- transports: SMTP (mandatory verified TLS), JSON HTTP providers, in-memory capture
|
|
10
|
+
- sanitized failures: classified codes and retry verdicts, never provider prose
|
|
11
|
+
- credential-free delivery events for observability
|
|
12
|
+
- integration with `ajo-kit`'s mail seam, so existing `send()` call sites gain
|
|
13
|
+
validation without being edited
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add ajo-kit-mail
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`ajo-kit-mail` requires `ajo-kit` as a peer dependency.
|
|
22
|
+
|
|
23
|
+
`nodemailer` is an optional peer used only by the SMTP transport. HTTP and
|
|
24
|
+
capture consumers do not install it:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pnpm add nodemailer # only when using smtp()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
Configure a transport once during app boot. `configure()` also adapts the
|
|
33
|
+
package into `ajo-kit`'s mail seam, so code importing `send` from
|
|
34
|
+
`ajo-kit/mail` delivers through it with validation and a deadline.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { configure } from 'ajo-kit-mail'
|
|
38
|
+
import { smtp } from 'ajo-kit-mail/smtp'
|
|
39
|
+
|
|
40
|
+
configure({
|
|
41
|
+
transport: smtp({ host: 'smtp.example.com', user: 'apikey', pass: process.env.SMTP_PASS }),
|
|
42
|
+
from: 'Ajo <noreply@example.com>',
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Development uses `capture()`; `configure()` refuses dev transports when
|
|
47
|
+
`NODE_ENV` is `production`.
|
|
48
|
+
|
|
49
|
+
## Sending
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { deliver, send } from 'ajo-kit-mail'
|
|
53
|
+
|
|
54
|
+
// Resolves to the message id, throws Refused or Undelivered.
|
|
55
|
+
await send({ to: 'user@example.com', subject: 'Reset', text: body })
|
|
56
|
+
|
|
57
|
+
// Never throws: a discriminated Outcome for code that branches on failure.
|
|
58
|
+
const outcome = await deliver({
|
|
59
|
+
to: { address: 'user@example.com', name: 'User' },
|
|
60
|
+
subject: 'Reset your password',
|
|
61
|
+
text: body,
|
|
62
|
+
kind: 'reset', // label for events: 'reset', 'verify', 'invite'
|
|
63
|
+
key: token.id, // idempotency key, forwarded to providers that accept one
|
|
64
|
+
expires: token.expires, // hard deadline: nothing is attempted past it
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (!outcome.ok && outcome.kind === 'undelivered' && outcome.retryable) retry()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A message has exactly one recipient — credential mail must not fan out. The
|
|
71
|
+
sealed envelope carries an absolute deadline (default 10 s, capped by
|
|
72
|
+
`expires`) and an `AbortSignal` transports must honour.
|
|
73
|
+
|
|
74
|
+
`probe()` runs the transport's optional credential check without sending:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const status = await probe() // { ok: true } | { ok: false, error }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Transports
|
|
81
|
+
|
|
82
|
+
### `smtp(options)`
|
|
83
|
+
|
|
84
|
+
One connection per message over nodemailer, with mandatory verified TLS:
|
|
85
|
+
STARTTLS is required on port 587 (`implicit: true` for 465), the certificate
|
|
86
|
+
is verified, and the floor is TLS 1.2. None of that is configurable, and
|
|
87
|
+
there is no credential URL form — discrete `host`/`user`/`pass` fields only.
|
|
88
|
+
Local development uses `capture()` instead of an insecure flag.
|
|
89
|
+
|
|
90
|
+
### `http(options)`
|
|
91
|
+
|
|
92
|
+
A JSON provider over global `fetch`. The body mapping is the only
|
|
93
|
+
provider-specific code an app writes:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { http } from 'ajo-kit-mail/http'
|
|
97
|
+
|
|
98
|
+
const transport = http({
|
|
99
|
+
url: 'https://api.provider.example/send',
|
|
100
|
+
headers: () => ({ Authorization: `Bearer ${read()}` }), // factory: read per send
|
|
101
|
+
body: mail => ({ from: mail.from.address, to: mail.to.address, subject: mail.subject, text: mail.text }),
|
|
102
|
+
id: payload => (payload as { id?: string }).id,
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The message `key` is forwarded as `Idempotency-Key`. Success bodies are read
|
|
107
|
+
up to 64 KiB; failure bodies are cancelled unread.
|
|
108
|
+
|
|
109
|
+
### `capture(options?)`
|
|
110
|
+
|
|
111
|
+
Bounded in-memory transport for tests and development — refused in
|
|
112
|
+
production:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { capture } from 'ajo-kit-mail/capture'
|
|
116
|
+
|
|
117
|
+
const mailbox = capture()
|
|
118
|
+
configure({ transport: mailbox, from: 'noreply@example.com' })
|
|
119
|
+
|
|
120
|
+
await send({ to: 'user@example.com', subject: 'Reset', text: `Open ${url}` })
|
|
121
|
+
mailbox.link(/\/reset\//) // first matching URL in the last body
|
|
122
|
+
mailbox.fail('throttled', 2) // drive the failure paths deterministically
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Custom transports
|
|
126
|
+
|
|
127
|
+
A transport is a function from a `Sealed` envelope to an optional `Receipt`.
|
|
128
|
+
It can only receive validated input — `seal()` is the sole constructor of
|
|
129
|
+
`Sealed`. Reuse `classify()` so failures inherit the sanitization guarantee:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { classify, type Transport } from 'ajo-kit-mail'
|
|
133
|
+
|
|
134
|
+
const transport: Transport = async mail => {
|
|
135
|
+
try {
|
|
136
|
+
await provider.send(/* ... */)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw classify(error) // shape only: never message, cause or response bodies
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Outcomes and errors
|
|
144
|
+
|
|
145
|
+
`Refused` means the message or configuration was rejected before any network
|
|
146
|
+
work: `invalid-recipient`, `empty-body`, `too-large`, `expired`, and friends.
|
|
147
|
+
`Undelivered` means a transport accepted the envelope and the attempt failed,
|
|
148
|
+
with a classification (`timeout`, `auth`, `tls`, `throttled`, `rejected`, …),
|
|
149
|
+
a `retryable` verdict and at most a protocol status hint (`smtp 451`). Both
|
|
150
|
+
extend `ajo-kit`'s `Failure`; neither ever echoes an address, a subject, a
|
|
151
|
+
body or provider prose.
|
|
152
|
+
|
|
153
|
+
Validation limits: subject ≤ 255 bytes, body ≤ 256 KiB (hard maximum),
|
|
154
|
+
control characters rejected everywhere — the boundary that stops header
|
|
155
|
+
injection.
|
|
156
|
+
|
|
157
|
+
## Observability
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
configure({
|
|
161
|
+
transport,
|
|
162
|
+
from: 'noreply@example.com',
|
|
163
|
+
concurrency: 4, // backpressure, not throughput
|
|
164
|
+
observe: delivery => log(delivery), // { id, kind, transport, outcome, code?, retryable?, domain?, ms }
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Events are body-free by construction: the recipient appears as its domain
|
|
169
|
+
only, and no field can hold a credential. A throwing observer never changes
|
|
170
|
+
delivery semantics.
|
package/dist/capture.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { n as Undelivered } from "./chunks/errors-CMUvhZvz.js";
|
|
2
|
+
import { t as domain } from "./chunks/seal-BORkLlJV.js";
|
|
3
|
+
//#region packages/ajo-kit-mail/src/capture.ts
|
|
4
|
+
var DELIVERY = new Set([
|
|
5
|
+
"timeout",
|
|
6
|
+
"busy",
|
|
7
|
+
"connection",
|
|
8
|
+
"tls",
|
|
9
|
+
"auth",
|
|
10
|
+
"rejected",
|
|
11
|
+
"throttled",
|
|
12
|
+
"unavailable",
|
|
13
|
+
"unknown"
|
|
14
|
+
]);
|
|
15
|
+
var RETRYABLE = new Set([
|
|
16
|
+
"timeout",
|
|
17
|
+
"busy",
|
|
18
|
+
"connection",
|
|
19
|
+
"throttled",
|
|
20
|
+
"unavailable"
|
|
21
|
+
]);
|
|
22
|
+
var LINKS = /https?:\/\/[^\s<>"'`]+/g;
|
|
23
|
+
var count = (value) => {
|
|
24
|
+
const keep = value ?? 50;
|
|
25
|
+
if (!Number.isSafeInteger(keep) || keep < 0) throw new RangeError("Capture keep must be a non-negative safe integer");
|
|
26
|
+
return keep;
|
|
27
|
+
};
|
|
28
|
+
var repetitions = (value) => {
|
|
29
|
+
const times = value ?? 1;
|
|
30
|
+
if (!Number.isSafeInteger(times) || times <= 0) throw new RangeError("Capture failure count must be a positive safe integer");
|
|
31
|
+
return times;
|
|
32
|
+
};
|
|
33
|
+
var matches = (pattern, value) => {
|
|
34
|
+
const lastIndex = pattern.lastIndex;
|
|
35
|
+
pattern.lastIndex = 0;
|
|
36
|
+
try {
|
|
37
|
+
return pattern.test(value);
|
|
38
|
+
} finally {
|
|
39
|
+
pattern.lastIndex = lastIndex;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** Creates a bounded in-memory transport for development and deterministic tests. */
|
|
43
|
+
function capture(options = {}) {
|
|
44
|
+
const keep = count(options.keep);
|
|
45
|
+
const messages = [];
|
|
46
|
+
let failure;
|
|
47
|
+
const transport = async (mail) => {
|
|
48
|
+
if (failure) {
|
|
49
|
+
const current = failure;
|
|
50
|
+
current.remaining--;
|
|
51
|
+
if (current.remaining === 0) failure = void 0;
|
|
52
|
+
throw new Undelivered(current.code, RETRYABLE.has(current.code));
|
|
53
|
+
}
|
|
54
|
+
messages.push(mail);
|
|
55
|
+
if (messages.length > keep) messages.splice(0, messages.length - keep);
|
|
56
|
+
if (options.log) console.log(`[mail] ${mail.id} ${mail.kind} @${domain(mail.to.address)} sent 0ms`);
|
|
57
|
+
return { id: mail.id };
|
|
58
|
+
};
|
|
59
|
+
return Object.assign(transport, {
|
|
60
|
+
label: "capture",
|
|
61
|
+
dev: true,
|
|
62
|
+
messages,
|
|
63
|
+
last: () => messages.at(-1),
|
|
64
|
+
link: (pattern) => {
|
|
65
|
+
const links = messages.at(-1)?.text.match(LINKS) ?? [];
|
|
66
|
+
return pattern ? links.find((value) => matches(pattern, value)) : links[0];
|
|
67
|
+
},
|
|
68
|
+
fail: (code, times) => {
|
|
69
|
+
if (!DELIVERY.has(code)) throw new RangeError("Unknown capture delivery code");
|
|
70
|
+
failure = {
|
|
71
|
+
code,
|
|
72
|
+
remaining: repetitions(times)
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
clear: () => {
|
|
76
|
+
messages.length = 0;
|
|
77
|
+
failure = void 0;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { capture };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Failure } from "ajo-kit";
|
|
2
|
+
//#region packages/ajo-kit-mail/src/errors.ts
|
|
3
|
+
var CONFIGURATION = new Set([
|
|
4
|
+
"no-transport",
|
|
5
|
+
"invalid-config",
|
|
6
|
+
"invalid-sender"
|
|
7
|
+
]);
|
|
8
|
+
var DELIVERY = new Set([
|
|
9
|
+
"timeout",
|
|
10
|
+
"busy",
|
|
11
|
+
"connection",
|
|
12
|
+
"tls",
|
|
13
|
+
"auth",
|
|
14
|
+
"rejected",
|
|
15
|
+
"throttled",
|
|
16
|
+
"unavailable",
|
|
17
|
+
"unknown"
|
|
18
|
+
]);
|
|
19
|
+
var RETRYABLE = new Set([
|
|
20
|
+
"timeout",
|
|
21
|
+
"busy",
|
|
22
|
+
"connection",
|
|
23
|
+
"throttled",
|
|
24
|
+
"unavailable"
|
|
25
|
+
]);
|
|
26
|
+
/**
|
|
27
|
+
* The message or the configuration was rejected. Nothing was sent and no provider
|
|
28
|
+
* was contacted. Extends Failure with a deliberate status so normalize() cannot
|
|
29
|
+
* inherit a provider status and server.tsx never console.error()s the object.
|
|
30
|
+
* The message is the code and never echoes an input value.
|
|
31
|
+
*/
|
|
32
|
+
var Refused = class extends Failure {
|
|
33
|
+
code;
|
|
34
|
+
constructor(code) {
|
|
35
|
+
super(CONFIGURATION.has(code) ? 500 : 422, `Mail refused: ${code}`);
|
|
36
|
+
this.name = "Refused";
|
|
37
|
+
this.code = code;
|
|
38
|
+
if (CONFIGURATION.has(code)) console.error(`[mail] refused: ${code}`);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* A transport accepted the envelope and the attempt failed. Carries a
|
|
43
|
+
* classification, a retry verdict and at most a protocol status. Never provider
|
|
44
|
+
* prose, never a recipient, never a subject, never a body, never a cause.
|
|
45
|
+
*/
|
|
46
|
+
var Undelivered = class extends Failure {
|
|
47
|
+
code;
|
|
48
|
+
retryable;
|
|
49
|
+
/** Protocol status only: 'smtp 451', 'status 429'. */
|
|
50
|
+
hint;
|
|
51
|
+
constructor(code, retryable, hint) {
|
|
52
|
+
super(502, `Mail delivery failed: ${code}`);
|
|
53
|
+
this.name = "Undelivered";
|
|
54
|
+
this.code = code;
|
|
55
|
+
this.retryable = retryable;
|
|
56
|
+
this.hint = hint;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var protocol = (value) => typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599 ? value : void 0;
|
|
60
|
+
/**
|
|
61
|
+
* Reduces any thrown value to an Undelivered by reading shape only: name, code,
|
|
62
|
+
* responseCode/status/statusCode. Never .message, never .cause, never .response,
|
|
63
|
+
* never .envelope. Exported so a third-party transport inherits the guarantee
|
|
64
|
+
* instead of reimplementing it.
|
|
65
|
+
*/
|
|
66
|
+
function classify(error) {
|
|
67
|
+
const value = error !== null && (typeof error === "object" || typeof error === "function") ? error : {};
|
|
68
|
+
const rawName = value.name;
|
|
69
|
+
const rawCode = value.code;
|
|
70
|
+
const rawResponseCode = value.responseCode;
|
|
71
|
+
const rawStatus = value.status;
|
|
72
|
+
const rawStatusCode = value.statusCode;
|
|
73
|
+
const name = typeof rawName === "string" ? rawName : "";
|
|
74
|
+
const code = typeof rawCode === "string" ? rawCode : "";
|
|
75
|
+
const responseCode = protocol(rawResponseCode);
|
|
76
|
+
const directStatus = protocol(rawStatus);
|
|
77
|
+
const statusCode = protocol(rawStatusCode);
|
|
78
|
+
const status = responseCode ?? directStatus ?? statusCode;
|
|
79
|
+
const hint = status === void 0 ? void 0 : `${responseCode === void 0 ? "status" : "smtp"} ${status}`;
|
|
80
|
+
const undelivered = (delivery, retryable, detail) => new Undelivered(delivery, retryable, detail);
|
|
81
|
+
if (name === "Undelivered" && DELIVERY.has(code)) {
|
|
82
|
+
const delivery = code;
|
|
83
|
+
return undelivered(delivery, RETRYABLE.has(delivery));
|
|
84
|
+
}
|
|
85
|
+
if (code === "EAUTH" || responseCode === 530 || responseCode === 535 || responseCode === void 0 && (status === 401 || status === 403)) return undelivered("auth", false, hint);
|
|
86
|
+
if (code === "ETLS" || code.startsWith("ERR_TLS") || code.includes("CERT")) return undelivered("tls", false);
|
|
87
|
+
if (name === "AbortError" || name === "TimeoutError" || code === "ETIMEDOUT" || code === "ECONNECTION" && status === void 0 || responseCode === void 0 && status === 408) return undelivered("timeout", true);
|
|
88
|
+
if (responseCode !== void 0 && responseCode >= 400 && responseCode < 500) return undelivered("throttled", true, hint);
|
|
89
|
+
if (responseCode !== void 0 && responseCode >= 500) return undelivered("rejected", false, hint);
|
|
90
|
+
if (status === 429) return undelivered("throttled", true, hint);
|
|
91
|
+
if (status !== void 0 && status >= 500) return undelivered("unavailable", true, hint);
|
|
92
|
+
if (status !== void 0 && status >= 400) return undelivered("rejected", false, hint);
|
|
93
|
+
if (code) return undelivered("connection", true);
|
|
94
|
+
return undelivered("unknown", false);
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
export { Undelivered as n, classify as r, Refused as t };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { t as Refused } from "./errors-CMUvhZvz.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
//#region packages/ajo-kit-mail/src/seal.ts
|
|
5
|
+
var CONTROL = /[\u0000-\u001f\u007f]/;
|
|
6
|
+
var ADDRESS = /^(?=.{3,254}$)[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
7
|
+
var NAME = /^[^"<>,;:\\\u0000-\u001f\u007f]{1,128}$/;
|
|
8
|
+
var KEY = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
9
|
+
var KIND = /^[a-z][a-z0-9-]{0,31}$/;
|
|
10
|
+
var ADDRESS_BYTES = 254;
|
|
11
|
+
var SUBJECT_BYTES = 255;
|
|
12
|
+
var NAME_BYTES = 128;
|
|
13
|
+
var KEY_BYTES = 128;
|
|
14
|
+
var KIND_BYTES = 32;
|
|
15
|
+
var BODY_BYTES = 262144;
|
|
16
|
+
var TIMEOUT = 1e4;
|
|
17
|
+
var WORD_BYTES = 45;
|
|
18
|
+
var bytes = (value) => Buffer.byteLength(value, "utf8");
|
|
19
|
+
var refusal = (code) => {
|
|
20
|
+
throw new Refused(code);
|
|
21
|
+
};
|
|
22
|
+
var mailbox = (recipient, code) => {
|
|
23
|
+
const address = typeof recipient === "string" ? recipient : recipient?.address;
|
|
24
|
+
const name = typeof recipient === "string" ? void 0 : recipient?.name;
|
|
25
|
+
if (typeof address !== "string") refusal(code);
|
|
26
|
+
if (CONTROL.test(address) || !ADDRESS.test(address) || bytes(address) > ADDRESS_BYTES) refusal(code);
|
|
27
|
+
if (name !== void 0) {
|
|
28
|
+
if (typeof name !== "string" || CONTROL.test(name) || !NAME.test(name) || bytes(name) > NAME_BYTES) refusal("invalid-name");
|
|
29
|
+
}
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
address,
|
|
32
|
+
...name !== void 0 && { name }
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
var duration = (value) => {
|
|
36
|
+
const timeout = value ?? TIMEOUT;
|
|
37
|
+
if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2147483647) refusal("invalid-config");
|
|
38
|
+
return timeout;
|
|
39
|
+
};
|
|
40
|
+
var bodyLimit = (value) => {
|
|
41
|
+
const limit = value ?? BODY_BYTES;
|
|
42
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > BODY_BYTES) refusal("invalid-config");
|
|
43
|
+
return limit;
|
|
44
|
+
};
|
|
45
|
+
var expiry = (value) => {
|
|
46
|
+
if (value === void 0) return Infinity;
|
|
47
|
+
const time = value instanceof Date ? value.getTime() : value;
|
|
48
|
+
return typeof time === "number" && Number.isFinite(time) ? time : refusal("expired");
|
|
49
|
+
};
|
|
50
|
+
/** Validates once, on the way in. Every later stage consumes only the result. */
|
|
51
|
+
function seal(message, policy) {
|
|
52
|
+
if (!message || typeof message !== "object" || !policy || typeof policy !== "object") refusal("invalid-config");
|
|
53
|
+
const toRecipient = message.to;
|
|
54
|
+
const subject = message.subject;
|
|
55
|
+
const text = message.text;
|
|
56
|
+
const html = message.html;
|
|
57
|
+
const fromRecipient = message.from;
|
|
58
|
+
const messageReply = message.replyTo;
|
|
59
|
+
const rawKind = message.kind;
|
|
60
|
+
const key = message.key;
|
|
61
|
+
const expires = message.expires;
|
|
62
|
+
const policyFrom = policy.from;
|
|
63
|
+
const policyReply = policy.replyTo;
|
|
64
|
+
const policyTimeout = policy.timeout;
|
|
65
|
+
const policyLimit = policy.limit;
|
|
66
|
+
const deadline = Math.min(Date.now() + duration(policyTimeout), expiry(expires));
|
|
67
|
+
const limit = bodyLimit(policyLimit);
|
|
68
|
+
const from = mailbox(fromRecipient ?? policyFrom, "invalid-sender");
|
|
69
|
+
const to = mailbox(toRecipient, "invalid-recipient");
|
|
70
|
+
const replyRecipient = messageReply ?? policyReply;
|
|
71
|
+
const replyTo = replyRecipient === void 0 ? void 0 : mailbox(replyRecipient, "invalid-recipient");
|
|
72
|
+
const kind = rawKind ?? "mail";
|
|
73
|
+
if (typeof subject !== "string" || !subject || CONTROL.test(subject) || bytes(subject) > SUBJECT_BYTES) refusal("invalid-subject");
|
|
74
|
+
if (typeof kind !== "string" || CONTROL.test(kind) || !KIND.test(kind) || bytes(kind) > KIND_BYTES) refusal("invalid-kind");
|
|
75
|
+
if (key !== void 0 && (typeof key !== "string" || CONTROL.test(key) || !KEY.test(key) || bytes(key) > KEY_BYTES)) refusal("invalid-key");
|
|
76
|
+
if (typeof text !== "string" || html !== void 0 && typeof html !== "string" || !text && !html) refusal("empty-body");
|
|
77
|
+
if (bytes(text) + bytes(html ?? "") > limit) refusal("too-large");
|
|
78
|
+
if (deadline <= Date.now()) refusal("expired");
|
|
79
|
+
const signal = AbortSignal.timeout(Math.max(0, Math.ceil(deadline - Date.now())));
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
id: randomUUID(),
|
|
82
|
+
kind,
|
|
83
|
+
from,
|
|
84
|
+
to,
|
|
85
|
+
...replyTo && { replyTo },
|
|
86
|
+
subject,
|
|
87
|
+
text,
|
|
88
|
+
...html !== void 0 && { html },
|
|
89
|
+
...key !== void 0 && { key },
|
|
90
|
+
deadline,
|
|
91
|
+
signal
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Returns the recipient domain, the only address part safe for logs. */
|
|
95
|
+
function domain(address) {
|
|
96
|
+
return address.slice(address.lastIndexOf("@") + 1).toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
/** Encodes and folds a value into RFC 2047 encoded-words of at most 75 characters. */
|
|
99
|
+
function encode(value) {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let chunk = "";
|
|
102
|
+
let size = 0;
|
|
103
|
+
for (const character of value) {
|
|
104
|
+
const width = bytes(character);
|
|
105
|
+
if (size + width > WORD_BYTES && chunk) {
|
|
106
|
+
chunks.push(chunk);
|
|
107
|
+
chunk = "";
|
|
108
|
+
size = 0;
|
|
109
|
+
}
|
|
110
|
+
chunk += character;
|
|
111
|
+
size += width;
|
|
112
|
+
}
|
|
113
|
+
chunks.push(chunk);
|
|
114
|
+
return chunks.map((part) => `=?UTF-8?B?${Buffer.from(part, "utf8").toString("base64")}?=`).join("\r\n ");
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
export { encode as n, seal as r, domain as t };
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { r as classify } from "./chunks/errors-CMUvhZvz.js";
|
|
2
|
+
//#region packages/ajo-kit-mail/src/http.ts
|
|
3
|
+
var RESPONSE_BYTES = 65536;
|
|
4
|
+
var payload = async (response) => {
|
|
5
|
+
if (!response.body) return;
|
|
6
|
+
const reader = response.body.getReader();
|
|
7
|
+
const bytes = new Uint8Array(RESPONSE_BYTES);
|
|
8
|
+
let size = 0;
|
|
9
|
+
let complete = false;
|
|
10
|
+
try {
|
|
11
|
+
while (size < RESPONSE_BYTES) {
|
|
12
|
+
const chunk = await reader.read();
|
|
13
|
+
if (chunk.done) {
|
|
14
|
+
complete = true;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
const length = Math.min(chunk.value.byteLength, RESPONSE_BYTES - size);
|
|
18
|
+
bytes.set(chunk.value.subarray(0, length), size);
|
|
19
|
+
size += length;
|
|
20
|
+
}
|
|
21
|
+
} finally {
|
|
22
|
+
if (complete) reader.releaseLock();
|
|
23
|
+
else await reader.cancel().catch(() => {});
|
|
24
|
+
}
|
|
25
|
+
if (!size) return;
|
|
26
|
+
return JSON.parse(new TextDecoder().decode(bytes.subarray(0, size)));
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Creates a provider transport over global fetch. Non-success bodies are
|
|
30
|
+
* cancelled unread, while success bodies are retained only up to 64 KiB.
|
|
31
|
+
*/
|
|
32
|
+
function http(options) {
|
|
33
|
+
const transport = async (mail) => {
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
const configured = typeof options.headers === "function" ? options.headers() : options.headers;
|
|
37
|
+
const headers = new Headers(configured);
|
|
38
|
+
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
39
|
+
headers.delete("Idempotency-Key");
|
|
40
|
+
if (mail.key !== void 0) headers.set("Idempotency-Key", mail.key);
|
|
41
|
+
response = await fetch(options.url, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers,
|
|
44
|
+
body: JSON.stringify(options.body(mail)),
|
|
45
|
+
signal: mail.signal
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw classify(error);
|
|
49
|
+
}
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
await response.body?.cancel().catch(() => {});
|
|
52
|
+
throw classify({ status: response.status });
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const result = await payload(response);
|
|
56
|
+
const id = options.id?.(result);
|
|
57
|
+
return id === void 0 ? void 0 : { id };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw classify(error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return Object.assign(transport, { label: options.label ?? "http" });
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
export { http };
|