tina4-nodejs 3.0.0-rc.2

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.
Files changed (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. package/skills/tina4-maintainer.skill +0 -0
@@ -0,0 +1,822 @@
1
+ /**
2
+ * Tina4 Messenger — SMTP email client using Node.js built-in modules only.
3
+ *
4
+ * Sends email via raw SMTP socket communication (net/tls).
5
+ * No nodemailer, no external dependencies.
6
+ *
7
+ * import { Messenger, createMessenger } from "@tina4/core";
8
+ *
9
+ * const messenger = createMessenger();
10
+ * await messenger.send({ to: "alice@example.com", subject: "Hello", body: "Hi there" });
11
+ */
12
+ import net from "node:net";
13
+ import tls from "node:tls";
14
+ import { readFileSync } from "node:fs";
15
+ import { basename } from "node:path";
16
+ import { randomUUID } from "node:crypto";
17
+
18
+ // ── Types ────────────────────────────────────────────────────
19
+
20
+ export interface SendResult {
21
+ success: boolean;
22
+ message: string;
23
+ id?: string;
24
+ }
25
+
26
+ export interface EmailMessage {
27
+ id: string;
28
+ type: "inbox" | "outbox";
29
+ from: string;
30
+ to: string[];
31
+ cc: string[];
32
+ bcc: string[];
33
+ reply_to?: string;
34
+ subject: string;
35
+ body: string;
36
+ html: boolean;
37
+ attachments: string[];
38
+ date: string;
39
+ read: boolean;
40
+ }
41
+
42
+ interface MessengerOptions {
43
+ host?: string;
44
+ port?: number;
45
+ username?: string;
46
+ password?: string;
47
+ fromAddress?: string;
48
+ fromName?: string;
49
+ useTls?: boolean;
50
+ imapHost?: string;
51
+ imapPort?: number;
52
+ imapUser?: string;
53
+ imapPass?: string;
54
+ }
55
+
56
+ export interface ImapMessage {
57
+ uid: string;
58
+ subject: string;
59
+ from: string;
60
+ to: string;
61
+ date: string;
62
+ snippet: string;
63
+ seen: boolean;
64
+ }
65
+
66
+ export interface ImapFullMessage {
67
+ uid: string;
68
+ subject: string;
69
+ from: string;
70
+ to: string;
71
+ cc: string;
72
+ date: string;
73
+ bodyText: string;
74
+ bodyHtml: string;
75
+ headers: Record<string, string>;
76
+ }
77
+
78
+ interface SendOptions {
79
+ to: string | string[];
80
+ subject: string;
81
+ body: string;
82
+ html?: boolean;
83
+ cc?: string[];
84
+ bcc?: string[];
85
+ replyTo?: string;
86
+ attachments?: string[];
87
+ headers?: Record<string, string>;
88
+ }
89
+
90
+ // ── SMTP helpers ─────────────────────────────────────────────
91
+
92
+ /**
93
+ * Read a single SMTP response line (or multiline continuation).
94
+ * Returns the status code and full response text.
95
+ */
96
+ function readResponse(socket: net.Socket | tls.TLSSocket): Promise<{ code: number; text: string }> {
97
+ return new Promise((resolve, reject) => {
98
+ let buffer = "";
99
+
100
+ const onData = (chunk: Buffer) => {
101
+ buffer += chunk.toString("utf-8");
102
+
103
+ // SMTP multiline: "250-..." continuation, "250 ..." final
104
+ const lines = buffer.split("\r\n");
105
+ for (let i = 0; i < lines.length; i++) {
106
+ const line = lines[i];
107
+ if (line.length < 3) continue;
108
+ const code = parseInt(line.substring(0, 3), 10);
109
+ // Final line has a space after the code
110
+ if (line.length >= 4 && line[3] === " ") {
111
+ socket.removeListener("data", onData);
112
+ socket.removeListener("error", onError);
113
+ resolve({ code, text: buffer.trim() });
114
+ return;
115
+ }
116
+ }
117
+ };
118
+
119
+ const onError = (err: Error) => {
120
+ socket.removeListener("data", onData);
121
+ reject(err);
122
+ };
123
+
124
+ socket.on("data", onData);
125
+ socket.on("error", onError);
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Send a command and read the response.
131
+ */
132
+ function sendCommand(
133
+ socket: net.Socket | tls.TLSSocket,
134
+ command: string,
135
+ ): Promise<{ code: number; text: string }> {
136
+ return new Promise((resolve, reject) => {
137
+ socket.write(command + "\r\n", "utf-8", (err) => {
138
+ if (err) return reject(err);
139
+ readResponse(socket).then(resolve, reject);
140
+ });
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Build an RFC 2822 MIME message.
146
+ */
147
+ function buildMimeMessage(options: {
148
+ from: string;
149
+ fromName?: string;
150
+ to: string[];
151
+ cc: string[];
152
+ subject: string;
153
+ body: string;
154
+ html: boolean;
155
+ replyTo?: string;
156
+ attachments?: string[];
157
+ headers?: Record<string, string>;
158
+ messageId: string;
159
+ }): string {
160
+ const boundary = `----=_Tina4_${Date.now()}_${Math.random().toString(36).substring(2)}`;
161
+ const hasAttachments = options.attachments && options.attachments.length > 0;
162
+ const lines: string[] = [];
163
+
164
+ // Headers
165
+ const fromHeader = options.fromName
166
+ ? `"${options.fromName}" <${options.from}>`
167
+ : options.from;
168
+ lines.push(`From: ${fromHeader}`);
169
+ lines.push(`To: ${options.to.join(", ")}`);
170
+ if (options.cc.length > 0) {
171
+ lines.push(`Cc: ${options.cc.join(", ")}`);
172
+ }
173
+ lines.push(`Subject: ${options.subject}`);
174
+ lines.push(`Date: ${new Date().toUTCString()}`);
175
+ lines.push(`Message-ID: <${options.messageId}>`);
176
+ lines.push("MIME-Version: 1.0");
177
+
178
+ if (options.replyTo) {
179
+ lines.push(`Reply-To: ${options.replyTo}`);
180
+ }
181
+
182
+ // Custom headers
183
+ if (options.headers) {
184
+ for (const [key, value] of Object.entries(options.headers)) {
185
+ lines.push(`${key}: ${value}`);
186
+ }
187
+ }
188
+
189
+ if (hasAttachments) {
190
+ lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
191
+ lines.push("");
192
+ lines.push(`--${boundary}`);
193
+ }
194
+
195
+ // Body part
196
+ if (options.html) {
197
+ if (hasAttachments) {
198
+ lines.push("Content-Type: text/html; charset=UTF-8");
199
+ lines.push("Content-Transfer-Encoding: 7bit");
200
+ lines.push("");
201
+ } else {
202
+ lines.push("Content-Type: text/html; charset=UTF-8");
203
+ lines.push("");
204
+ }
205
+ } else {
206
+ if (hasAttachments) {
207
+ lines.push("Content-Type: text/plain; charset=UTF-8");
208
+ lines.push("Content-Transfer-Encoding: 7bit");
209
+ lines.push("");
210
+ } else {
211
+ lines.push("Content-Type: text/plain; charset=UTF-8");
212
+ lines.push("");
213
+ }
214
+ }
215
+
216
+ lines.push(options.body);
217
+
218
+ // Attachments
219
+ if (hasAttachments && options.attachments) {
220
+ for (const filePath of options.attachments) {
221
+ const fileName = basename(filePath);
222
+ const fileData = readFileSync(filePath);
223
+ const base64Data = fileData.toString("base64");
224
+
225
+ lines.push("");
226
+ lines.push(`--${boundary}`);
227
+ lines.push(`Content-Type: application/octet-stream; name="${fileName}"`);
228
+ lines.push("Content-Transfer-Encoding: base64");
229
+ lines.push(`Content-Disposition: attachment; filename="${fileName}"`);
230
+ lines.push("");
231
+
232
+ // Split base64 into 76-char lines per RFC 2045
233
+ for (let i = 0; i < base64Data.length; i += 76) {
234
+ lines.push(base64Data.substring(i, i + 76));
235
+ }
236
+ }
237
+
238
+ lines.push("");
239
+ lines.push(`--${boundary}--`);
240
+ }
241
+
242
+ return lines.join("\r\n");
243
+ }
244
+
245
+ // ── Messenger ────────────────────────────────────────────────
246
+
247
+ export class Messenger {
248
+ private host: string;
249
+ private port: number;
250
+ private username: string;
251
+ private password: string;
252
+ private fromAddress: string;
253
+ private fromName: string;
254
+ private useTls: boolean;
255
+ private imapHost: string;
256
+ private imapPort: number;
257
+ private imapUser: string;
258
+ private imapPass: string;
259
+
260
+ constructor(options?: MessengerOptions) {
261
+ this.host = options?.host ?? process.env.SMTP_HOST ?? "localhost";
262
+ this.port = options?.port ?? parseInt(process.env.SMTP_PORT ?? "587", 10);
263
+ this.username = options?.username ?? process.env.SMTP_USERNAME ?? "";
264
+ this.password = options?.password ?? process.env.SMTP_PASSWORD ?? "";
265
+ this.fromAddress = options?.fromAddress ?? process.env.SMTP_FROM ?? "";
266
+ this.fromName = options?.fromName ?? process.env.SMTP_FROM_NAME ?? "";
267
+ this.useTls = options?.useTls ?? (process.env.SMTP_USE_TLS !== "false");
268
+ this.imapHost = options?.imapHost ?? process.env.IMAP_HOST ?? "";
269
+ this.imapPort = options?.imapPort ?? parseInt(process.env.IMAP_PORT ?? "993", 10);
270
+ this.imapUser = options?.imapUser ?? process.env.IMAP_USER ?? this.username;
271
+ this.imapPass = options?.imapPass ?? process.env.IMAP_PASS ?? this.password;
272
+ }
273
+
274
+ /**
275
+ * Send an email via SMTP.
276
+ */
277
+ async send(options: SendOptions): Promise<SendResult> {
278
+ const toList = Array.isArray(options.to) ? options.to : [options.to];
279
+ const ccList = options.cc ?? [];
280
+ const bccList = options.bcc ?? [];
281
+ const allRecipients = [...toList, ...ccList, ...bccList];
282
+ const messageId = `${randomUUID()}@${this.host}`;
283
+
284
+ if (allRecipients.length === 0) {
285
+ return { success: false, message: "No recipients specified" };
286
+ }
287
+
288
+ if (!this.fromAddress) {
289
+ return { success: false, message: "No from address configured" };
290
+ }
291
+
292
+ try {
293
+ // Connect
294
+ let socket: net.Socket | tls.TLSSocket;
295
+
296
+ if (this.port === 465) {
297
+ // Implicit TLS (SMTPS)
298
+ socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false });
299
+ await new Promise<void>((resolve, reject) => {
300
+ socket.once("secureConnect", resolve);
301
+ socket.once("error", reject);
302
+ });
303
+ } else {
304
+ socket = net.createConnection({ host: this.host, port: this.port });
305
+ await new Promise<void>((resolve, reject) => {
306
+ socket.once("connect", resolve);
307
+ socket.once("error", reject);
308
+ });
309
+ }
310
+
311
+ // Read greeting
312
+ const greeting = await readResponse(socket);
313
+ if (greeting.code !== 220) {
314
+ socket.destroy();
315
+ return { success: false, message: `SMTP greeting failed: ${greeting.text}` };
316
+ }
317
+
318
+ // EHLO
319
+ const ehlo = await sendCommand(socket, `EHLO ${this.host}`);
320
+ if (ehlo.code !== 250) {
321
+ socket.destroy();
322
+ return { success: false, message: `EHLO failed: ${ehlo.text}` };
323
+ }
324
+
325
+ // STARTTLS upgrade (for port 587 or when useTls is true and not already TLS)
326
+ if (this.useTls && this.port !== 465 && ehlo.text.includes("STARTTLS")) {
327
+ const starttls = await sendCommand(socket, "STARTTLS");
328
+ if (starttls.code !== 220) {
329
+ socket.destroy();
330
+ return { success: false, message: `STARTTLS failed: ${starttls.text}` };
331
+ }
332
+
333
+ // Upgrade to TLS
334
+ const plainSocket = socket as net.Socket;
335
+ socket = tls.connect(
336
+ { socket: plainSocket, host: this.host, rejectUnauthorized: false },
337
+ );
338
+ await new Promise<void>((resolve, reject) => {
339
+ (socket as tls.TLSSocket).once("secureConnect", resolve);
340
+ (socket as tls.TLSSocket).once("error", reject);
341
+ });
342
+
343
+ // Re-EHLO after TLS upgrade
344
+ const ehlo2 = await sendCommand(socket, `EHLO ${this.host}`);
345
+ if (ehlo2.code !== 250) {
346
+ socket.destroy();
347
+ return { success: false, message: `EHLO after STARTTLS failed: ${ehlo2.text}` };
348
+ }
349
+ }
350
+
351
+ // AUTH LOGIN
352
+ if (this.username && this.password) {
353
+ const auth = await sendCommand(socket, "AUTH LOGIN");
354
+ if (auth.code !== 334) {
355
+ socket.destroy();
356
+ return { success: false, message: `AUTH LOGIN failed: ${auth.text}` };
357
+ }
358
+
359
+ const userResp = await sendCommand(socket, Buffer.from(this.username).toString("base64"));
360
+ if (userResp.code !== 334) {
361
+ socket.destroy();
362
+ return { success: false, message: `AUTH username failed: ${userResp.text}` };
363
+ }
364
+
365
+ const passResp = await sendCommand(socket, Buffer.from(this.password).toString("base64"));
366
+ if (passResp.code !== 235) {
367
+ socket.destroy();
368
+ return { success: false, message: `AUTH password failed: ${passResp.text}` };
369
+ }
370
+ }
371
+
372
+ // MAIL FROM
373
+ const mailFrom = await sendCommand(socket, `MAIL FROM:<${this.fromAddress}>`);
374
+ if (mailFrom.code !== 250) {
375
+ socket.destroy();
376
+ return { success: false, message: `MAIL FROM failed: ${mailFrom.text}` };
377
+ }
378
+
379
+ // RCPT TO for all recipients
380
+ for (const recipient of allRecipients) {
381
+ const rcpt = await sendCommand(socket, `RCPT TO:<${recipient}>`);
382
+ if (rcpt.code !== 250 && rcpt.code !== 251) {
383
+ socket.destroy();
384
+ return { success: false, message: `RCPT TO <${recipient}> failed: ${rcpt.text}` };
385
+ }
386
+ }
387
+
388
+ // DATA
389
+ const dataCmd = await sendCommand(socket, "DATA");
390
+ if (dataCmd.code !== 354) {
391
+ socket.destroy();
392
+ return { success: false, message: `DATA failed: ${dataCmd.text}` };
393
+ }
394
+
395
+ // Build and send the MIME message
396
+ const mimeMessage = buildMimeMessage({
397
+ from: this.fromAddress,
398
+ fromName: this.fromName,
399
+ to: toList,
400
+ cc: ccList,
401
+ subject: options.subject,
402
+ body: options.body,
403
+ html: options.html ?? false,
404
+ replyTo: options.replyTo,
405
+ attachments: options.attachments,
406
+ headers: options.headers,
407
+ messageId,
408
+ });
409
+
410
+ // Send message body, terminate with <CRLF>.<CRLF>
411
+ const endData = await sendCommand(socket, mimeMessage + "\r\n.");
412
+ if (endData.code !== 250) {
413
+ socket.destroy();
414
+ return { success: false, message: `Message delivery failed: ${endData.text}` };
415
+ }
416
+
417
+ // QUIT
418
+ await sendCommand(socket, "QUIT");
419
+ socket.destroy();
420
+
421
+ return { success: true, message: "Email sent successfully", id: messageId };
422
+ } catch (err) {
423
+ const errMsg = err instanceof Error ? err.message : String(err);
424
+ return { success: false, message: `SMTP error: ${errMsg}` };
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Test the SMTP connection without sending an email.
430
+ */
431
+ async testConnection(): Promise<{ success: boolean; message: string }> {
432
+ try {
433
+ let socket: net.Socket | tls.TLSSocket;
434
+
435
+ if (this.port === 465) {
436
+ socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false });
437
+ await new Promise<void>((resolve, reject) => {
438
+ socket.once("secureConnect", resolve);
439
+ socket.once("error", reject);
440
+ });
441
+ } else {
442
+ socket = net.createConnection({ host: this.host, port: this.port });
443
+ await new Promise<void>((resolve, reject) => {
444
+ socket.once("connect", resolve);
445
+ socket.once("error", reject);
446
+ });
447
+ }
448
+
449
+ const greeting = await readResponse(socket);
450
+ if (greeting.code !== 220) {
451
+ socket.destroy();
452
+ return { success: false, message: `SMTP greeting failed: ${greeting.text}` };
453
+ }
454
+
455
+ const ehlo = await sendCommand(socket, `EHLO ${this.host}`);
456
+ if (ehlo.code !== 250) {
457
+ socket.destroy();
458
+ return { success: false, message: `EHLO failed: ${ehlo.text}` };
459
+ }
460
+
461
+ await sendCommand(socket, "QUIT");
462
+ socket.destroy();
463
+
464
+ return { success: true, message: `Connected to ${this.host}:${this.port}` };
465
+ } catch (err) {
466
+ const errMsg = err instanceof Error ? err.message : String(err);
467
+ return { success: false, message: `Connection failed: ${errMsg}` };
468
+ }
469
+ }
470
+
471
+ // ── IMAP (Read) ────────────────────────────────────────────
472
+
473
+ /**
474
+ * Connect to the IMAP server via raw TCP/TLS.
475
+ * Returns the socket and reads the greeting.
476
+ */
477
+ private async imapConnect(): Promise<net.Socket | tls.TLSSocket> {
478
+ if (!this.imapHost) {
479
+ throw new Error("IMAP host not configured (set imapHost or IMAP_HOST env)");
480
+ }
481
+
482
+ let socket: net.Socket | tls.TLSSocket;
483
+
484
+ if (this.imapPort === 993) {
485
+ socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: false });
486
+ await new Promise<void>((resolve, reject) => {
487
+ socket.once("secureConnect", resolve);
488
+ socket.once("error", reject);
489
+ });
490
+ } else {
491
+ socket = net.createConnection({ host: this.imapHost, port: this.imapPort });
492
+ await new Promise<void>((resolve, reject) => {
493
+ socket.once("connect", resolve);
494
+ socket.once("error", reject);
495
+ });
496
+ }
497
+
498
+ // Read server greeting
499
+ await imapReadLine(socket);
500
+
501
+ // Login
502
+ if (this.imapUser && this.imapPass) {
503
+ const loginResp = await imapCommand(socket, `LOGIN ${imapQuote(this.imapUser)} ${imapQuote(this.imapPass)}`);
504
+ if (!loginResp.includes("OK")) {
505
+ socket.destroy();
506
+ throw new Error(`IMAP login failed: ${loginResp}`);
507
+ }
508
+ }
509
+
510
+ return socket;
511
+ }
512
+
513
+ /**
514
+ * Disconnect from IMAP cleanly.
515
+ */
516
+ private async imapDisconnect(socket: net.Socket | tls.TLSSocket): Promise<void> {
517
+ try {
518
+ await imapCommand(socket, "LOGOUT");
519
+ } catch { /* ignore */ }
520
+ socket.destroy();
521
+ }
522
+
523
+ /**
524
+ * Fetch latest messages from a folder.
525
+ * Returns list of message summaries.
526
+ */
527
+ async inbox(limit: number = 20, offset: number = 0, folder: string = "INBOX"): Promise<ImapMessage[]> {
528
+ const socket = await this.imapConnect();
529
+ try {
530
+ // Select folder
531
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
532
+
533
+ // Search for all messages
534
+ const searchResp = await imapCommand(socket, "SEARCH ALL");
535
+ const uids = parseSearchResponse(searchResp);
536
+ if (uids.length === 0) return [];
537
+
538
+ // Latest first
539
+ uids.reverse();
540
+ const selected = uids.slice(offset, offset + limit);
541
+ if (selected.length === 0) return [];
542
+
543
+ const messages: ImapMessage[] = [];
544
+ for (const uid of selected) {
545
+ const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
546
+ messages.push(parseHeaderResponse(uid, fetchResp));
547
+ }
548
+
549
+ return messages;
550
+ } finally {
551
+ await this.imapDisconnect(socket);
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Read a single message by sequence number or UID.
557
+ */
558
+ async read(uid: string, folder: string = "INBOX"): Promise<ImapFullMessage> {
559
+ const socket = await this.imapConnect();
560
+ try {
561
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
562
+ const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY[])`);
563
+
564
+ // Mark as seen
565
+ await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
566
+
567
+ return parseFullMessage(uid, fetchResp);
568
+ } finally {
569
+ await this.imapDisconnect(socket);
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Search messages using IMAP search criteria.
575
+ */
576
+ async search(query: string, folder: string = "INBOX"): Promise<ImapMessage[]> {
577
+ const socket = await this.imapConnect();
578
+ try {
579
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
580
+ const searchResp = await imapCommand(socket, `SEARCH ${query}`);
581
+ const uids = parseSearchResponse(searchResp);
582
+ if (uids.length === 0) return [];
583
+
584
+ uids.reverse();
585
+ const messages: ImapMessage[] = [];
586
+ for (const uid of uids.slice(0, 50)) {
587
+ const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
588
+ messages.push(parseHeaderResponse(uid, fetchResp));
589
+ }
590
+ return messages;
591
+ } finally {
592
+ await this.imapDisconnect(socket);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Delete a message by UID.
598
+ */
599
+ async deleteMessage(uid: string, folder: string = "INBOX"): Promise<void> {
600
+ const socket = await this.imapConnect();
601
+ try {
602
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
603
+ await imapCommand(socket, `STORE ${uid} +FLAGS (\\Deleted)`);
604
+ await imapCommand(socket, "EXPUNGE");
605
+ } finally {
606
+ await this.imapDisconnect(socket);
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Mark a message as read.
612
+ */
613
+ async markRead(uid: string, folder: string = "INBOX"): Promise<void> {
614
+ const socket = await this.imapConnect();
615
+ try {
616
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
617
+ await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
618
+ } finally {
619
+ await this.imapDisconnect(socket);
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Count unseen messages in a folder.
625
+ */
626
+ async unread(folder: string = "INBOX"): Promise<number> {
627
+ const socket = await this.imapConnect();
628
+ try {
629
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
630
+ const searchResp = await imapCommand(socket, "SEARCH UNSEEN");
631
+ return parseSearchResponse(searchResp).length;
632
+ } finally {
633
+ await this.imapDisconnect(socket);
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Test IMAP connectivity without reading.
639
+ */
640
+ async testImapConnection(): Promise<{ success: boolean; message: string }> {
641
+ try {
642
+ const socket = await this.imapConnect();
643
+ await this.imapDisconnect(socket);
644
+ return { success: true, message: `Connected to ${this.imapHost}:${this.imapPort}` };
645
+ } catch (err) {
646
+ const errMsg = err instanceof Error ? err.message : String(err);
647
+ return { success: false, message: `IMAP connection failed: ${errMsg}` };
648
+ }
649
+ }
650
+ }
651
+
652
+ // ── IMAP helpers (raw TCP protocol) ──────────────────────────
653
+
654
+ let imapTagCounter = 0;
655
+
656
+ function imapQuote(s: string): string {
657
+ if (/^[a-zA-Z0-9_./-]+$/.test(s)) return s;
658
+ return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
659
+ }
660
+
661
+ function imapReadLine(socket: net.Socket | tls.TLSSocket): Promise<string> {
662
+ return new Promise((resolve, reject) => {
663
+ let buffer = "";
664
+
665
+ const onData = (chunk: Buffer) => {
666
+ buffer += chunk.toString("utf-8");
667
+ const nlIndex = buffer.indexOf("\r\n");
668
+ if (nlIndex !== -1) {
669
+ socket.removeListener("data", onData);
670
+ socket.removeListener("error", onError);
671
+ resolve(buffer);
672
+ }
673
+ };
674
+
675
+ const onError = (err: Error) => {
676
+ socket.removeListener("data", onData);
677
+ reject(err);
678
+ };
679
+
680
+ socket.on("data", onData);
681
+ socket.on("error", onError);
682
+ });
683
+ }
684
+
685
+ function imapCommand(socket: net.Socket | tls.TLSSocket, command: string): Promise<string> {
686
+ return new Promise((resolve, reject) => {
687
+ imapTagCounter++;
688
+ const tag = `T${imapTagCounter}`;
689
+ const fullCommand = `${tag} ${command}\r\n`;
690
+
691
+ let buffer = "";
692
+ const onData = (chunk: Buffer) => {
693
+ buffer += chunk.toString("utf-8");
694
+ // Look for tagged response line indicating completion
695
+ if (buffer.includes(`${tag} OK`) || buffer.includes(`${tag} NO`) || buffer.includes(`${tag} BAD`)) {
696
+ socket.removeListener("data", onData);
697
+ socket.removeListener("error", onError);
698
+ resolve(buffer);
699
+ }
700
+ };
701
+
702
+ const onError = (err: Error) => {
703
+ socket.removeListener("data", onData);
704
+ reject(err);
705
+ };
706
+
707
+ socket.on("data", onData);
708
+ socket.on("error", onError);
709
+ socket.write(fullCommand, "utf-8");
710
+ });
711
+ }
712
+
713
+ function parseSearchResponse(response: string): string[] {
714
+ // SEARCH response: * SEARCH 1 2 3 4 5
715
+ const match = response.match(/\* SEARCH (.+)/);
716
+ if (!match) return [];
717
+ return match[1].trim().split(/\s+/).filter((s) => /^\d+$/.test(s));
718
+ }
719
+
720
+ function parseHeaderResponse(uid: string, response: string): ImapMessage {
721
+ const headers: Record<string, string> = {};
722
+ const headerBlock = response.match(/\r\n([\s\S]*?)\r\n\)/);
723
+ if (headerBlock) {
724
+ const lines = headerBlock[1].split(/\r\n/);
725
+ let currentKey = "";
726
+ for (const line of lines) {
727
+ if (/^\s/.test(line) && currentKey) {
728
+ headers[currentKey] += " " + line.trim();
729
+ } else {
730
+ const colonIdx = line.indexOf(":");
731
+ if (colonIdx > 0) {
732
+ currentKey = line.substring(0, colonIdx).trim().toLowerCase();
733
+ headers[currentKey] = line.substring(colonIdx + 1).trim();
734
+ }
735
+ }
736
+ }
737
+ }
738
+
739
+ const seen = /\\Seen/i.test(response);
740
+
741
+ return {
742
+ uid,
743
+ subject: headers["subject"] ?? "",
744
+ from: headers["from"] ?? "",
745
+ to: headers["to"] ?? "",
746
+ date: headers["date"] ?? "",
747
+ snippet: "",
748
+ seen,
749
+ };
750
+ }
751
+
752
+ function parseFullMessage(uid: string, response: string): ImapFullMessage {
753
+ // Extract the raw message body from FETCH response
754
+ const bodyMatch = response.match(/\{(\d+)\}\r\n([\s\S]*)/);
755
+ const rawMessage = bodyMatch ? bodyMatch[2] : response;
756
+
757
+ // Split headers and body
758
+ const headerEnd = rawMessage.indexOf("\r\n\r\n");
759
+ const headerSection = headerEnd > 0 ? rawMessage.substring(0, headerEnd) : rawMessage;
760
+ const bodySection = headerEnd > 0 ? rawMessage.substring(headerEnd + 4) : "";
761
+
762
+ // Parse headers
763
+ const headers: Record<string, string> = {};
764
+ const headerLines = headerSection.split(/\r\n/);
765
+ let currentKey = "";
766
+ for (const line of headerLines) {
767
+ if (/^\s/.test(line) && currentKey) {
768
+ headers[currentKey] += " " + line.trim();
769
+ } else {
770
+ const colonIdx = line.indexOf(":");
771
+ if (colonIdx > 0) {
772
+ currentKey = line.substring(0, colonIdx).trim().toLowerCase();
773
+ headers[currentKey] = line.substring(colonIdx + 1).trim();
774
+ }
775
+ }
776
+ }
777
+
778
+ // Determine content type
779
+ const contentType = headers["content-type"] ?? "text/plain";
780
+ let bodyText = "";
781
+ let bodyHtml = "";
782
+
783
+ if (contentType.includes("multipart")) {
784
+ // Extract boundary
785
+ const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/);
786
+ if (boundaryMatch) {
787
+ const boundary = boundaryMatch[1];
788
+ const parts = bodySection.split("--" + boundary);
789
+ for (const part of parts) {
790
+ if (part.trim() === "" || part.trim() === "--") continue;
791
+ const partHeaderEnd = part.indexOf("\r\n\r\n");
792
+ const partHeaders = partHeaderEnd > 0 ? part.substring(0, partHeaderEnd).toLowerCase() : "";
793
+ const partBody = partHeaderEnd > 0 ? part.substring(partHeaderEnd + 4).trim() : "";
794
+ if (partHeaders.includes("text/html")) {
795
+ bodyHtml = partBody;
796
+ } else if (partHeaders.includes("text/plain")) {
797
+ bodyText = partBody;
798
+ }
799
+ }
800
+ }
801
+ } else if (contentType.includes("text/html")) {
802
+ bodyHtml = bodySection;
803
+ } else {
804
+ bodyText = bodySection;
805
+ }
806
+
807
+ // Clean up trailing IMAP response data
808
+ bodyText = bodyText.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
809
+ bodyHtml = bodyHtml.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
810
+
811
+ return {
812
+ uid,
813
+ subject: headers["subject"] ?? "",
814
+ from: headers["from"] ?? "",
815
+ to: headers["to"] ?? "",
816
+ cc: headers["cc"] ?? "",
817
+ date: headers["date"] ?? "",
818
+ bodyText,
819
+ bodyHtml,
820
+ headers,
821
+ };
822
+ }