@yozz.app/smtp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fishball Ltd
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,30 @@
1
+ # @yozz.app/smtp
2
+
3
+ Transport-agnostic SMTP client core for YOZZ, plus the RFC 5322 message builder.
4
+
5
+ ```bash
6
+ pnpm add @yozz.app/smtp
7
+ ```
8
+
9
+ ## The seam
10
+
11
+ `@yozz.app/smtp` speaks SMTP over any `ByteDuplex` from `@yozz.app/tls`. It knows replies, EHLO
12
+ keywords, AUTH PLAIN / LOGIN, the MAIL / RCPT / DATA sequence and dot-stuffing. It **never knows**
13
+ TLS, certificates or the vault. STARTTLS is not spoken: the transport is already TLS (465).
14
+
15
+ `buildMessage` turns composer fields into 7-bit bytes: RFC 2047 headers, 7bit or quoted-printable bodies,
16
+ `multipart/alternative` when an HTML rendering is given, `In-Reply-To` + `References` for replies.
17
+
18
+ ## Tests
19
+
20
+ ```bash
21
+ pnpm -F @yozz.app/smtp test
22
+ ```
23
+
24
+ ## Live
25
+
26
+ ```bash
27
+ pnpm -F @yozz.app/smtp live # banner + EHLO on nine hosts over 465
28
+ YOZZ_SMTP_HOST=smtp.example.com YOZZ_SMTP_USER=me@example.com YOZZ_SMTP_PASSWORD=… \
29
+ YOZZ_SMTP_TO=me@example.com pnpm -F @yozz.app/smtp live smtp.example.com # auth + one real send
30
+ ```
@@ -0,0 +1,89 @@
1
+ import { ByteDuplex } from "@yozz.app/tls";
2
+
3
+ //#region src/reply.d.ts
4
+ type SmtpReply = {
5
+ readonly code: number; /** One entry per line, the `NNN-` / `NNN ` prefix removed. */
6
+ readonly lines: readonly string[];
7
+ };
8
+ type SmtpFailure = /** The server answered, and the answer was no: 4xx (try later) or 5xx (do not). */{
9
+ readonly kind: 'reply';
10
+ readonly code: number;
11
+ readonly text: string;
12
+ } | {
13
+ readonly kind: 'closed';
14
+ } | {
15
+ readonly kind: 'protocol';
16
+ readonly detail: string;
17
+ } | {
18
+ readonly kind: 'unsupported';
19
+ readonly detail: string;
20
+ };
21
+ type SmtpResult<T> = {
22
+ readonly ok: true;
23
+ readonly value: T;
24
+ } | {
25
+ readonly ok: false;
26
+ readonly reason: SmtpFailure;
27
+ };
28
+ //#endregion
29
+ //#region src/client.d.ts
30
+ type SmtpCapabilities = {
31
+ /** EHLO keywords, upper-cased, e.g. `SIZE`, `8BITMIME`, `SMTPUTF8`, `PIPELINING`. */readonly keywords: readonly string[]; /** SASL mechanisms from the AUTH line, upper-cased. */
32
+ readonly auth: readonly string[];
33
+ };
34
+ type SmtpClient = {
35
+ readonly greeting: () => Promise<SmtpResult<SmtpReply>>;
36
+ readonly ehlo: (clientName: string) => Promise<SmtpResult<SmtpCapabilities>>; /** PLAIN when offered, else LOGIN; credentials are base64 on the wire, so TLS is assumed. */
37
+ readonly authenticate: (username: string, password: string) => Promise<SmtpResult<void>>; /** MAIL FROM, one RCPT TO per recipient, DATA, the dot-stuffed message, QUIT is the caller's. */
38
+ readonly send: (envelope: SmtpEnvelope) => Promise<SmtpResult<SmtpReply>>;
39
+ readonly quit: () => Promise<SmtpResult<void>>;
40
+ };
41
+ type SmtpEnvelope = {
42
+ readonly from: string;
43
+ readonly to: readonly string[]; /** The RFC 5322 message, CRLF line endings, no trailing terminator. */
44
+ readonly data: Uint8Array;
45
+ };
46
+ /**
47
+ * RFC 5321 §4.5.2: a line beginning with `.` gets a second `.`; the message ends at `CRLF.CRLF`.
48
+ * Done on bytes so a body is never decoded and re-encoded on the way out.
49
+ */
50
+ declare const dotStuff: (data: Uint8Array) => Uint8Array;
51
+ declare const createSmtpClient: (transport: ByteDuplex) => SmtpClient;
52
+ //#endregion
53
+ //#region src/message.d.ts
54
+ /**
55
+ * An RFC 5322 message as bytes, built from fields the composer owns. Everything non-ASCII is
56
+ * encoded on the way out (RFC 2047 `=?utf-8?B?…?=` in headers, quoted-printable bodies), so the result is
57
+ * 7-bit clean and needs nothing from the server. Line endings are CRLF throughout.
58
+ */
59
+ type MessageInput = {
60
+ readonly from: {
61
+ readonly address: string;
62
+ readonly name?: string;
63
+ };
64
+ readonly to: readonly string[]; /** Carbon copies, named in a `Cc` header. Blind copies are the envelope's business, never a header. */
65
+ readonly cc?: readonly string[];
66
+ readonly subject: string;
67
+ readonly date: Date; /** `<local@domain>`; the caller mints it so it can keep the id for its own Sent copy. */
68
+ readonly messageId: string;
69
+ readonly text: string; /** When present the message is `multipart/alternative`, text first. */
70
+ readonly html?: string;
71
+ readonly inReplyTo?: string; /** When present the whole message becomes `multipart/mixed`: the body first, then each file. */
72
+ readonly attachments?: readonly MessageAttachment[];
73
+ };
74
+ type MessageAttachment = {
75
+ readonly filename: string;
76
+ readonly mimeType: string;
77
+ readonly content: Uint8Array;
78
+ };
79
+ declare const encodeHeaderText: (value: string) => string;
80
+ /** `Name <addr>` with the name quoted or encoded as its characters require; a bare address otherwise. */
81
+ declare const formatMailbox: ({
82
+ address,
83
+ name
84
+ }: MessageInput["from"]) => string;
85
+ /** RFC 5322 §3.3, in the caller's local zone so the header says when the sender wrote it. */
86
+ declare const formatDate: (date: Date) => string;
87
+ declare const buildMessage: (input: MessageInput) => Uint8Array;
88
+ //#endregion
89
+ export { type MessageAttachment, type MessageInput, type SmtpCapabilities, type SmtpClient, type SmtpEnvelope, type SmtpFailure, type SmtpReply, type SmtpResult, buildMessage, createSmtpClient, dotStuff, encodeHeaderText, formatDate, formatMailbox };
package/dist/index.mjs ADDED
@@ -0,0 +1,446 @@
1
+ //#region src/reply.ts
2
+ /** RFC 5321 §4.5.3.1.5 says 512 octets; real servers write longer EHLO lines, so a lenient cap. */
3
+ const MAX_LINE_BYTES = 4096;
4
+ /** A reply is a handful of lines; EHLO is the longest real one. Past this the server is not one. */
5
+ const MAX_REPLY_LINES = 64;
6
+ const asciiDecoder = new TextDecoder("ascii");
7
+ /** Lines out of a byte stream, CRLF-terminated, buffered across reads. */
8
+ const createLineReader = (transport) => {
9
+ let buffer = /* @__PURE__ */ new Uint8Array(0);
10
+ let isClosed = false;
11
+ const readLine = async () => {
12
+ for (;;) {
13
+ const lf = buffer.indexOf(10);
14
+ if (lf > MAX_LINE_BYTES || lf === -1 && buffer.length > MAX_LINE_BYTES) return {
15
+ ok: false,
16
+ reason: {
17
+ kind: "protocol",
18
+ detail: "reply line too long"
19
+ }
20
+ };
21
+ if (lf !== -1) {
22
+ if (lf === 0 || buffer[lf - 1] !== 13) return {
23
+ ok: false,
24
+ reason: {
25
+ kind: "protocol",
26
+ detail: "bare LF in reply"
27
+ }
28
+ };
29
+ const line = asciiDecoder.decode(buffer.subarray(0, lf - 1));
30
+ buffer = buffer.slice(lf + 1);
31
+ return {
32
+ ok: true,
33
+ value: line
34
+ };
35
+ }
36
+ if (isClosed) return {
37
+ ok: false,
38
+ reason: { kind: "closed" }
39
+ };
40
+ const chunk = await transport.read();
41
+ if (chunk === null) {
42
+ isClosed = true;
43
+ continue;
44
+ }
45
+ const merged = new Uint8Array(buffer.length + chunk.length);
46
+ merged.set(buffer, 0);
47
+ merged.set(chunk, buffer.length);
48
+ buffer = merged;
49
+ }
50
+ };
51
+ return { readLine };
52
+ };
53
+ const REPLY_LINE = /^(\d{3})([ -])(.*)$/;
54
+ const readReply = async (reader) => {
55
+ const lines = [];
56
+ let code = null;
57
+ for (;;) {
58
+ const line = await reader.readLine();
59
+ if (!line.ok) return line;
60
+ const match = REPLY_LINE.exec(line.value);
61
+ if (match === null) return {
62
+ ok: false,
63
+ reason: {
64
+ kind: "protocol",
65
+ detail: `not a reply line: ${line.value}`
66
+ }
67
+ };
68
+ const [, codeText = "", separator, text = ""] = match;
69
+ const lineCode = Number(codeText);
70
+ if (code !== null && lineCode !== code) return {
71
+ ok: false,
72
+ reason: {
73
+ kind: "protocol",
74
+ detail: `reply code changed from ${code} to ${lineCode}`
75
+ }
76
+ };
77
+ code = lineCode;
78
+ lines.push(text);
79
+ if (separator === " ") return {
80
+ ok: true,
81
+ value: {
82
+ code,
83
+ lines
84
+ }
85
+ };
86
+ if (lines.length >= MAX_REPLY_LINES) return {
87
+ ok: false,
88
+ reason: {
89
+ kind: "protocol",
90
+ detail: "reply has too many lines"
91
+ }
92
+ };
93
+ }
94
+ };
95
+ //#endregion
96
+ //#region src/client.ts
97
+ const encoder$1 = new TextEncoder();
98
+ const base64$1 = (bytes) => {
99
+ let binary = "";
100
+ for (const byte of bytes) binary += String.fromCharCode(byte);
101
+ return btoa(binary);
102
+ };
103
+ const isSuccess = (code) => code >= 200 && code < 300;
104
+ const refused = (reply) => ({
105
+ ok: false,
106
+ reason: {
107
+ kind: "reply",
108
+ code: reply.code,
109
+ text: reply.lines.join(" ")
110
+ }
111
+ });
112
+ /** A bare address may not contain the characters that would end or alter the command. */
113
+ const isCommandSafe = (address) => /^[^\s<>\r\n]+$/.test(address);
114
+ /**
115
+ * RFC 5321 §4.5.2: a line beginning with `.` gets a second `.`; the message ends at `CRLF.CRLF`.
116
+ * Done on bytes so a body is never decoded and re-encoded on the way out.
117
+ */
118
+ const dotStuff = (data) => {
119
+ const out = [];
120
+ let atLineStart = true;
121
+ for (const byte of data) {
122
+ if (atLineStart && byte === 46) out.push(46);
123
+ out.push(byte);
124
+ atLineStart = byte === 10;
125
+ }
126
+ if (!atLineStart) out.push(13, 10);
127
+ out.push(46, 13, 10);
128
+ return Uint8Array.from(out);
129
+ };
130
+ const createSmtpClient = (transport) => {
131
+ const reader = createLineReader(transport);
132
+ let inData = false;
133
+ let capabilities = {
134
+ keywords: [],
135
+ auth: []
136
+ };
137
+ const command = async (line) => {
138
+ try {
139
+ await transport.write(encoder$1.encode(`${line}\r\n`));
140
+ } catch (error) {
141
+ return {
142
+ ok: false,
143
+ reason: {
144
+ kind: "protocol",
145
+ detail: `write failed: ${String(error)}`
146
+ }
147
+ };
148
+ }
149
+ return readReply(reader);
150
+ };
151
+ const expect = async (line, ...codes) => {
152
+ const reply = await command(line);
153
+ if (!reply.ok) return reply;
154
+ return (codes.length === 0 ? isSuccess(reply.value.code) : codes.includes(reply.value.code)) ? reply : refused(reply.value);
155
+ };
156
+ const greeting = async () => {
157
+ const reply = await readReply(reader);
158
+ if (!reply.ok) return reply;
159
+ return reply.value.code === 220 ? reply : refused(reply.value);
160
+ };
161
+ const ehlo = async (clientName) => {
162
+ const reply = await expect(`EHLO ${clientName}`, 250);
163
+ if (!reply.ok) return reply;
164
+ const keywordLines = reply.value.lines.slice(1).map((line) => line.trim().toUpperCase());
165
+ const authLine = keywordLines.find((line) => line === "AUTH" || line.startsWith("AUTH "));
166
+ capabilities = {
167
+ keywords: keywordLines.map((line) => line.split(" ")[0] ?? line),
168
+ auth: authLine === void 0 ? [] : authLine.split(" ").slice(1)
169
+ };
170
+ return {
171
+ ok: true,
172
+ value: capabilities
173
+ };
174
+ };
175
+ const authenticate = async (username, password) => {
176
+ const mechanisms = capabilities.auth;
177
+ if (mechanisms.includes("PLAIN")) {
178
+ const reply = await expect(`AUTH PLAIN ${base64$1(encoder$1.encode(`\0${username}\0${password}`))}`, 235);
179
+ return reply.ok ? {
180
+ ok: true,
181
+ value: void 0
182
+ } : reply;
183
+ }
184
+ if (mechanisms.includes("LOGIN")) {
185
+ const challenge = await expect("AUTH LOGIN", 334);
186
+ if (!challenge.ok) return challenge;
187
+ const user = await expect(base64$1(encoder$1.encode(username)), 334);
188
+ if (!user.ok) return user;
189
+ const done = await expect(base64$1(encoder$1.encode(password)), 235);
190
+ return done.ok ? {
191
+ ok: true,
192
+ value: void 0
193
+ } : done;
194
+ }
195
+ return {
196
+ ok: false,
197
+ reason: {
198
+ kind: "unsupported",
199
+ detail: mechanisms.length === 0 ? "Server offers no AUTH (was EHLO sent?)" : `Server offers only ${mechanisms.join(", ")}`
200
+ }
201
+ };
202
+ };
203
+ const send = async (envelope) => {
204
+ for (const address of [envelope.from, ...envelope.to]) if (!isCommandSafe(address)) return {
205
+ ok: false,
206
+ reason: {
207
+ kind: "protocol",
208
+ detail: `unsafe address: ${address}`
209
+ }
210
+ };
211
+ if (envelope.to.length === 0) return {
212
+ ok: false,
213
+ reason: {
214
+ kind: "protocol",
215
+ detail: "no recipients"
216
+ }
217
+ };
218
+ const bodyParam = capabilities.keywords.includes("8BITMIME") ? " BODY=8BITMIME" : "";
219
+ const mailFrom = await expect(`MAIL FROM:<${envelope.from}>${bodyParam}`, 250);
220
+ if (!mailFrom.ok) return mailFrom;
221
+ for (const recipient of envelope.to) {
222
+ const rcpt = await expect(`RCPT TO:<${recipient}>`, 250, 251, 252);
223
+ if (!rcpt.ok) return rcpt;
224
+ }
225
+ const data = await expect("DATA", 354);
226
+ if (!data.ok) return data;
227
+ try {
228
+ await transport.write(dotStuff(envelope.data));
229
+ } catch (error) {
230
+ inData = true;
231
+ return {
232
+ ok: false,
233
+ reason: {
234
+ kind: "protocol",
235
+ detail: `write failed: ${String(error)}`
236
+ }
237
+ };
238
+ }
239
+ const accepted = await readReply(reader);
240
+ if (!accepted.ok) return accepted;
241
+ return accepted.value.code === 250 ? accepted : refused(accepted.value);
242
+ };
243
+ const quit = async () => {
244
+ if (inData) return {
245
+ ok: false,
246
+ reason: {
247
+ kind: "protocol",
248
+ detail: "connection abandoned inside DATA"
249
+ }
250
+ };
251
+ const reply = await expect("QUIT", 221);
252
+ return reply.ok ? {
253
+ ok: true,
254
+ value: void 0
255
+ } : reply;
256
+ };
257
+ return {
258
+ greeting,
259
+ ehlo,
260
+ authenticate,
261
+ send,
262
+ quit
263
+ };
264
+ };
265
+ //#endregion
266
+ //#region src/message.ts
267
+ const encoder = new TextEncoder();
268
+ const isAscii = (value) => [...value].every((ch) => ch.charCodeAt(0) >= 32 && ch.charCodeAt(0) <= 126);
269
+ const BASE64_CHUNK = 32768;
270
+ const base64 = (bytes) => {
271
+ let binary = "";
272
+ for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK));
273
+ return btoa(binary);
274
+ };
275
+ /** RFC 2045 §6.8: base64 in lines of 76. */
276
+ const base64Lines = (bytes) => base64(bytes).replace(/.{76}/g, "$&\r\n");
277
+ const toCrlf = (text) => text.replace(/\r?\n/g, "\r\n");
278
+ /**
279
+ * RFC 2045 §6.7 quoted-printable: bytes outside printable ASCII become `=XX`, trailing
280
+ * whitespace is protected, and lines are soft-wrapped with `=` before 76 characters. Chosen over
281
+ * base64 because filters treat a base64 text part as text that is hiding something.
282
+ */
283
+ const quotedPrintable = (text) => {
284
+ return toCrlf(text).split("\r\n").map((line) => {
285
+ let encoded = "";
286
+ const bytes = encoder.encode(line);
287
+ bytes.forEach((byte, index) => {
288
+ const isLast = index === bytes.length - 1;
289
+ encoded += byte >= 33 && byte <= 126 && byte !== 61 || (byte === 32 || byte === 9) && !isLast ? String.fromCharCode(byte) : `=${byte.toString(16).toUpperCase().padStart(2, "0")}`;
290
+ });
291
+ const out = [];
292
+ let rest = encoded;
293
+ while (rest.length > 76) {
294
+ let cut = 75;
295
+ if (rest[cut - 1] === "=") cut -= 1;
296
+ else if (rest[cut - 2] === "=") cut -= 2;
297
+ out.push(`${rest.slice(0, cut)}=`);
298
+ rest = rest.slice(cut);
299
+ }
300
+ out.push(rest);
301
+ return out.join("\r\n");
302
+ }).join("\r\n");
303
+ };
304
+ /** 7bit when the text already is (ASCII, lines under 998), quoted-printable otherwise. */
305
+ const encodeBody = (text) => {
306
+ const crlf = toCrlf(text);
307
+ return [...crlf].every((ch) => {
308
+ const code = ch.charCodeAt(0);
309
+ return code === 13 || code === 10 || code === 9 || code >= 32 && code <= 126;
310
+ }) && crlf.split("\r\n").every((line) => line.length <= 998) ? {
311
+ encoding: "7bit",
312
+ body: crlf
313
+ } : {
314
+ encoding: "quoted-printable",
315
+ body: quotedPrintable(text)
316
+ };
317
+ };
318
+ const textPartOf = (contentType, text) => {
319
+ const { encoding, body } = encodeBody(text);
320
+ return [
321
+ `Content-Type: ${contentType}; charset=utf-8`,
322
+ `Content-Transfer-Encoding: ${encoding}`,
323
+ "",
324
+ body,
325
+ ""
326
+ ].join("\r\n");
327
+ };
328
+ /**
329
+ * RFC 2231 for a filename: plain ASCII is a quoted-string, anything else goes as
330
+ * `filename*=utf-8''` with the bytes percent-encoded. Both forms name the same file.
331
+ */
332
+ const filenameParameter = (filename) => isAscii(filename) ? `filename="${filename.replace(/["\\]/g, "\\$&")}"` : `filename*=utf-8''${encodeURIComponent(filename).replace(/[*'()]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`)}`;
333
+ const attachmentPartOf = ({ filename, mimeType, content }) => [
334
+ `Content-Type: ${mimeType}`,
335
+ `Content-Disposition: attachment; ${filenameParameter(filename)}`,
336
+ "Content-Transfer-Encoding: base64",
337
+ "",
338
+ base64Lines(content),
339
+ ""
340
+ ].join("\r\n");
341
+ const multipartOf = (subtype, parts) => {
342
+ const boundary = `=_yozz_${crypto.randomUUID()}`;
343
+ return [
344
+ `Content-Type: multipart/${subtype}; boundary="${boundary}"`,
345
+ "",
346
+ ...parts.flatMap((part) => [`--${boundary}`, part]),
347
+ `--${boundary}--`,
348
+ ""
349
+ ].join("\r\n");
350
+ };
351
+ /**
352
+ * RFC 2047 B-encoding. One encoded-word may be at most 75 characters: `=?utf-8?B?` + `?=` is 12,
353
+ * so 63 of base64, which is 45 bytes of UTF-8. Split on code points so no character straddles
354
+ * two words; the words are folded onto continuation lines, which a reader joins back together.
355
+ */
356
+ const ENCODED_WORD_BYTES = 45;
357
+ const encodedWords = (value) => {
358
+ const words = [];
359
+ let chunk = "";
360
+ for (const ch of value) {
361
+ if (encoder.encode(chunk + ch).length > ENCODED_WORD_BYTES) {
362
+ words.push(chunk);
363
+ chunk = "";
364
+ }
365
+ chunk += ch;
366
+ }
367
+ words.push(chunk);
368
+ return words.map((word) => `=?utf-8?B?${base64(encoder.encode(word))}?=`).join("\r\n ");
369
+ };
370
+ /** RFC 5322 §2.2.3 folding for plain ASCII: break at spaces so no line passes 78 characters. */
371
+ const foldAscii = (value) => {
372
+ const lines = [];
373
+ let line = "";
374
+ for (const word of value.split(" ")) if (line !== "" && line.length + 1 + word.length > 76) {
375
+ lines.push(line);
376
+ line = word;
377
+ } else line = line === "" ? word : `${line} ${word}`;
378
+ lines.push(line);
379
+ return lines.join("\r\n ");
380
+ };
381
+ const encodeHeaderText = (value) => isAscii(value) ? foldAscii(value) : encodedWords(value);
382
+ /** `Name <addr>` with the name quoted or encoded as its characters require; a bare address otherwise. */
383
+ const formatMailbox = ({ address, name }) => {
384
+ if (name === void 0 || name.trim() === "") return address;
385
+ if (!isAscii(name)) return `${encodedWords(name)} <${address}>`;
386
+ return `${/[()<>[\]:;@\\,."]/.test(name) ? `"${name.replace(/["\\]/g, "\\$&")}"` : name} <${address}>`;
387
+ };
388
+ const DAYS = [
389
+ "Sun",
390
+ "Mon",
391
+ "Tue",
392
+ "Wed",
393
+ "Thu",
394
+ "Fri",
395
+ "Sat"
396
+ ];
397
+ const MONTHS = [
398
+ "Jan",
399
+ "Feb",
400
+ "Mar",
401
+ "Apr",
402
+ "May",
403
+ "Jun",
404
+ "Jul",
405
+ "Aug",
406
+ "Sep",
407
+ "Oct",
408
+ "Nov",
409
+ "Dec"
410
+ ];
411
+ const two = (n) => String(n).padStart(2, "0");
412
+ /** RFC 5322 §3.3, in the caller's local zone so the header says when the sender wrote it. */
413
+ const formatDate = (date) => {
414
+ const offset = -date.getTimezoneOffset();
415
+ const zone = `${offset < 0 ? "-" : "+"}${two(Math.floor(Math.abs(offset) / 60))}${two(Math.abs(offset) % 60)}`;
416
+ return `${DAYS[date.getDay()]}, ${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())} ${zone}`;
417
+ };
418
+ /** A header value may not contain CR or LF: a field that could end the header block is refused. */
419
+ const assertNoLineBreak = (field, value) => {
420
+ if (/[\r\n]/.test(value)) throw new Error(`${field} contains a line break`);
421
+ };
422
+ const buildMessage = (input) => {
423
+ for (const recipient of input.to) assertNoLineBreak("To", recipient);
424
+ for (const recipient of input.cc ?? []) assertNoLineBreak("Cc", recipient);
425
+ assertNoLineBreak("From", input.from.address);
426
+ assertNoLineBreak("From", input.from.name ?? "");
427
+ assertNoLineBreak("Message-ID", input.messageId);
428
+ assertNoLineBreak("In-Reply-To", input.inReplyTo ?? "");
429
+ const headers = [["From", formatMailbox(input.from)], ...input.to.length === 0 ? [] : [["To", input.to.join(", ")]]];
430
+ const cc = input.cc ?? [];
431
+ if (cc.length > 0) headers.push(["Cc", cc.join(", ")]);
432
+ headers.push(["Subject", encodeHeaderText(input.subject)], ["Date", formatDate(input.date)], ["Message-ID", input.messageId], ["MIME-Version", "1.0"]);
433
+ if (input.inReplyTo !== void 0) headers.push(["In-Reply-To", input.inReplyTo], ["References", input.inReplyTo]);
434
+ for (const { filename, mimeType } of input.attachments ?? []) {
435
+ assertNoLineBreak("filename", filename);
436
+ assertNoLineBreak("Content-Type", mimeType);
437
+ }
438
+ const textPart = textPartOf("text/plain", input.text);
439
+ const bodyPart = input.html === void 0 ? textPart : multipartOf("alternative", [textPart, textPartOf("text/html", input.html)]);
440
+ const attachments = input.attachments ?? [];
441
+ const body = attachments.length === 0 ? bodyPart : multipartOf("mixed", [bodyPart, ...attachments.map(attachmentPartOf)]);
442
+ const headerBlock = headers.map(([name, value]) => `${name}: ${value}`).join("\r\n");
443
+ return encoder.encode(`${headerBlock}\r\n${body}`);
444
+ };
445
+ //#endregion
446
+ export { buildMessage, createSmtpClient, dotStuff, encodeHeaderText, formatDate, formatMailbox };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@yozz.app/smtp",
3
+ "version": "0.1.0",
4
+ "description": "Transport-agnostic SMTP client core plus an RFC 5322 message builder.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/fishballapp/yozz",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/fishballapp/yozz.git",
10
+ "directory": "packages/smtp"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=24"
18
+ },
19
+ "type": "module",
20
+ "main": "./dist/index.mjs",
21
+ "types": "./dist/index.d.mts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.mts",
25
+ "default": "./dist/index.mjs"
26
+ }
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@yozz.app/tls": "0.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^24.0.0",
36
+ "tsdown": "^0.22.3",
37
+ "@yozz.app/x509": "0.1.0"
38
+ },
39
+ "scripts": {
40
+ "build": "tsdown src/index.ts --format esm --dts",
41
+ "test": "vitest run",
42
+ "live": "node harness/live.ts"
43
+ }
44
+ }