@mostajs/smtp-lite 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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog — @mostajs/smtp-lite
2
+
3
+ ## 0.1.0 — 2026-06-21
4
+ - Première version. Transport SMTP zéro-dépendance (`node:net` + `node:tls`) : 587 STARTTLS + AUTH LOGIN.
5
+ - API : `createSmtpTransport`, `sendMail`, `encodeSubject`, `encodeDisplayName`.
6
+ - Encodage RFC 2047 du `Subject` et du nom d'affichage `From` (corrige les rejets `554 Transaction failed / policy restrictions` sur en-têtes non-ASCII).
7
+ - Lecture SMTP robuste (file d'attente des réponses, timeouts, erreurs socket), dot-stuffing RFC 5321.
8
+ - Extrait du client SMTP initialement écrit inline dans l'app Hadhinat, conformément au principe « le SMTP est un métier → son propre module » ; consommé par `@mostajs/mailer` (driver `smtp-lite`).
9
+ - 4 tests `@mostajs/mjs-unit` verts + validation d'envoi réel (IONOS).
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @mostajs/smtp-lite
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · Licence : AGPL-3.0-or-later
4
+
5
+ Transport SMTP minimal **zéro-dépendance** (uniquement `node:net` + `node:tls`) : soumission sur le port 587 avec **STARTTLS** et **AUTH LOGIN**. Variante « lite » (comme `@mostajs/auth-lite`) pour les apps **zéro-build / vendorisées / WebContainer**, sans `nodemailer` ni addon natif.
6
+
7
+ > Pour un dispatcher complet multi-drivers (Resend, SES, Postmark, Brevo, logs, idempotence), voir **`@mostajs/mailer`** — qui peut justement **consommer ce module** via son driver `smtp-lite`.
8
+
9
+ ## API
10
+
11
+ ```js
12
+ import { createSmtpTransport, sendMail, encodeSubject } from '@mostajs/smtp-lite';
13
+
14
+ // Transport réutilisable
15
+ const t = createSmtpTransport({ host: 'smtp.ionos.fr', port: 587, user: 'admin@amia.fr', pass: '***' });
16
+ await t.send({ from: 'admin@amia.fr', fromName: 'Hadhinat', to: 'x@y.z', subject: 'Bonjour', html: '<p>…</p>' });
17
+
18
+ // Ou en une fois
19
+ await sendMail({ host, port, user, pass }, { from, fromName, to, subject, html, text });
20
+ ```
21
+
22
+ - `createSmtpTransport(server) → { server, send(mail) }`
23
+ - `sendMail(server, mail) → { ok: true }` (throw avec le code SMTP en cas de rejet)
24
+ - `encodeSubject(s)` / `encodeDisplayName(name)` : encodage **RFC 2047** des en-têtes non-ASCII (évite les rejets `554` sur sujets/noms accentués).
25
+
26
+ ## Notes
27
+
28
+ - Les en-têtes `Subject` et le nom d'affichage `From` sont encodés RFC 2047 si non-ASCII (sinon certains serveurs renvoient `554 Transaction failed`).
29
+ - Dot-stuffing RFC 5321 appliqué au corps.
30
+ - Timeouts (connexion + réponse) et propagation d'erreur socket intégrés.
package/llms.txt ADDED
@@ -0,0 +1,20 @@
1
+ # @mostajs/smtp-lite
2
+
3
+ Transport SMTP zéro-dépendance (node:net+tls, 587 STARTTLS, AUTH LOGIN). Variante "lite" sans nodemailer ni natif, pour apps zéro-build/WebContainer. Auteur : Dr Hamid MADANI <drmdh@msn.com>. AGPL-3.0-or-later.
4
+
5
+ ## Exports (./src/index.js)
6
+ - createSmtpTransport({host,port=587,user,pass}) -> { server, send(mail) }
7
+ - sendMail({host,port,user,pass}, {from,fromName?,to,subject,html?,text?}) -> {ok:true} ; throw avec code SMTP si rejet
8
+ - encodeSubject(s) -> "=?UTF-8?B?...?=" (RFC 2047)
9
+ - encodeDisplayName(name) -> nom d'affichage sûr (encoded-word si non-ASCII, sinon quoted-string)
10
+
11
+ ## Points clés
12
+ - Encodage RFC 2047 du Subject ET du nom d'affichage From -> évite "554 Transaction failed / Reject due to policy restrictions" sur en-têtes accentués (tiret cadratin, é, apostrophe...).
13
+ - Dot-stuffing RFC 5321 du corps.
14
+ - Lecture SMTP robuste : réponses mises en file (pas de race read/data), timeouts, erreurs socket propagées.
15
+
16
+ ## Relation avec @mostajs/mailer
17
+ @mostajs/mailer expose createSmtpLiteDriver() + createMailerFromProcessEnv() qui CONSOMMENT ce module comme driver zéro-dépendance (alternative au driver nodemailer createSmtpDriver).
18
+
19
+ ## Quand l'utiliser
20
+ App Node sans build/npm install (vendorisée), ou contexte WebContainer/edge. Sinon préférer @mostajs/mailer (multi-drivers, persistance, idempotence).
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@mostajs/smtp-lite",
3
+ "version": "0.1.0",
4
+ "description": "Client SMTP minimal ZÉRO-DÉPENDANCE (node:net+tls, soumission 587 STARTTLS, AUTH LOGIN). Variante « lite » sans natif ni npm (comme @mostajs/auth-lite) pour apps zéro-build / WebContainer. Pour les drivers riches (Resend/SES/Brevo…) voir @mostajs/mailer.",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "files": [
10
+ "src",
11
+ "llms.txt",
12
+ "README.md",
13
+ "CHANGELOG.md"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.js"
17
+ },
18
+ "keywords": [
19
+ "mostajs",
20
+ "smtp",
21
+ "email",
22
+ "mailer",
23
+ "starttls",
24
+ "zero-dependency",
25
+ "lite"
26
+ ],
27
+ "devDependencies": {
28
+ "@mostajs/mjs-unit": "^0.3.0"
29
+ },
30
+ "scripts": {
31
+ "test": "bash test-scripts/run-tests.sh"
32
+ }
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,93 @@
1
+ // @mostajs/smtp-lite — transport SMTP minimal ZÉRO-DÉPENDANCE (node:net + node:tls). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // Soumission RFC : connexion 587 → EHLO → STARTTLS → EHLO → AUTH LOGIN → MAIL/RCPT/DATA. Aucune dépendance npm, aucun natif.
3
+ // Variante « lite » (cf. @mostajs/auth-lite) pour apps zéro-build / WebContainer. Drivers riches (Resend/SES/Brevo…) : @mostajs/mailer.
4
+ import net from 'node:net';
5
+ import tls from 'node:tls';
6
+ import crypto from 'node:crypto';
7
+
8
+ // Connexion robuste : les réponses arrivant AVANT read() sont mises en file (pas de race) ; erreurs socket + timeout propagés.
9
+ function smtpConn(sock, timeoutMs = 20000) {
10
+ let buf = ''; let waiter = null; const queue = []; let failed = null;
11
+ const flush = () => {
12
+ const lines = buf.split(/\r?\n/).filter(Boolean);
13
+ const last = lines[lines.length - 1] || '';
14
+ if (/^\d{3} /.test(last)) { // réponse complète = dernière ligne "NNN " (espace après le code)
15
+ const out = buf; buf = '';
16
+ if (waiter) { const w = waiter; waiter = null; clearTimeout(w.t); w.res(out); } else queue.push(out);
17
+ }
18
+ };
19
+ sock.on('data', (d) => { buf += d.toString('utf8'); flush(); });
20
+ sock.on('error', (e) => { failed = e; if (waiter) { const w = waiter; waiter = null; clearTimeout(w.t); w.rej(e); } });
21
+ const read = () => new Promise((res, rej) => {
22
+ if (failed) return rej(failed);
23
+ if (queue.length) return res(queue.shift());
24
+ const t = setTimeout(() => { waiter = null; rej(new Error('SMTP timeout (pas de réponse)')); }, timeoutMs);
25
+ waiter = { res, rej, t };
26
+ });
27
+ const send = (line) => new Promise((res, rej) => sock.write(line + '\r\n', (e) => (e ? rej(e) : res())));
28
+ return { read, send };
29
+ }
30
+ const expect = (resp, codes) => {
31
+ const code = Number((resp.match(/^(\d{3})/) || [])[1]);
32
+ if (!codes.includes(code)) throw new Error(`SMTP réponse inattendue (${code}): ${resp.trim().slice(0, 200)}`);
33
+ return code;
34
+ };
35
+
36
+ /** Encode une valeur d'en-tête non-ASCII en =?UTF-8?B?…?= (RFC 2047). */
37
+ export function encodeSubject(s) { return '=?UTF-8?B?' + Buffer.from(String(s), 'utf8').toString('base64') + '?='; }
38
+
39
+ /** Nom d'affichage d'adresse : encoded-word si non-ASCII (RFC 2047), sinon "quoted-string". Évite les rejets 554 (en-tête 8-bit). */
40
+ export function encodeDisplayName(name) {
41
+ if (!name) return '';
42
+ return /[^\x20-\x7E]/.test(name) ? encodeSubject(name) : '"' + String(name).replace(/["\\]/g, '') + '"';
43
+ }
44
+
45
+ /**
46
+ * Envoie un email via SMTP (STARTTLS 587, AUTH LOGIN). Bas niveau, sans état.
47
+ * @param {{host,port?,user,pass}} server
48
+ * @param {{from,fromName?,to,subject,html?,text?}} mail
49
+ */
50
+ export async function sendMail(server, mail) {
51
+ const { host, port = 587, user, pass } = server || {};
52
+ const { from, fromName, to, subject, html, text } = mail || {};
53
+ if (!host || !user || !pass) throw new Error('SMTP non configuré (host/user/pass requis)');
54
+ if (!to) throw new Error('sendMail: destinataire (to) requis');
55
+ const plain = net.connect({ host, port });
56
+ plain.setTimeout(20000, () => plain.destroy(new Error('SMTP timeout (connexion)')));
57
+ await new Promise((res, rej) => { plain.once('connect', res); plain.once('error', rej); });
58
+ let c = smtpConn(plain);
59
+ expect(await c.read(), [220]);
60
+ await c.send('EHLO mostajs'); expect(await c.read(), [250]);
61
+ await c.send('STARTTLS'); expect(await c.read(), [220]);
62
+ const sock = tls.connect({ socket: plain, servername: host });
63
+ await new Promise((res, rej) => { sock.once('secureConnect', res); sock.once('error', rej); });
64
+ c = smtpConn(sock);
65
+ await c.send('EHLO mostajs'); expect(await c.read(), [250]);
66
+ await c.send('AUTH LOGIN'); expect(await c.read(), [334]);
67
+ await c.send(Buffer.from(user, 'utf8').toString('base64')); expect(await c.read(), [334]);
68
+ await c.send(Buffer.from(pass, 'utf8').toString('base64')); expect(await c.read(), [235]);
69
+ await c.send(`MAIL FROM:<${from || user}>`); expect(await c.read(), [250]);
70
+ await c.send(`RCPT TO:<${to}>`); expect(await c.read(), [250, 251]);
71
+ await c.send('DATA'); expect(await c.read(), [354]);
72
+ const disp = encodeDisplayName(fromName);
73
+ const headers = [
74
+ `From: ${disp ? disp + ' ' : ''}<${from || user}>`,
75
+ `To: <${to}>`,
76
+ `Subject: ${encodeSubject(subject || '')}`,
77
+ `Date: ${new Date().toUTCString()}`,
78
+ `Message-ID: <${crypto.randomBytes(12).toString('hex')}@mostajs>`,
79
+ 'MIME-Version: 1.0',
80
+ 'Content-Type: text/html; charset=utf-8',
81
+ 'Content-Transfer-Encoding: 8bit',
82
+ ].join('\r\n');
83
+ const body = (html || text || '').replace(/\r?\n\./g, '\n..'); // dot-stuffing RFC 5321
84
+ await c.send(headers + '\r\n\r\n' + body + '\r\n.'); expect(await c.read(), [250]);
85
+ await c.send('QUIT').catch(() => {});
86
+ try { sock.end(); } catch { /* ignore */ }
87
+ return { ok: true };
88
+ }
89
+
90
+ /** Transport réutilisable lié à un serveur SMTP : `createSmtpTransport(server).send(mail)`. */
91
+ export function createSmtpTransport(server) {
92
+ return { server, send: (mail) => sendMail(server, mail) };
93
+ }