tina4-nodejs 3.13.94 → 3.13.96

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 (123) hide show
  1. package/CLAUDE.md +158 -30
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/packages/cli/dist/bin.js +30911 -28444
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +30810 -28261
  8. package/packages/core/public/css/tina4.min.css +1 -1
  9. package/packages/core/src/ai.ts +7 -1
  10. package/packages/core/src/auth.ts +191 -39
  11. package/packages/core/src/background.ts +19 -19
  12. package/packages/core/src/cache.ts +492 -49
  13. package/packages/core/src/devAdmin.ts +79 -32
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +6 -7
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +294 -106
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +34 -16
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +886 -421
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  34. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  35. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  36. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  37. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  38. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  39. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  40. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  41. package/packages/core/src/testClient.ts +18 -5
  42. package/packages/core/src/trustedProxy.ts +249 -0
  43. package/packages/core/src/types.ts +29 -5
  44. package/packages/core/src/websocket.ts +66 -0
  45. package/packages/orm/dist/index.js +22717 -20168
  46. package/packages/orm/src/adapters/firebird.ts +183 -56
  47. package/packages/orm/src/adapters/mongodb.ts +25 -4
  48. package/packages/orm/src/adapters/mssql.ts +114 -29
  49. package/packages/orm/src/adapters/mysql.ts +103 -40
  50. package/packages/orm/src/adapters/odbc.ts +44 -21
  51. package/packages/orm/src/adapters/postgres.ts +118 -26
  52. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  53. package/packages/orm/src/adapters/sqlite.ts +60 -24
  54. package/packages/orm/src/autoCrud.ts +12 -10
  55. package/packages/orm/src/baseModel.ts +135 -40
  56. package/packages/orm/src/cachedDatabase.ts +43 -19
  57. package/packages/orm/src/connectTimeout.ts +265 -0
  58. package/packages/orm/src/database.ts +241 -197
  59. package/packages/orm/src/databaseResult.ts +51 -28
  60. package/packages/orm/src/databaseUrl.ts +484 -0
  61. package/packages/orm/src/docstore.ts +386 -145
  62. package/packages/orm/src/index.ts +13 -6
  63. package/packages/orm/src/migration.ts +44 -11
  64. package/packages/orm/src/model.ts +4 -0
  65. package/packages/orm/src/queryBuilder.ts +47 -6
  66. package/packages/orm/src/sqlTranslator.ts +310 -4
  67. package/packages/orm/src/types.ts +21 -77
  68. package/packages/swagger/dist/index.js +78 -20
  69. package/packages/swagger/src/generator.ts +172 -29
  70. package/types/core/src/ai.d.ts +1 -1
  71. package/types/core/src/auth.d.ts +28 -5
  72. package/types/core/src/background.d.ts +3 -3
  73. package/types/core/src/cache.d.ts +15 -12
  74. package/types/core/src/dispatchPipeline.d.ts +117 -0
  75. package/types/core/src/dotenv.d.ts +38 -16
  76. package/types/core/src/index.d.ts +6 -9
  77. package/types/core/src/logger.d.ts +93 -16
  78. package/types/core/src/messenger.d.ts +47 -6
  79. package/types/core/src/metrics.d.ts +25 -61
  80. package/types/core/src/middleware.d.ts +134 -11
  81. package/types/core/src/queue.d.ts +54 -5
  82. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  83. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  84. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  85. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  86. package/types/core/src/router.d.ts +14 -3
  87. package/types/core/src/server.d.ts +15 -4
  88. package/types/core/src/session.d.ts +87 -2
  89. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  90. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  91. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  92. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  93. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  94. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  95. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  96. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  97. package/types/core/src/trustedProxy.d.ts +44 -0
  98. package/types/core/src/types.d.ts +28 -5
  99. package/types/core/src/websocket.d.ts +26 -0
  100. package/types/orm/src/adapters/firebird.d.ts +55 -10
  101. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  102. package/types/orm/src/adapters/mssql.d.ts +18 -11
  103. package/types/orm/src/adapters/mysql.d.ts +11 -10
  104. package/types/orm/src/adapters/odbc.d.ts +9 -12
  105. package/types/orm/src/adapters/postgres.d.ts +11 -10
  106. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  107. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  108. package/types/orm/src/baseModel.d.ts +45 -9
  109. package/types/orm/src/cachedDatabase.d.ts +18 -5
  110. package/types/orm/src/connectTimeout.d.ts +100 -0
  111. package/types/orm/src/database.d.ts +78 -28
  112. package/types/orm/src/databaseResult.d.ts +29 -15
  113. package/types/orm/src/databaseUrl.d.ts +125 -0
  114. package/types/orm/src/docstore.d.ts +102 -43
  115. package/types/orm/src/index.d.ts +6 -4
  116. package/types/orm/src/migration.d.ts +4 -3
  117. package/types/orm/src/queryBuilder.d.ts +23 -3
  118. package/types/orm/src/sqlTranslator.d.ts +126 -2
  119. package/types/orm/src/types.d.ts +21 -38
  120. package/packages/core/src/scss.ts +0 -623
  121. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  122. package/types/core/src/scss.d.ts +0 -19
  123. package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
