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.
- package/CLAUDE.md +3 -4
- package/package.json +2 -1
- package/packages/cli/dist/bin.js +708 -1012
- package/packages/core/dist/index.js +588 -893
- package/packages/core/public/css/tina4.min.css +1 -1
- package/packages/core/src/index.ts +1 -3
- package/packages/core/src/messenger.ts +288 -96
- package/packages/core/src/queueBackends/kafkaBackend.ts +23 -2
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +29 -17
- package/packages/core/src/request.ts +28 -7
- package/packages/core/src/server.ts +135 -7
- package/packages/core/src/session.ts +8 -1
- package/packages/orm/dist/index.js +639 -944
- package/packages/orm/src/autoCrud.ts +12 -10
- package/packages/orm/src/database.ts +62 -58
- package/packages/orm/src/databaseResult.ts +44 -73
- package/packages/orm/src/index.ts +0 -3
- package/packages/orm/src/migration.ts +26 -8
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +12 -5
- package/packages/orm/src/types.ts +7 -74
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/index.d.ts +1 -3
- package/types/core/src/messenger.d.ts +45 -4
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +2 -1
- package/types/core/src/server.d.ts +0 -4
- package/types/core/src/session.d.ts +7 -0
- package/types/orm/src/database.d.ts +34 -30
- package/types/orm/src/databaseResult.d.ts +26 -36
- package/types/orm/src/index.d.ts +1 -2
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/types.d.ts +7 -34
- package/packages/core/src/scss.ts +0 -623
- 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
|
-
|
|
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
|
-
|
|
744
|
-
|
|
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
|
|
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[
|
|
828
|
-
messages.push(
|
|
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
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
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
|
|
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
|
|
1066
|
-
if (
|
|
1067
|
-
currentKey = line.substring(0,
|
|
1068
|
-
headers[currentKey] = line.substring(
|
|
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
|
-
|
|
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
|
|
1084
|
-
|
|
1085
|
-
if (
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
|
701
|
-
//
|
|
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
|
}
|