mikromail 0.0.1

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.
@@ -0,0 +1,541 @@
1
+ import {
2
+ encodeQuotedPrintable,
3
+ validateEmail
4
+ } from "./chunk-UDLJWUFN.mjs";
5
+
6
+ // src/SMTPClient.ts
7
+ import { Buffer } from "node:buffer";
8
+ import crypto from "node:crypto";
9
+ import net from "node:net";
10
+ import os from "node:os";
11
+ import tls from "node:tls";
12
+ var SMTPClient = class {
13
+ config;
14
+ socket;
15
+ connected;
16
+ lastCommand;
17
+ serverCapabilities;
18
+ secureMode;
19
+ retryCount;
20
+ constructor(config) {
21
+ this.config = {
22
+ host: config.host,
23
+ user: config.user,
24
+ password: config.password,
25
+ port: config.port ?? (config.secure ? 465 : 587),
26
+ secure: config.secure ?? true,
27
+ debug: config.debug ?? false,
28
+ timeout: config.timeout ?? 1e4,
29
+ clientName: config.clientName ?? os.hostname(),
30
+ maxRetries: config.maxRetries ?? 3,
31
+ retryDelay: config.retryDelay ?? 1e3,
32
+ skipAuthentication: config.skipAuthentication || false
33
+ };
34
+ this.socket = null;
35
+ this.connected = false;
36
+ this.lastCommand = "";
37
+ this.serverCapabilities = [];
38
+ this.secureMode = this.config.secure;
39
+ this.retryCount = 0;
40
+ }
41
+ /**
42
+ * Log debug messages if debug mode is enabled
43
+ */
44
+ log(message, isError = false) {
45
+ if (this.config.debug) {
46
+ const prefix = isError ? "SMTP ERROR: " : "SMTP: ";
47
+ console.log(`${prefix}${message}`);
48
+ }
49
+ }
50
+ /**
51
+ * Connect to the SMTP server
52
+ */
53
+ async connect() {
54
+ return new Promise((resolve, reject) => {
55
+ const connectionTimeout = setTimeout(() => {
56
+ reject(new Error(`Connection timeout after ${this.config.timeout}ms`));
57
+ this.socket?.destroy();
58
+ }, this.config.timeout);
59
+ try {
60
+ if (this.config.secure) {
61
+ this.createTLSConnection(connectionTimeout, resolve, reject);
62
+ } else {
63
+ this.createPlainConnection(connectionTimeout, resolve, reject);
64
+ }
65
+ } catch (error) {
66
+ clearTimeout(connectionTimeout);
67
+ this.log(`Failed to create socket: ${error.message}`, true);
68
+ reject(error);
69
+ }
70
+ });
71
+ }
72
+ /**
73
+ * Create a secure TLS connection
74
+ */
75
+ createTLSConnection(connectionTimeout, resolve, reject) {
76
+ this.socket = tls.connect({
77
+ host: this.config.host,
78
+ port: this.config.port,
79
+ rejectUnauthorized: true,
80
+ // Always validate TLS certificates
81
+ minVersion: "TLSv1.2",
82
+ // Enforce TLS 1.2 or higher
83
+ ciphers: "HIGH:!aNULL:!MD5:!RC4"
84
+ });
85
+ this.setupSocketEventHandlers(connectionTimeout, resolve, reject);
86
+ }
87
+ /**
88
+ * Create a plain socket connection (for later STARTTLS upgrade)
89
+ */
90
+ createPlainConnection(connectionTimeout, resolve, reject) {
91
+ this.socket = net.createConnection({
92
+ host: this.config.host,
93
+ port: this.config.port
94
+ });
95
+ this.setupSocketEventHandlers(connectionTimeout, resolve, reject);
96
+ }
97
+ /**
98
+ * Set up common socket event handlers
99
+ */
100
+ setupSocketEventHandlers(connectionTimeout, resolve, reject) {
101
+ if (!this.socket) return;
102
+ this.socket.once("error", (err) => {
103
+ clearTimeout(connectionTimeout);
104
+ this.log(`Connection error: ${err.message}`, true);
105
+ reject(new Error(`SMTP connection error: ${err.message}`));
106
+ });
107
+ this.socket.once("connect", () => {
108
+ this.log("Connected to SMTP server");
109
+ clearTimeout(connectionTimeout);
110
+ this.socket.once("data", (data) => {
111
+ const greeting = data.toString().trim();
112
+ this.log(`Server greeting: ${greeting}`);
113
+ if (greeting.startsWith("220")) {
114
+ this.connected = true;
115
+ this.secureMode = this.config.secure;
116
+ resolve();
117
+ } else {
118
+ reject(new Error(`Unexpected server greeting: ${greeting}`));
119
+ this.socket.destroy();
120
+ }
121
+ });
122
+ });
123
+ this.socket.once("close", (hadError) => {
124
+ if (this.connected) {
125
+ this.log(`Connection closed${hadError ? " with error" : ""}`);
126
+ } else {
127
+ clearTimeout(connectionTimeout);
128
+ reject(new Error("Connection closed before initialization completed"));
129
+ }
130
+ this.connected = false;
131
+ });
132
+ }
133
+ /**
134
+ * Upgrade connection to TLS using STARTTLS
135
+ */
136
+ async upgradeToTLS() {
137
+ if (!this.socket || this.secureMode) return;
138
+ return new Promise((resolve, reject) => {
139
+ const plainSocket = this.socket;
140
+ const tlsOptions = {
141
+ socket: plainSocket,
142
+ host: this.config.host,
143
+ rejectUnauthorized: true,
144
+ minVersion: "TLSv1.2",
145
+ ciphers: "HIGH:!aNULL:!MD5:!RC4"
146
+ };
147
+ const tlsSocket = tls.connect(tlsOptions);
148
+ tlsSocket.once("error", (err) => {
149
+ this.log(`TLS upgrade error: ${err.message}`, true);
150
+ reject(new Error(`STARTTLS error: ${err.message}`));
151
+ });
152
+ tlsSocket.once("secureConnect", () => {
153
+ this.log("Connection upgraded to TLS");
154
+ if (tlsSocket.authorized) {
155
+ this.socket = tlsSocket;
156
+ this.secureMode = true;
157
+ resolve();
158
+ } else {
159
+ reject(
160
+ new Error(
161
+ `TLS certificate verification failed: ${tlsSocket.authorizationError}`
162
+ )
163
+ );
164
+ }
165
+ });
166
+ });
167
+ }
168
+ /**
169
+ * Send an SMTP command and await response
170
+ */
171
+ async sendCommand(command, expectedCode, timeout = this.config.timeout) {
172
+ if (!this.socket || !this.connected) {
173
+ throw new Error("Not connected to SMTP server");
174
+ }
175
+ return new Promise((resolve, reject) => {
176
+ const commandTimeout = setTimeout(() => {
177
+ this.socket?.removeListener("data", onData);
178
+ reject(new Error(`Command timeout after ${timeout}ms: ${command}`));
179
+ }, timeout);
180
+ let responseData = "";
181
+ const onData = (chunk) => {
182
+ responseData += chunk.toString();
183
+ const lines = responseData.split("\r\n");
184
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
185
+ const lastLine = lines[lines.length - 2] || "";
186
+ const matches = /^(\d{3})(.?)/.exec(lastLine);
187
+ if (matches?.[1] && matches[2] !== "-") {
188
+ this.socket?.removeListener("data", onData);
189
+ clearTimeout(commandTimeout);
190
+ this.log(`SMTP Response: ${responseData.trim()}`);
191
+ if (matches[1] === expectedCode.toString()) {
192
+ resolve(responseData.trim());
193
+ } else {
194
+ reject(new Error(`SMTP Error: ${responseData.trim()}`));
195
+ }
196
+ }
197
+ }
198
+ };
199
+ this.socket.on("data", onData);
200
+ if (command.startsWith("AUTH PLAIN") || command.startsWith("AUTH LOGIN") || this.lastCommand === "AUTH LOGIN" && !command.startsWith("AUTH")) {
201
+ this.log("SMTP Command: [Credentials hidden]");
202
+ } else {
203
+ this.log(`SMTP Command: ${command}`);
204
+ }
205
+ this.lastCommand = command;
206
+ this.socket.write(`${command}\r
207
+ `);
208
+ });
209
+ }
210
+ /**
211
+ * Parse EHLO response to determine server capabilities
212
+ */
213
+ parseCapabilities(ehloResponse) {
214
+ const lines = ehloResponse.split("\r\n");
215
+ this.serverCapabilities = [];
216
+ for (let i = 1; i < lines.length; i++) {
217
+ const line = lines[i];
218
+ if (line.match(/^\d{3}/) && line.charAt(3) === " ") {
219
+ const capability = line.substr(4).toUpperCase();
220
+ this.serverCapabilities.push(capability);
221
+ }
222
+ }
223
+ this.log(`Server capabilities: ${this.serverCapabilities.join(", ")}`);
224
+ }
225
+ /**
226
+ * Determine the best authentication method supported by the server
227
+ */
228
+ getBestAuthMethod() {
229
+ const capabilities = this.serverCapabilities.map(
230
+ (cap) => cap.split(" ")[0]
231
+ );
232
+ if (capabilities.includes("AUTH")) {
233
+ const authLine = this.serverCapabilities.find(
234
+ (cap) => cap.startsWith("AUTH ")
235
+ );
236
+ if (authLine) {
237
+ const methods = authLine.split(" ").slice(1);
238
+ if (methods.includes("CRAM-MD5")) return "CRAM-MD5";
239
+ if (methods.includes("LOGIN")) return "LOGIN";
240
+ if (methods.includes("PLAIN")) return "PLAIN";
241
+ }
242
+ }
243
+ return "PLAIN";
244
+ }
245
+ /**
246
+ * Authenticate with the SMTP server using the best available method
247
+ */
248
+ async authenticate() {
249
+ const authMethod = this.getBestAuthMethod();
250
+ switch (authMethod) {
251
+ case "CRAM-MD5":
252
+ await this.authenticateCramMD5();
253
+ break;
254
+ case "LOGIN":
255
+ await this.authenticateLogin();
256
+ break;
257
+ default:
258
+ await this.authenticatePlain();
259
+ break;
260
+ }
261
+ }
262
+ /**
263
+ * Authenticate using PLAIN method
264
+ */
265
+ async authenticatePlain() {
266
+ const authPlain = Buffer.from(
267
+ `\0${this.config.user}\0${this.config.password}`
268
+ ).toString("base64");
269
+ await this.sendCommand(`AUTH PLAIN ${authPlain}`, 235);
270
+ }
271
+ /**
272
+ * Authenticate using LOGIN method
273
+ */
274
+ async authenticateLogin() {
275
+ await this.sendCommand("AUTH LOGIN", 334);
276
+ await this.sendCommand(
277
+ Buffer.from(this.config.user).toString("base64"),
278
+ 334
279
+ );
280
+ await this.sendCommand(
281
+ Buffer.from(this.config.password).toString("base64"),
282
+ 235
283
+ );
284
+ }
285
+ /**
286
+ * Authenticate using CRAM-MD5 method
287
+ */
288
+ async authenticateCramMD5() {
289
+ const response = await this.sendCommand("AUTH CRAM-MD5", 334);
290
+ const challenge = Buffer.from(response.substr(4), "base64").toString(
291
+ "utf8"
292
+ );
293
+ const hmac = crypto.createHmac("md5", this.config.password);
294
+ hmac.update(challenge);
295
+ const digest = hmac.digest("hex");
296
+ const cramResponse = `${this.config.user} ${digest}`;
297
+ const encodedResponse = Buffer.from(cramResponse).toString("base64");
298
+ await this.sendCommand(encodedResponse, 235);
299
+ }
300
+ /**
301
+ * Generate a unique message ID
302
+ */
303
+ generateMessageId() {
304
+ const random = crypto.randomBytes(16).toString("hex");
305
+ const domain = this.config.user.split("@")[1] || "localhost";
306
+ return `<${random}@${domain}>`;
307
+ }
308
+ /**
309
+ * Generate MIME boundary for multipart messages
310
+ */
311
+ generateBoundary() {
312
+ return `----=_NextPart_${crypto.randomBytes(12).toString("hex")}`;
313
+ }
314
+ /**
315
+ * Encode string according to RFC 2047 for headers with non-ASCII characters
316
+ */
317
+ encodeHeaderValue(value) {
318
+ if (/^[\x00-\x7F]*$/.test(value)) {
319
+ return value;
320
+ }
321
+ return `=?UTF-8?Q?${// biome-ignore lint/suspicious/noControlCharactersInRegex: <explanation>
322
+ value.replace(/[^\x00-\x7F]/g, (c) => {
323
+ const hex = c.charCodeAt(0).toString(16).toUpperCase();
324
+ return `=${hex.length < 2 ? `0${hex}` : hex}`;
325
+ })}?=`;
326
+ }
327
+ /**
328
+ * Sanitize and encode header value to prevent injection and handle internationalization
329
+ */
330
+ sanitizeHeader(value) {
331
+ const sanitized = value.replace(/[\r\n]+/g, " ");
332
+ return this.encodeHeaderValue(sanitized);
333
+ }
334
+ /**
335
+ * Create email headers with proper sanitization
336
+ */
337
+ createEmailHeaders(options) {
338
+ const messageId = this.generateMessageId();
339
+ const date = (/* @__PURE__ */ new Date()).toUTCString();
340
+ const from = options.from || this.config.user;
341
+ const { to } = options;
342
+ const headers = [
343
+ `From: ${this.sanitizeHeader(from)}`,
344
+ `To: ${this.sanitizeHeader(to)}`,
345
+ `Subject: ${this.sanitizeHeader(options.subject)}`,
346
+ `Message-ID: ${messageId}`,
347
+ `Date: ${date}`,
348
+ "MIME-Version: 1.0"
349
+ ];
350
+ if (options.cc) {
351
+ const cc = Array.isArray(options.cc) ? options.cc.join(", ") : options.cc;
352
+ headers.push(`Cc: ${this.sanitizeHeader(cc)}`);
353
+ }
354
+ if (options.replyTo) {
355
+ headers.push(`Reply-To: ${this.sanitizeHeader(options.replyTo)}`);
356
+ }
357
+ if (options.headers) {
358
+ for (const [name, value] of Object.entries(options.headers)) {
359
+ if (!/^[a-zA-Z0-9-]+$/.test(name)) continue;
360
+ if (/^(from|to|cc|bcc|subject|date|message-id)$/i.test(name)) continue;
361
+ headers.push(`${name}: ${this.sanitizeHeader(value)}`);
362
+ }
363
+ }
364
+ return headers;
365
+ }
366
+ /**
367
+ * Create a multipart email with text and HTML parts
368
+ */
369
+ createMultipartEmail(options) {
370
+ const { text, html } = options;
371
+ const headers = this.createEmailHeaders(options);
372
+ const boundary = this.generateBoundary();
373
+ if (html && text) {
374
+ headers.push(
375
+ `Content-Type: multipart/alternative; boundary="${boundary}"`
376
+ );
377
+ return `${headers.join("\r\n")}\r
378
+ \r
379
+ --${boundary}\r
380
+ Content-Type: text/plain; charset=utf-8\r
381
+ Content-Transfer-Encoding: quoted-printable\r
382
+ \r
383
+ ${encodeQuotedPrintable(text || "")}\r
384
+ \r
385
+ --${boundary}\r
386
+ Content-Type: text/html; charset=utf-8\r
387
+ Content-Transfer-Encoding: quoted-printable\r
388
+ \r
389
+ ${encodeQuotedPrintable(html || "")}\r
390
+ \r
391
+ --${boundary}--\r
392
+ `;
393
+ }
394
+ if (html) {
395
+ headers.push("Content-Type: text/html; charset=utf-8");
396
+ headers.push("Content-Transfer-Encoding: quoted-printable");
397
+ return `${headers.join("\r\n")}\r
398
+ \r
399
+ ${encodeQuotedPrintable(html)}`;
400
+ }
401
+ headers.push("Content-Type: text/plain; charset=utf-8");
402
+ headers.push("Content-Transfer-Encoding: quoted-printable");
403
+ return `${headers.join("\r\n")}\r
404
+ \r
405
+ ${encodeQuotedPrintable(text || "")}`;
406
+ }
407
+ /**
408
+ * Perform full SMTP handshake, including STARTTLS if needed
409
+ */
410
+ async smtpHandshake() {
411
+ const ehloResponse = await this.sendCommand(
412
+ `EHLO ${this.config.clientName}`,
413
+ 250
414
+ );
415
+ this.parseCapabilities(ehloResponse);
416
+ if (!this.secureMode && this.serverCapabilities.includes("STARTTLS")) {
417
+ await this.sendCommand("STARTTLS", 220);
418
+ await this.upgradeToTLS();
419
+ const secureEhloResponse = await this.sendCommand(
420
+ `EHLO ${this.config.clientName}`,
421
+ 250
422
+ );
423
+ this.parseCapabilities(secureEhloResponse);
424
+ }
425
+ if (!this.config.skipAuthentication) {
426
+ await this.authenticate();
427
+ } else {
428
+ this.log("Authentication skipped (testing mode)");
429
+ }
430
+ }
431
+ /**
432
+ * Send an email with retry capability
433
+ */
434
+ async sendEmail(options) {
435
+ const from = options.from || this.config.user;
436
+ const { to, subject } = options;
437
+ const text = options.text || "";
438
+ const html = options.html || "";
439
+ if (!from || !to || !subject || !text && !html) {
440
+ return {
441
+ success: false,
442
+ error: "Missing required email parameters (from, to, subject, and either text or html)"
443
+ };
444
+ }
445
+ if (!validateEmail(from) || !validateEmail(to)) {
446
+ return {
447
+ success: false,
448
+ error: "Invalid email address format"
449
+ };
450
+ }
451
+ for (this.retryCount = 0; this.retryCount <= this.config.maxRetries; this.retryCount++) {
452
+ try {
453
+ if (this.retryCount > 0) {
454
+ this.log(
455
+ `Retrying email send (attempt ${this.retryCount} of ${this.config.maxRetries})...`
456
+ );
457
+ await new Promise(
458
+ (resolve) => setTimeout(resolve, this.config.retryDelay)
459
+ );
460
+ }
461
+ if (!this.connected) {
462
+ await this.connect();
463
+ await this.smtpHandshake();
464
+ }
465
+ await this.sendCommand(`MAIL FROM:<${from}>`, 250);
466
+ await this.sendCommand(`RCPT TO:<${to}>`, 250);
467
+ if (options.cc) {
468
+ const ccList = Array.isArray(options.cc) ? options.cc : [options.cc];
469
+ for (const cc of ccList) {
470
+ if (validateEmail(cc)) {
471
+ await this.sendCommand(`RCPT TO:<${cc}>`, 250);
472
+ }
473
+ }
474
+ }
475
+ if (options.bcc) {
476
+ const bccList = Array.isArray(options.bcc) ? options.bcc : [options.bcc];
477
+ for (const bcc of bccList) {
478
+ if (validateEmail(bcc)) {
479
+ await this.sendCommand(`RCPT TO:<${bcc}>`, 250);
480
+ }
481
+ }
482
+ }
483
+ await this.sendCommand("DATA", 354);
484
+ const emailContent = this.createMultipartEmail(options);
485
+ await this.sendCommand(`${emailContent}\r
486
+ .`, 250);
487
+ const messageIdMatch = /Message-ID: (.*)/i.exec(emailContent);
488
+ const messageId = messageIdMatch ? messageIdMatch[1].trim() : void 0;
489
+ return {
490
+ success: true,
491
+ messageId,
492
+ message: "Email sent successfully"
493
+ };
494
+ } catch (error) {
495
+ const errorMessage = error.message;
496
+ this.log(`Error sending email: ${errorMessage}`, true);
497
+ const isPermanentError = errorMessage.includes("5.") || // 5xx SMTP errors are permanent
498
+ errorMessage.includes("Authentication failed") || errorMessage.includes("certificate");
499
+ if (isPermanentError || this.retryCount >= this.config.maxRetries) {
500
+ return {
501
+ success: false,
502
+ error: errorMessage
503
+ };
504
+ }
505
+ try {
506
+ if (this.connected) {
507
+ await this.sendCommand("RSET", 250);
508
+ }
509
+ } catch (_error) {
510
+ }
511
+ this.socket?.end();
512
+ this.connected = false;
513
+ }
514
+ }
515
+ return {
516
+ success: false,
517
+ error: "Maximum retry count exceeded"
518
+ };
519
+ }
520
+ /**
521
+ * Close the connection gracefully
522
+ */
523
+ async close() {
524
+ try {
525
+ if (this.connected) {
526
+ await this.sendCommand("QUIT", 221);
527
+ }
528
+ } catch (e) {
529
+ this.log(`Error during QUIT: ${e.message}`, true);
530
+ } finally {
531
+ if (this.socket) {
532
+ this.socket.end();
533
+ this.connected = false;
534
+ }
535
+ }
536
+ }
537
+ };
538
+
539
+ export {
540
+ SMTPClient
541
+ };
@@ -0,0 +1,83 @@
1
+ // src/utils/index.ts
2
+ import { promises as dnsPromises } from "node:dns";
3
+ function encodeQuotedPrintable(text) {
4
+ let result = text.replace(/\r?\n/g, "\r\n");
5
+ result = result.replace(/=/g, "=3D");
6
+ const utf8Bytes = new TextEncoder().encode(result);
7
+ let encoded = "";
8
+ let lineLength = 0;
9
+ for (let i = 0; i < utf8Bytes.length; i++) {
10
+ const byte = utf8Bytes[i];
11
+ let chunk = "";
12
+ if (byte >= 33 && byte <= 126 && byte !== 61 || byte === 32) {
13
+ chunk = String.fromCharCode(byte);
14
+ } else if (byte === 13 || byte === 10) {
15
+ chunk = String.fromCharCode(byte);
16
+ if (byte === 10) {
17
+ lineLength = 0;
18
+ }
19
+ } else {
20
+ const hex = byte.toString(16).toUpperCase();
21
+ chunk = `=${hex.length < 2 ? `0${hex}` : hex}`;
22
+ }
23
+ if (lineLength + chunk.length > 75 && !(byte === 13 || byte === 10)) {
24
+ encoded += "=\r\n";
25
+ lineLength = 0;
26
+ }
27
+ encoded += chunk;
28
+ lineLength += chunk.length;
29
+ }
30
+ return encoded;
31
+ }
32
+ function validateEmail(email) {
33
+ try {
34
+ const [localPart, domain] = email.split("@");
35
+ if (!localPart || localPart.length > 64) return false;
36
+ if (localPart.startsWith(".") || localPart.endsWith(".") || localPart.includes(".."))
37
+ return false;
38
+ if (!/^[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~.]+$/.test(localPart)) return false;
39
+ if (!domain || domain.length > 255) return false;
40
+ if (domain.startsWith("[") && domain.endsWith("]")) {
41
+ const ipContent = domain.slice(1, -1);
42
+ if (ipContent.startsWith("IPv6:")) return true;
43
+ const ipv4Regex = /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/;
44
+ return ipv4Regex.test(ipContent);
45
+ }
46
+ if (domain.startsWith(".") || domain.endsWith(".") || domain.includes(".."))
47
+ return false;
48
+ const domainParts = domain.split(".");
49
+ if (domainParts.length < 2 || domainParts[domainParts.length - 1].length < 2)
50
+ return false;
51
+ for (const part of domainParts) {
52
+ if (!part || part.length > 63) return false;
53
+ if (!/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$/.test(part)) return false;
54
+ }
55
+ return true;
56
+ } catch (_error) {
57
+ return false;
58
+ }
59
+ }
60
+ async function verifyMXRecords(domain) {
61
+ try {
62
+ const records = await dnsPromises.resolveMx(domain);
63
+ return !!records && records.length > 0;
64
+ } catch (_error) {
65
+ return false;
66
+ }
67
+ }
68
+ async function verifyEmailDomain(email) {
69
+ try {
70
+ const domain = email.split("@")[1];
71
+ if (!domain) return false;
72
+ return await verifyMXRecords(domain);
73
+ } catch (_error) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ export {
79
+ encodeQuotedPrintable,
80
+ validateEmail,
81
+ verifyMXRecords,
82
+ verifyEmailDomain
83
+ };