tina4-nodejs 3.13.95 → 3.13.97

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 (36) hide show
  1. package/CLAUDE.md +3 -4
  2. package/package.json +2 -1
  3. package/packages/cli/dist/bin.js +708 -1012
  4. package/packages/core/dist/index.js +588 -893
  5. package/packages/core/public/css/tina4.min.css +1 -1
  6. package/packages/core/src/index.ts +1 -3
  7. package/packages/core/src/messenger.ts +288 -96
  8. package/packages/core/src/queueBackends/kafkaBackend.ts +23 -2
  9. package/packages/core/src/queueBackends/rabbitmqBackend.ts +29 -17
  10. package/packages/core/src/request.ts +28 -7
  11. package/packages/core/src/server.ts +135 -7
  12. package/packages/core/src/session.ts +8 -1
  13. package/packages/orm/dist/index.js +639 -944
  14. package/packages/orm/src/autoCrud.ts +12 -10
  15. package/packages/orm/src/database.ts +62 -58
  16. package/packages/orm/src/databaseResult.ts +44 -73
  17. package/packages/orm/src/index.ts +0 -3
  18. package/packages/orm/src/migration.ts +26 -8
  19. package/packages/orm/src/model.ts +4 -0
  20. package/packages/orm/src/queryBuilder.ts +12 -5
  21. package/packages/orm/src/types.ts +7 -74
  22. package/packages/swagger/dist/index.js +78 -20
  23. package/packages/swagger/src/generator.ts +172 -29
  24. package/types/core/src/index.d.ts +1 -3
  25. package/types/core/src/messenger.d.ts +45 -4
  26. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -0
  27. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +2 -1
  28. package/types/core/src/server.d.ts +0 -4
  29. package/types/core/src/session.d.ts +7 -0
  30. package/types/orm/src/database.d.ts +34 -30
  31. package/types/orm/src/databaseResult.d.ts +26 -36
  32. package/types/orm/src/index.d.ts +1 -2
  33. package/types/orm/src/migration.d.ts +4 -3
  34. package/types/orm/src/types.d.ts +7 -34
  35. package/packages/core/src/scss.ts +0 -623
  36. package/types/core/src/scss.d.ts +0 -19
@@ -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
  */