@@ -48,7 +48,13 @@ function tlsRejectUnauthorized(): boolean {
48
48
  export interface SendResult {
49
49
  success: boolean;
50
50
  message: string;
51
- id?: string;
51
+ /**
52
+ * The real Message-ID on success, `null` on failure — but ALWAYS present, so a
53
+ * caller reading `result.id` gets one shape from both branches (G6). It used to
54
+ * be omitted on the failure path, handing back `undefined` there and a string on
55
+ * success.
56
+ */
57
+ id: string | null;
52
58
  }
53
59
 
54
60
  /**
@@ -116,15 +122,34 @@ export interface ImapMessage {
116
122
  seen: boolean;
117
123
  }
118
124
 
125
+ /**
126
+ * An attachment from a read() message. `content` is the RAW DECODED BYTES of the
127
+ * part (transfer-decoded from base64 / quoted-printable), the SAME convention as
128
+ * req.files[x].content — raw bytes, not base64 — so an attachment is downloadable
129
+ * as-is; `size` is that decoded byte length. Parity with Python's read()
130
+ * attachment dict {filename, content_type, size, content}, in Node's idiomatic
131
+ * camelCase (ADR-0008 / G5). #69 folded the bytes in HERE — there is no separate
132
+ * carrier (Python retired its attachments_data in 3.13.96).
133
+ */
134
+ export interface ImapAttachment {
135
+ filename: string;
136
+ contentType: string;
137
+ size: number;
138
+ content: Buffer;
139
+ }
140
+
119
141
  export interface ImapFullMessage {
120
142
  uid: string;
121
143
  subject: string;
122
144
  from: string;
123
145
  to: string;
124
146
  cc: string;
147
+ /** ISO-8601, parsed from the Date header (parity with Python's _iso_date). */
125
148
  date: string;
126
149
  bodyText: string;
127
150
  bodyHtml: string;
151
+ /** Attachments, each carrying its decoded bytes. Empty when the message has none (G5 / #69). */
152
+ attachments: ImapAttachment[];
128
153
  headers: Record<string, string>;
129
154
  }
130
155
 
@@ -469,11 +494,11 @@ export class Messenger {
469
494
  const messageId = `${randomUUID()}@${this.host}`;
470
495
 
471
496
  if (allRecipients.length === 0) {
472
- return { success: false, message: "No recipients specified" };
497
+ return { success: false, message: "No recipients specified", id: null };
473
498
  }
474
499
 
475
500
  if (!this.fromAddress) {
476
- return { success: false, message: "No from address configured" };
501
+ return { success: false, message: "No from address configured", id: null };
477
502
  }
478
503
 
479
504
  try {
@@ -499,14 +524,14 @@ export class Messenger {
499
524
  const greeting = await readResponse(socket);
500
525
  if (greeting.code !== 220) {
501
526
  socket.destroy();
502
- return { success: false, message: `SMTP greeting failed: ${greeting.text}` };
527
+ return { success: false, message: `SMTP greeting failed: ${greeting.text}`, id: null };
503
528
  }
504
529
 
505
530
  // EHLO
506
531
  const ehlo = await sendCommand(socket, `EHLO ${this.host}`);
507
532
  if (ehlo.code !== 250) {
508
533
  socket.destroy();
509
- return { success: false, message: `EHLO failed: ${ehlo.text}` };
534
+ return { success: false, message: `EHLO failed: ${ehlo.text}`, id: null };
510
535
  }
511
536
 
512
537
  // STARTTLS upgrade (for port 587 or when useTls is true and not already TLS)
@@ -514,7 +539,7 @@ export class Messenger {
514
539
  const starttls = await sendCommand(socket, "STARTTLS");
515
540
  if (starttls.code !== 220) {
516
541
  socket.destroy();
517
- return { success: false, message: `STARTTLS failed: ${starttls.text}` };
542
+ return { success: false, message: `STARTTLS failed: ${starttls.text}`, id: null };
518
543
  }
519
544
 
520
545
  // Upgrade to TLS
@@ -531,7 +556,7 @@ export class Messenger {
531
556
  const ehlo2 = await sendCommand(socket, `EHLO ${this.host}`);
532
557
  if (ehlo2.code !== 250) {
533
558
  socket.destroy();
534
- return { success: false, message: `EHLO after STARTTLS failed: ${ehlo2.text}` };
559
+ return { success: false, message: `EHLO after STARTTLS failed: ${ehlo2.text}`, id: null };
535
560
  }
536
561
  }
537
562
 
@@ -540,19 +565,19 @@ export class Messenger {
540
565
  const auth = await sendCommand(socket, "AUTH LOGIN");
541
566
  if (auth.code !== 334) {
542
567
  socket.destroy();
543
- return { success: false, message: `AUTH LOGIN failed: ${auth.text}` };
568
+ return { success: false, message: `AUTH LOGIN failed: ${auth.text}`, id: null };
544
569
  }
545
570
 
546
571
  const userResp = await sendCommand(socket, Buffer.from(this.username).toString("base64"));
547
572
  if (userResp.code !== 334) {
548
573
  socket.destroy();
549
- return { success: false, message: `AUTH username failed: ${userResp.text}` };
574
+ return { success: false, message: `AUTH username failed: ${userResp.text}`, id: null };
550
575
  }
551
576
 
552
577
  const passResp = await sendCommand(socket, Buffer.from(this.password).toString("base64"));
553
578
  if (passResp.code !== 235) {
554
579
  socket.destroy();
555
- return { success: false, message: `AUTH password failed: ${passResp.text}` };
580
+ return { success: false, message: `AUTH password failed: ${passResp.text}`, id: null };
556
581
  }
557
582
  }
558
583
 
@@ -560,7 +585,7 @@ export class Messenger {
560
585
  const mailFrom = await sendCommand(socket, `MAIL FROM:<${this.fromAddress}>`);
561
586
  if (mailFrom.code !== 250) {
562
587
  socket.destroy();
563
- return { success: false, message: `MAIL FROM failed: ${mailFrom.text}` };
588
+ return { success: false, message: `MAIL FROM failed: ${mailFrom.text}`, id: null };
564
589
  }
565
590
 
566
591
  // RCPT TO for all recipients
@@ -568,7 +593,7 @@ export class Messenger {
568
593
  const rcpt = await sendCommand(socket, `RCPT TO:<${recipient}>`);
569
594
  if (rcpt.code !== 250 && rcpt.code !== 251) {
570
595
  socket.destroy();
571
- return { success: false, message: `RCPT TO <${recipient}> failed: ${rcpt.text}` };
596
+ return { success: false, message: `RCPT TO <${recipient}> failed: ${rcpt.text}`, id: null };
572
597
  }
573
598
  }
574
599
 
@@ -576,7 +601,7 @@ export class Messenger {
576
601
  const dataCmd = await sendCommand(socket, "DATA");
577
602
  if (dataCmd.code !== 354) {
578
603
  socket.destroy();
579
- return { success: false, message: `DATA failed: ${dataCmd.text}` };
604
+ return { success: false, message: `DATA failed: ${dataCmd.text}`, id: null };
580
605
  }
581
606
 
582
607
  // Build and send the MIME message
@@ -599,7 +624,7 @@ export class Messenger {
599
624
  const endData = await sendCommand(socket, mimeMessage + "\r\n.");
600
625
  if (endData.code !== 250) {
601
626
  socket.destroy();
602
- return { success: false, message: `Message delivery failed: ${endData.text}` };
627
+ return { success: false, message: `Message delivery failed: ${endData.text}`, id: null };
603
628
  }
604
629
 
605
630
  // QUIT
@@ -609,10 +634,41 @@ export class Messenger {
609
634
  return { success: true, message: "Email sent successfully", id: messageId };
610
635
  } catch (err) {
611
636
  const errMsg = err instanceof Error ? err.message : String(err);
612
- return { success: false, message: `SMTP error: ${errMsg}` };
637
+ return { success: false, message: `SMTP error: ${errMsg}`, id: null };
613
638
  }
614
639
  }
615
640
 
641
+ /**
642
+ * Render a Frond template STRING and send it as an HTML email (G7, parity with
643
+ * Python's send_template). Extra send() options (cc, bcc, replyTo, attachments,
644
+ * headers) pass through. If the Frond package cannot be loaded the raw template
645
+ * is sent verbatim (matches Python's ImportError fallback) rather than failing.
646
+ */
647
+ async sendTemplate(
648
+ to: string | string[],
649
+ subject: string,
650
+ template: string,
651
+ data: Record<string, unknown> = {},
652
+ cc?: string | string[],
653
+ bcc?: string | string[],
654
+ replyTo?: string,
655
+ attachments?: string[],
656
+ headers?: Record<string, string>,
657
+ ): Promise<SendResult> {
658
+ let body = template;
659
+ try {
660
+ // Same sibling-package specifier server.ts uses for Frond (resolves in dev
661
+ // under tsx and in the built dist). Core never hard-depends on Frond.
662
+ const { Frond } = (await import("../../frond/src/engine.js")) as {
663
+ Frond: new (dir?: string) => { renderString(t: string, d?: Record<string, unknown>): string };
664
+ };
665
+ body = new Frond().renderString(template, data);
666
+ } catch {
667
+ // Frond unavailable — send the template text as-is (Python parity).
668
+ }
669
+ return this.send(to, subject, body, true, undefined, cc, bcc, replyTo, attachments, headers);
670
+ }
671
+
616
672
  /**
617
673
  * Test the SMTP connection without sending an email.
618
674
  */
@@ -717,7 +773,7 @@ export class Messenger {
717
773
  * Fetch latest messages from a folder.
718
774
  * Returns list of message summaries.
719
775
  */
720
- async inbox(limit: number = 20, offset: number = 0, folder: string = "INBOX"): Promise<ImapMessage[]> {
776
+ async inbox(folder: string = "INBOX", limit: number = 20, offset: number = 0): Promise<ImapMessage[]> {
721
777
  let socket: net.Socket | tls.TLSSocket;
722
778
  try {
723
779
  socket = await this.imapConnect();
@@ -729,7 +785,7 @@ export class Messenger {
729
785
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
730
786
 
731
787
  // Search for all messages
732
- const searchResp = await imapCommand(socket, "SEARCH ALL");
788
+ const searchResp = await imapCommand(socket, "UID SEARCH ALL");
733
789
  const uids = parseSearchResponse(searchResp);
734
790
  if (uids.length === 0) return [];
735
791
 
@@ -740,8 +796,11 @@ export class Messenger {
740
796
 
741
797
  const messages: ImapMessage[] = [];
742
798
  for (const uid of selected) {
743
- const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
744
- messages.push(parseHeaderResponse(uid, fetchResp));
799
+ // Fetch the WHOLE message (PEEK never mark seen) so the snippet is
800
+ // built from real, transfer-decoded body text (G3), not the empty string
801
+ // a header-only fetch could ever produce.
802
+ const fetchResp = await imapCommand(socket, `UID FETCH ${uid} (FLAGS BODY.PEEK[])`);
803
+ messages.push(parseSummary(uid, fetchResp));
745
804
  }
746
805
 
747
806
  return messages;
@@ -753,9 +812,9 @@ export class Messenger {
753
812
  }
754
813
 
755
814
  /**
756
- * Read a single message by sequence number or UID.
815
+ * Read a single message by its IMAP UID.
757
816
  */
758
- async read(uid: string, folder: string = "INBOX"): Promise<ImapFullMessage> {
817
+ async read(uid: string, folder: string = "INBOX"): Promise<ImapFullMessage | null> {
759
818
  let socket: net.Socket | tls.TLSSocket;
760
819
  try {
761
820
  socket = await this.imapConnect();
@@ -764,16 +823,21 @@ export class Messenger {
764
823
  }
765
824
  try {
766
825
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
767
- const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY[])`);
826
+ const fetchResp = await imapCommand(socket, `UID FETCH ${uid} (FLAGS BODY[])`);
768
827
 
769
828
  // A genuinely missing UID is a tagged OK with no message body literal —
770
- // that is NOT an error: return an empty message (parity with Python's {}).
829
+ // that is NOT an error, so it must not throw. It returns null: FALSY, so
830
+ // `if (!msg)` detects it. This used to return emptyFullMessage(uid) under
831
+ // a comment claiming "parity with Python's {}" -- but that object is
832
+ // TRUTHY, while Python's {} , PHP's null and Ruby's nil are all falsy, so
833
+ // Node was the one framework where a caller could not tell "no such
834
+ // message" from a real one without inspecting individual fields.
771
835
  if (!/\{\d+\}/.test(fetchResp)) {
772
- return emptyFullMessage(uid);
836
+ return null;
773
837
  }
774
838
 
775
839
  // Mark as seen
776
- await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
840
+ await imapCommand(socket, `UID STORE ${uid} +FLAGS (\\Seen)`);
777
841
 
778
842
  return parseFullMessage(uid, fetchResp);
779
843
  } catch (err) {
@@ -812,15 +876,15 @@ export class Messenger {
812
876
  }
813
877
  try {
814
878
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
815
- const searchResp = await imapCommand(socket, `SEARCH ${query}`);
879
+ const searchResp = await imapCommand(socket, `UID SEARCH ${query}`);
816
880
  const uids = parseSearchResponse(searchResp);
817
881
  if (uids.length === 0) return [];
818
882
 
819
883
  uids.reverse();
820
884
  const messages: ImapMessage[] = [];
821
885
  for (const uid of uids.slice(0, limit)) {
822
- const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
823
- messages.push(parseHeaderResponse(uid, fetchResp));
886
+ const fetchResp = await imapCommand(socket, `UID FETCH ${uid} (FLAGS BODY.PEEK[])`);
887
+ messages.push(parseSummary(uid, fetchResp));
824
888
  }
825
889
  return messages;
826
890
  } catch (err) {
@@ -831,27 +895,49 @@ export class Messenger {
831
895
  }
832
896
 
833
897
  /**
834
- * Delete a message by UID.
898
+ * Delete a message by UID (mark \Deleted, then EXPUNGE).
899
+ *
900
+ * `delete` is the one cross-framework name (python/php/ruby/node all spell it
901
+ * `delete`). `deleteMessage` remains as a DEPRECATED alias for one release.
835
902
  */
836
- async deleteMessage(uid: string, folder: string = "INBOX"): Promise<void> {
903
+ async delete(uid: string, folder: string = "INBOX"): Promise<void> {
837
904
  const socket = await this.imapConnect();
838
905
  try {
839
906
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
840
- await imapCommand(socket, `STORE ${uid} +FLAGS (\\Deleted)`);
907
+ await imapCommand(socket, `UID STORE ${uid} +FLAGS (\\Deleted)`);
841
908
  await imapCommand(socket, "EXPUNGE");
842
909
  } finally {
843
910
  await this.imapDisconnect(socket);
844
911
  }
845
912
  }
846
913
 
914
+ /** @deprecated Use {@link delete} — kept as an alias for one release (G7). */
915
+ async deleteMessage(uid: string, folder: string = "INBOX"): Promise<void> {
916
+ return this.delete(uid, folder);
917
+ }
918
+
847
919
  /**
848
- * Mark a message as read.
920
+ * Mark a message as read (+FLAGS \Seen).
849
921
  */
850
922
  async markRead(uid: string, folder: string = "INBOX"): Promise<void> {
851
923
  const socket = await this.imapConnect();
852
924
  try {
853
925
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
854
- await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
926
+ await imapCommand(socket, `UID STORE ${uid} +FLAGS (\\Seen)`);
927
+ } finally {
928
+ await this.imapDisconnect(socket);
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Mark a message as unread (-FLAGS \Seen) — the inverse of markRead (G7,
934
+ * parity with Python's mark_unread).
935
+ */
936
+ async markUnread(uid: string, folder: string = "INBOX"): Promise<void> {
937
+ const socket = await this.imapConnect();
938
+ try {
939
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
940
+ await imapCommand(socket, `UID STORE ${uid} -FLAGS (\\Seen)`);
855
941
  } finally {
856
942
  await this.imapDisconnect(socket);
857
943
  }
@@ -869,7 +955,7 @@ export class Messenger {
869
955
  }
870
956
  try {
871
957
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
872
- const searchResp = await imapCommand(socket, "SEARCH UNSEEN");
958
+ const searchResp = await imapCommand(socket, "UID SEARCH UNSEEN");
873
959
  return parseSearchResponse(searchResp).length;
874
960
  } catch (err) {
875
961
  throw imapFail("unread", err);
@@ -1007,115 +1093,217 @@ function parseSearchResponse(response: string): string[] {
1007
1093
  return match[1].trim().split(/\s+/).filter((s) => /^\d+$/.test(s));
1008
1094
  }
1009
1095
 
1010
- function parseHeaderResponse(uid: string, response: string): ImapMessage {
1096
+ /**
1097
+ * The RFC822 message octets from a FETCH response. IMAP frames the body as a
1098
+ * `{N}` literal followed by exactly N octets, so slice N — that cleanly drops the
1099
+ * trailing `)\r\nTAG OK ...` the server appends after the literal (which the old
1100
+ * regex-then-strip path had to chase). For the ASCII bodies these methods see,
1101
+ * octet length equals string length; a response with no literal falls back to
1102
+ * itself.
1103
+ */
1104
+ function extractRawMessage(response: string): string {
1105
+ const m = response.match(/\{(\d+)\}\r\n/);
1106
+ if (!m) return response;
1107
+ const start = (m.index ?? 0) + m[0].length;
1108
+ return response.slice(start, start + parseInt(m[1], 10));
1109
+ }
1110
+
1111
+ /** Parse a header block into a lower-cased map, honouring folded continuations. */
1112
+ function parseMimeHeaders(section: string): Record<string, string> {
1011
1113
  const headers: Record<string, string> = {};
1012
- const headerBlock = response.match(/\r\n([\s\S]*?)\r\n\)/);
1013
- if (headerBlock) {
1014
- const lines = headerBlock[1].split(/\r\n/);
1015
- let currentKey = "";
1016
- for (const line of lines) {
1017
- if (/^\s/.test(line) && currentKey) {
1018
- headers[currentKey] += " " + line.trim();
1019
- } else {
1020
- const colonIdx = line.indexOf(":");
1021
- if (colonIdx > 0) {
1022
- currentKey = line.substring(0, colonIdx).trim().toLowerCase();
1023
- headers[currentKey] = line.substring(colonIdx + 1).trim();
1024
- }
1114
+ let currentKey = "";
1115
+ for (const line of section.split(/\r\n/)) {
1116
+ if (/^\s/.test(line) && currentKey) {
1117
+ headers[currentKey] += " " + line.trim();
1118
+ } else {
1119
+ const idx = line.indexOf(":");
1120
+ if (idx > 0) {
1121
+ currentKey = line.substring(0, idx).trim().toLowerCase();
1122
+ headers[currentKey] = line.substring(idx + 1).trim();
1025
1123
  }
1026
1124
  }
1027
1125
  }
1028
-
1029
- const seen = /\\Seen/i.test(response);
1030
-
1031
- return {
1032
- uid,
1033
- subject: headers["subject"] ?? "",
1034
- from: headers["from"] ?? "",
1035
- to: headers["to"] ?? "",
1036
- date: headers["date"] ?? "",
1037
- snippet: "",
1038
- seen,
1039
- };
1126
+ return headers;
1040
1127
  }
1041
1128
 
1042
1129
  /**
1043
- * An empty full message returned when a FETCH succeeds (tagged OK) but the
1044
- * UID does not exist, so there is no message body. Mirrors Python's {} return:
1045
- * a missing UID is NOT an error.
1130
+ * Transfer-decode a part body per its Content-Transfer-Encoding. base64 and
1131
+ * quoted-printable become readable text; 7bit/8bit/binary/absent pass through.
1132
+ * This is what turns the snippet from raw base64 into real words (G3).
1046
1133
  */
1047
- function emptyFullMessage(uid: string): ImapFullMessage {
1048
- return { uid, subject: "", from: "", to: "", cc: "", date: "", bodyText: "", bodyHtml: "", headers: {} };
1134
+ function decodeTransfer(body: string, encoding: string): string {
1135
+ const enc = encoding.toLowerCase().trim();
1136
+ if (enc === "base64") {
1137
+ try {
1138
+ return Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf-8");
1139
+ } catch {
1140
+ return body;
1141
+ }
1142
+ }
1143
+ if (enc === "quoted-printable") {
1144
+ return body
1145
+ .replace(/=\r?\n/g, "") // soft line breaks
1146
+ .replace(/=([0-9A-Fa-f]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16)));
1147
+ }
1148
+ return body;
1049
1149
  }
1050
1150
 
1051
- function parseFullMessage(uid: string, response: string): ImapFullMessage {
1052
- // Extract the raw message body from FETCH response
1053
- const bodyMatch = response.match(/\{(\d+)\}\r\n([\s\S]*)/);
1054
- const rawMessage = bodyMatch ? bodyMatch[2] : response;
1055
-
1056
- // Split headers and body
1057
- const headerEnd = rawMessage.indexOf("\r\n\r\n");
1058
- const headerSection = headerEnd > 0 ? rawMessage.substring(0, headerEnd) : rawMessage;
1059
- const bodySection = headerEnd > 0 ? rawMessage.substring(headerEnd + 4) : "";
1060
-
1061
- // Parse headers
1062
- const headers: Record<string, string> = {};
1063
- const headerLines = headerSection.split(/\r\n/);
1064
- let currentKey = "";
1065
- for (const line of headerLines) {
1066
- if (/^\s/.test(line) && currentKey) {
1067
- headers[currentKey] += " " + line.trim();
1068
- } else {
1069
- const colonIdx = line.indexOf(":");
1070
- if (colonIdx > 0) {
1071
- currentKey = line.substring(0, colonIdx).trim().toLowerCase();
1072
- headers[currentKey] = line.substring(colonIdx + 1).trim();
1151
+ /**
1152
+ * Transfer-decode an attachment part body to its RAW BYTES (#69). base64 and
1153
+ * quoted-printable become the original octets; 7bit/8bit/binary/absent pass
1154
+ * through as their own bytes. This is the byte-level sibling of decodeTransfer()
1155
+ * (which returns text for the message bodies): an attachment must round-trip
1156
+ * byte-for-byte to be downloadable, so it returns a Buffer, never a string.
1157
+ * Mirrors Python's part.get_payload(decode=True).
1158
+ */
1159
+ function decodeAttachmentBytes(body: string, encoding: string): Buffer {
1160
+ const enc = encoding.toLowerCase().trim();
1161
+ if (enc === "base64") {
1162
+ // The transport wraps base64 at 76 cols (RFC 2045); strip ALL whitespace so
1163
+ // the wrapped lines rejoin into exactly the original bytes.
1164
+ return Buffer.from(body.replace(/\s+/g, ""), "base64");
1165
+ }
1166
+ // RFC 2046: the CRLF immediately before the boundary delimiter belongs to the
1167
+ // boundary, not the part body — drop exactly that one trailing CRLF.
1168
+ const trimmed = body.replace(/\r\n$/, "");
1169
+ if (enc === "quoted-printable") {
1170
+ const collapsed = trimmed.replace(/=\r?\n/g, ""); // soft line breaks
1171
+ const bytes: number[] = [];
1172
+ for (let i = 0; i < collapsed.length; i++) {
1173
+ const hex = collapsed.substring(i + 1, i + 3);
1174
+ if (collapsed[i] === "=" && /^[0-9A-Fa-f]{2}$/.test(hex)) {
1175
+ bytes.push(parseInt(hex, 16));
1176
+ i += 2;
1177
+ } else {
1178
+ bytes.push(collapsed.charCodeAt(i) & 0xff);
1073
1179
  }
1074
1180
  }
1181
+ return Buffer.from(bytes);
1075
1182
  }
1183
+ return Buffer.from(trimmed, "utf-8");
1184
+ }
1185
+
1186
+ /** filename="..." from Content-Disposition, else name="..." from Content-Type. */
1187
+ function attachmentFilename(disposition: string, contentType: string): string {
1188
+ const d = disposition.match(/filename="?([^";\r\n]+)"?/i);
1189
+ if (d) return d[1].trim();
1190
+ const c = contentType.match(/name="?([^";\r\n]+)"?/i);
1191
+ if (c) return c[1].trim();
1192
+ return "attachment";
1193
+ }
1076
1194
 
1077
- // Determine content type
1195
+ /**
1196
+ * A decoded, tag-stripped, whitespace-collapsed 200-char preview (G3). Prefers
1197
+ * the plain-text body, falls back to the HTML with its tags removed. The inputs
1198
+ * are already transfer-decoded, so this is real readable text — never the raw
1199
+ * base64 a header-only / undecoded fetch used to emit.
1200
+ */
1201
+ function makeSnippet(bodyText: string, bodyHtml: string): string {
1202
+ return (bodyText || bodyHtml || "")
1203
+ .replace(/<[^>]+>/g, " ") // strip HTML tags
1204
+ .replace(/\s+/g, " ") // collapse whitespace
1205
+ .trim()
1206
+ .slice(0, 200);
1207
+ }
1208
+
1209
+ /** The Date header as ISO-8601 (G4); the raw string if unparseable, "" if absent. */
1210
+ function toIsoDate(raw: string): string {
1211
+ if (!raw) return "";
1212
+ const d = new Date(raw);
1213
+ return Number.isNaN(d.getTime()) ? raw : d.toISOString();
1214
+ }
1215
+
1216
+ interface ParsedMessage {
1217
+ headers: Record<string, string>;
1218
+ bodyText: string;
1219
+ bodyHtml: string;
1220
+ attachments: ImapAttachment[];
1221
+ }
1222
+
1223
+ /**
1224
+ * Walk a FETCH response into headers + transfer-decoded bodies + attachment
1225
+ * metadata. Shared by parseSummary() (inbox/search) and parseFullMessage()
1226
+ * (read) so the snippet and the read() bodies can never drift.
1227
+ */
1228
+ function parseMessage(response: string): ParsedMessage {
1229
+ const raw = extractRawMessage(response);
1230
+ const headerEnd = raw.indexOf("\r\n\r\n");
1231
+ const headerSection = headerEnd >= 0 ? raw.substring(0, headerEnd) : raw;
1232
+ const bodySection = headerEnd >= 0 ? raw.substring(headerEnd + 4) : "";
1233
+ const headers = parseMimeHeaders(headerSection);
1078
1234
  const contentType = headers["content-type"] ?? "text/plain";
1235
+
1079
1236
  let bodyText = "";
1080
1237
  let bodyHtml = "";
1238
+ const attachments: ImapAttachment[] = [];
1081
1239
 
1082
1240
  if (contentType.includes("multipart")) {
1083
- // Extract boundary
1084
1241
  const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/);
1085
1242
  if (boundaryMatch) {
1086
- const boundary = boundaryMatch[1];
1087
- const parts = bodySection.split("--" + boundary);
1088
- for (const part of parts) {
1089
- if (part.trim() === "" || part.trim() === "--") continue;
1090
- const partHeaderEnd = part.indexOf("\r\n\r\n");
1091
- const partHeaders = partHeaderEnd > 0 ? part.substring(0, partHeaderEnd).toLowerCase() : "";
1092
- const partBody = partHeaderEnd > 0 ? part.substring(partHeaderEnd + 4).trim() : "";
1093
- if (partHeaders.includes("text/html")) {
1094
- bodyHtml = partBody;
1095
- } else if (partHeaders.includes("text/plain")) {
1096
- bodyText = partBody;
1243
+ const boundary = "--" + boundaryMatch[1];
1244
+ for (const part of bodySection.split(boundary)) {
1245
+ const trimmed = part.trim();
1246
+ if (trimmed === "" || trimmed === "--") continue;
1247
+ const pEnd = part.indexOf("\r\n\r\n");
1248
+ if (pEnd < 0) continue;
1249
+ const pHeaders = parseMimeHeaders(part.substring(0, pEnd));
1250
+ const pBody = part.substring(pEnd + 4);
1251
+ const cte = pHeaders["content-transfer-encoding"] ?? "";
1252
+ const pType = pHeaders["content-type"] ?? "text/plain";
1253
+ const disposition = pHeaders["content-disposition"] ?? "";
1254
+ if (/attachment/i.test(disposition)) {
1255
+ // The RAW DECODED BYTES of the part (#69) — transfer-decoded so the
1256
+ // attachment is downloadable byte-for-byte (same convention as
1257
+ // req.files[x].content); size is that decoded byte length.
1258
+ const content = decodeAttachmentBytes(pBody, cte);
1259
+ attachments.push({
1260
+ filename: attachmentFilename(disposition, pType),
1261
+ contentType: pType.split(";")[0].trim(),
1262
+ size: content.length,
1263
+ content,
1264
+ });
1265
+ } else if (pType.includes("text/html")) {
1266
+ bodyHtml = decodeTransfer(pBody, cte).trim();
1267
+ } else if (pType.includes("text/plain")) {
1268
+ bodyText = decodeTransfer(pBody, cte).trim();
1097
1269
  }
1098
1270
  }
1099
1271
  }
1100
1272
  } else if (contentType.includes("text/html")) {
1101
- bodyHtml = bodySection;
1273
+ bodyHtml = decodeTransfer(bodySection, headers["content-transfer-encoding"] ?? "").trim();
1102
1274
  } else {
1103
- bodyText = bodySection;
1275
+ bodyText = decodeTransfer(bodySection, headers["content-transfer-encoding"] ?? "").trim();
1104
1276
  }
1105
1277
 
1106
- // Clean up trailing IMAP response data
1107
- bodyText = bodyText.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
1108
- bodyHtml = bodyHtml.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
1278
+ return { headers, bodyText, bodyHtml, attachments };
1279
+ }
1280
+
1281
+ /** The inbox()/search() listing row: {uid, subject, from, to, date, snippet, seen} (G3/G4). */
1282
+ function parseSummary(uid: string, response: string): ImapMessage {
1283
+ const { headers, bodyText, bodyHtml } = parseMessage(response);
1284
+ return {
1285
+ uid,
1286
+ subject: headers["subject"] ?? "",
1287
+ from: headers["from"] ?? "",
1288
+ to: headers["to"] ?? "",
1289
+ date: toIsoDate(headers["date"] ?? ""),
1290
+ snippet: makeSnippet(bodyText, bodyHtml),
1291
+ seen: /\\Seen/i.test(response),
1292
+ };
1293
+ }
1109
1294
 
1295
+ function parseFullMessage(uid: string, response: string): ImapFullMessage {
1296
+ const { headers, bodyText, bodyHtml, attachments } = parseMessage(response);
1110
1297
  return {
1111
1298
  uid,
1112
1299
  subject: headers["subject"] ?? "",
1113
1300
  from: headers["from"] ?? "",
1114
1301
  to: headers["to"] ?? "",
1115
1302
  cc: headers["cc"] ?? "",
1116
- date: headers["date"] ?? "",
1303
+ date: toIsoDate(headers["date"] ?? ""),
1117
1304
  bodyText,
1118
1305
  bodyHtml,
1306
+ attachments,
1119
1307
  headers,
1120
1308
  };
1121
1309
  }