@@ -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,7 +812,7 @@ 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
817
  async read(uid: string, folder: string = "INBOX"): Promise<ImapFullMessage | null> {
759
818
  let socket: net.Socket | tls.TLSSocket;
@@ -764,7 +823,7 @@ 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
829
  // that is NOT an error, so it must not throw. It returns null: FALSY, so
@@ -778,7 +837,7 @@ export class Messenger {
778
837
  }
779
838
 
780
839
  // Mark as seen
781
- await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
840
+ await imapCommand(socket, `UID STORE ${uid} +FLAGS (\\Seen)`);
782
841
 
783
842
  return parseFullMessage(uid, fetchResp);
784
843
  } catch (err) {
@@ -817,15 +876,15 @@ export class Messenger {
817
876
  }
818
877
  try {
819
878
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
820
- const searchResp = await imapCommand(socket, `SEARCH ${query}`);
879
+ const searchResp = await imapCommand(socket, `UID SEARCH ${query}`);
821
880
  const uids = parseSearchResponse(searchResp);
822
881
  if (uids.length === 0) return [];
823
882
 
824
883
  uids.reverse();
825
884
  const messages: ImapMessage[] = [];
826
885
  for (const uid of uids.slice(0, limit)) {
827
- const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
828
- messages.push(parseHeaderResponse(uid, fetchResp));
886
+ const fetchResp = await imapCommand(socket, `UID FETCH ${uid} (FLAGS BODY.PEEK[])`);
887
+ messages.push(parseSummary(uid, fetchResp));
829
888
  }
830
889
  return messages;
831
890
  } catch (err) {
@@ -836,27 +895,49 @@ export class Messenger {
836
895
  }
837
896
 
838
897
  /**
839
- * 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.
840
902
  */
841
- async deleteMessage(uid: string, folder: string = "INBOX"): Promise<void> {
903
+ async delete(uid: string, folder: string = "INBOX"): Promise<void> {
842
904
  const socket = await this.imapConnect();
843
905
  try {
844
906
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
845
- await imapCommand(socket, `STORE ${uid} +FLAGS (\\Deleted)`);
907
+ await imapCommand(socket, `UID STORE ${uid} +FLAGS (\\Deleted)`);
846
908
  await imapCommand(socket, "EXPUNGE");
847
909
  } finally {
848
910
  await this.imapDisconnect(socket);
849
911
  }
850
912
  }
851
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
+
852
919
  /**
853
- * Mark a message as read.
920
+ * Mark a message as read (+FLAGS \Seen).
854
921
  */
855
922
  async markRead(uid: string, folder: string = "INBOX"): Promise<void> {
856
923
  const socket = await this.imapConnect();
857
924
  try {
858
925
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
859
- 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)`);
860
941
  } finally {
861
942
  await this.imapDisconnect(socket);
862
943
  }
@@ -874,7 +955,7 @@ export class Messenger {
874
955
  }
875
956
  try {
876
957
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
877
- const searchResp = await imapCommand(socket, "SEARCH UNSEEN");
958
+ const searchResp = await imapCommand(socket, "UID SEARCH UNSEEN");
878
959
  return parseSearchResponse(searchResp).length;
879
960
  } catch (err) {
880
961
  throw imapFail("unread", err);
@@ -1012,106 +1093,217 @@ function parseSearchResponse(response: string): string[] {
1012
1093
  return match[1].trim().split(/\s+/).filter((s) => /^\d+$/.test(s));
1013
1094
  }
1014
1095
 
1015
- function parseHeaderResponse(uid: string, response: string): ImapMessage {
1016
- const headers: Record<string, string> = {};
1017
- const headerBlock = response.match(/\r\n([\s\S]*?)\r\n\)/);
1018
- if (headerBlock) {
1019
- const lines = headerBlock[1].split(/\r\n/);
1020
- let currentKey = "";
1021
- for (const line of lines) {
1022
- if (/^\s/.test(line) && currentKey) {
1023
- headers[currentKey] += " " + line.trim();
1024
- } else {
1025
- const colonIdx = line.indexOf(":");
1026
- if (colonIdx > 0) {
1027
- currentKey = line.substring(0, colonIdx).trim().toLowerCase();
1028
- headers[currentKey] = line.substring(colonIdx + 1).trim();
1029
- }
1030
- }
1031
- }
1032
- }
1033
-
1034
- const seen = /\\Seen/i.test(response);
1035
-
1036
- return {
1037
- uid,
1038
- subject: headers["subject"] ?? "",
1039
- from: headers["from"] ?? "",
1040
- to: headers["to"] ?? "",
1041
- date: headers["date"] ?? "",
1042
- snippet: "",
1043
- seen,
1044
- };
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));
1045
1109
  }
1046
1110
 
1047
- function parseFullMessage(uid: string, response: string): ImapFullMessage {
1048
- // Extract the raw message body from FETCH response
1049
- const bodyMatch = response.match(/\{(\d+)\}\r\n([\s\S]*)/);
1050
- const rawMessage = bodyMatch ? bodyMatch[2] : response;
1051
-
1052
- // Split headers and body
1053
- const headerEnd = rawMessage.indexOf("\r\n\r\n");
1054
- const headerSection = headerEnd > 0 ? rawMessage.substring(0, headerEnd) : rawMessage;
1055
- const bodySection = headerEnd > 0 ? rawMessage.substring(headerEnd + 4) : "";
1056
-
1057
- // Parse headers
1111
+ /** Parse a header block into a lower-cased map, honouring folded continuations. */
1112
+ function parseMimeHeaders(section: string): Record<string, string> {
1058
1113
  const headers: Record<string, string> = {};
1059
- const headerLines = headerSection.split(/\r\n/);
1060
1114
  let currentKey = "";
1061
- for (const line of headerLines) {
1115
+ for (const line of section.split(/\r\n/)) {
1062
1116
  if (/^\s/.test(line) && currentKey) {
1063
1117
  headers[currentKey] += " " + line.trim();
1064
1118
  } else {
1065
- const colonIdx = line.indexOf(":");
1066
- if (colonIdx > 0) {
1067
- currentKey = line.substring(0, colonIdx).trim().toLowerCase();
1068
- headers[currentKey] = line.substring(colonIdx + 1).trim();
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();
1069
1123
  }
1070
1124
  }
1071
1125
  }
1126
+ return headers;
1127
+ }
1128
+
1129
+ /**
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).
1133
+ */
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;
1149
+ }
1150
+
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);
1179
+ }
1180
+ }
1181
+ return Buffer.from(bytes);
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
+ }
1194
+
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
+ }
1072
1215
 
1073
- // Determine content type
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);
1074
1234
  const contentType = headers["content-type"] ?? "text/plain";
1235
+
1075
1236
  let bodyText = "";
1076
1237
  let bodyHtml = "";
1238
+ const attachments: ImapAttachment[] = [];
1077
1239
 
1078
1240
  if (contentType.includes("multipart")) {
1079
- // Extract boundary
1080
1241
  const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/);
1081
1242
  if (boundaryMatch) {
1082
- const boundary = boundaryMatch[1];
1083
- const parts = bodySection.split("--" + boundary);
1084
- for (const part of parts) {
1085
- if (part.trim() === "" || part.trim() === "--") continue;
1086
- const partHeaderEnd = part.indexOf("\r\n\r\n");
1087
- const partHeaders = partHeaderEnd > 0 ? part.substring(0, partHeaderEnd).toLowerCase() : "";
1088
- const partBody = partHeaderEnd > 0 ? part.substring(partHeaderEnd + 4).trim() : "";
1089
- if (partHeaders.includes("text/html")) {
1090
- bodyHtml = partBody;
1091
- } else if (partHeaders.includes("text/plain")) {
1092
- 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();
1093
1269
  }
1094
1270
  }
1095
1271
  }
1096
1272
  } else if (contentType.includes("text/html")) {
1097
- bodyHtml = bodySection;
1273
+ bodyHtml = decodeTransfer(bodySection, headers["content-transfer-encoding"] ?? "").trim();
1098
1274
  } else {
1099
- bodyText = bodySection;
1275
+ bodyText = decodeTransfer(bodySection, headers["content-transfer-encoding"] ?? "").trim();
1100
1276
  }
1101
1277
 
1102
- // Clean up trailing IMAP response data
1103
- bodyText = bodyText.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
1104
- 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
+ }
1105
1294
 
1295
+ function parseFullMessage(uid: string, response: string): ImapFullMessage {
1296
+ const { headers, bodyText, bodyHtml, attachments } = parseMessage(response);
1106
1297
  return {
1107
1298
  uid,
1108
1299
  subject: headers["subject"] ?? "",
1109
1300
  from: headers["from"] ?? "",
1110
1301
  to: headers["to"] ?? "",
1111
1302
  cc: headers["cc"] ?? "",
1112
- date: headers["date"] ?? "",
1303
+ date: toIsoDate(headers["date"] ?? ""),
1113
1304
  bodyText,
1114
1305
  bodyHtml,
1306
+ attachments,
1115
1307
  headers,
1116
1308
  };
1117
1309
  }
@@ -697,7 +697,28 @@ export class KafkaBackend implements QueueBackend {
697
697
  }
698
698
 
699
699
  clear(_queue: string): void {
700
- // Kafka topics are cleared via retention policies, not purging
701
- // This is a no-op for Kafka
700
+ // Not performable on Kafka - throws naming the backend and the operation.
701
+ // clear() empties the queue, but a Kafka log cannot delete records on
702
+ // demand: a partition is read in offset order and records leave only by
703
+ // retention. This used to be a silent no-op, which claimed the queue was
704
+ // emptied when it was untouched (ADR-0022 invariant 6). PHP, Python and
705
+ // Ruby already refuse by name; this brings the Node backend class in line.
706
+ throw new Error(
707
+ "The kafka queue backend cannot perform clear(): Kafka has no notion of " +
708
+ "job status and cannot delete records on demand. A log is read in " +
709
+ "offset order and records leave only by retention. Use the file or " +
710
+ "mongodb backend.",
711
+ );
712
+ }
713
+
714
+ purge(_queue: string, _status?: string): number {
715
+ // Not performable on Kafka - throws naming the backend and the operation.
716
+ // purge(status) removes jobs SELECTED BY STATUS; a Kafka log has no notion
717
+ // of status to purge by. Refusing by name is the honest answer.
718
+ throw new Error(
719
+ "The kafka queue backend cannot perform purge(): Kafka has no notion of " +
720
+ "job status to purge by. A log is read in offset order and records " +
721
+ "leave only by retention. Use the file or mongodb backend.",
722
+ );
702
723
  }
703
724
  }