arn-rawmime 0.0.9 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/rawmimeBuilder.d.ts +13 -3
- package/src/rawmimeBuilder.js +133 -29
- package/src/utility/mailParser.js +35 -9
package/package.json
CHANGED
package/src/rawmimeBuilder.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export class MimeMessage {
|
|
|
35
35
|
setReplyTo(replyTo: MailboxInput): void;
|
|
36
36
|
setSubject(subject: string): void;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Sets the style of the multipart boundary separator.
|
|
40
|
+
*/
|
|
41
|
+
setBoundaryStyle(style: "google" | "outlook" | "apple-mail"): void;
|
|
42
|
+
|
|
38
43
|
/**
|
|
39
44
|
* Sets the Date header using a specific Time Zone or a random US Time Zone.
|
|
40
45
|
* @param options Configuration object.
|
|
@@ -51,20 +56,25 @@ export class MimeMessage {
|
|
|
51
56
|
/**
|
|
52
57
|
* Adds the plain text version of the email.
|
|
53
58
|
*/
|
|
54
|
-
addText(options: {
|
|
59
|
+
addText(options: {
|
|
60
|
+
data: string | Buffer;
|
|
61
|
+
encoding?: "quoted-printable" | "base64" | "7bit" | "8bit";
|
|
62
|
+
charset?: "UTF-8" | "ISO-8859-1" | "US-ASCII" | (string & {});
|
|
63
|
+
}): void;
|
|
55
64
|
|
|
56
65
|
/**
|
|
57
66
|
* Adds the HTML version of the email.
|
|
58
67
|
*/
|
|
59
68
|
addHtml(options: {
|
|
60
|
-
data: string;
|
|
61
|
-
encoding?: "quoted-printable" | "base64";
|
|
69
|
+
data: string | Buffer;
|
|
70
|
+
encoding?: "quoted-printable" | "base64" | "7bit" | "8bit";
|
|
62
71
|
autoTextPart?: boolean;
|
|
63
72
|
autoMinify?: boolean;
|
|
64
73
|
/** * If true, the `data` is treated as input for the markdown processor.
|
|
65
74
|
* It will be converted to HTML, sanitized, and used to generate the body.
|
|
66
75
|
*/
|
|
67
76
|
markdownToHtml?: boolean;
|
|
77
|
+
charset?: "UTF-8" | "ISO-8859-1" | "US-ASCII" | (string & {});
|
|
68
78
|
}): void;
|
|
69
79
|
|
|
70
80
|
/**
|
package/src/rawmimeBuilder.js
CHANGED
|
@@ -29,6 +29,15 @@ class MimeMessage {
|
|
|
29
29
|
this.attachmentPromises = [];
|
|
30
30
|
this.processingPromises = [];
|
|
31
31
|
this.removedHeaders = new Set();
|
|
32
|
+
this.boundaryStyle = "google";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setBoundaryStyle(style) {
|
|
36
|
+
const allowed = ["google", "outlook", "apple-mail"];
|
|
37
|
+
if (!allowed.includes(style)) {
|
|
38
|
+
throw new Error(`Invalid boundary style: ${style}. Allowed: ${allowed.join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
this.boundaryStyle = style;
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
// ─── HELPER: HTML to Text Converter ─────────────────────────────
|
|
@@ -57,8 +66,14 @@ class MimeMessage {
|
|
|
57
66
|
return minified.trim();
|
|
58
67
|
}
|
|
59
68
|
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
_getBufferEncoding(charset) {
|
|
70
|
+
const clean = (charset || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
71
|
+
if (clean === "iso88591" || clean === "latin1") return "latin1";
|
|
72
|
+
if (clean === "usascii" || clean === "ascii") return "ascii";
|
|
73
|
+
return "utf8";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_encodeQuotedPrintable(str, bufferEncoding = "utf8") {
|
|
62
77
|
if (!str) return "";
|
|
63
78
|
|
|
64
79
|
// 1. Split into paragraphs first to preserve user's hard returns
|
|
@@ -66,7 +81,7 @@ class MimeMessage {
|
|
|
66
81
|
|
|
67
82
|
return paragraphs
|
|
68
83
|
.map((line) => {
|
|
69
|
-
const buffer = Buffer.from(line,
|
|
84
|
+
const buffer = Buffer.from(line, bufferEncoding);
|
|
70
85
|
|
|
71
86
|
// 2. Pre-encode characters
|
|
72
87
|
let tempStr = "";
|
|
@@ -372,38 +387,96 @@ class MimeMessage {
|
|
|
372
387
|
}
|
|
373
388
|
|
|
374
389
|
_validateEncoding(encoding) {
|
|
375
|
-
const allowed = ["quoted-printable", "base64"];
|
|
390
|
+
const allowed = ["quoted-printable", "base64", "7bit", "8bit"];
|
|
376
391
|
if (!allowed.includes(encoding)) {
|
|
377
392
|
throw new Error(`Invalid encoding: ${encoding}. Allowed options: ${allowed.join(", ")}`);
|
|
378
393
|
}
|
|
379
394
|
}
|
|
380
395
|
|
|
381
|
-
_addBodyPart(data, contentType, encoding) {
|
|
382
|
-
|
|
383
|
-
if (
|
|
396
|
+
_addBodyPart(data, contentType, encoding, charset = "UTF-8") {
|
|
397
|
+
// 1. Normalize data to string
|
|
398
|
+
if (Buffer.isBuffer(data)) {
|
|
399
|
+
data = data.toString("utf8");
|
|
400
|
+
} else if (typeof data !== "string") {
|
|
401
|
+
data = data !== null && data !== undefined ? String(data) : "";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let finalEncoding = encoding;
|
|
405
|
+
let finalCharset = charset;
|
|
384
406
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
407
|
+
// 2. Validate and Fallback Encoding
|
|
408
|
+
const allowedEncodings = ["quoted-printable", "base64", "7bit", "8bit"];
|
|
409
|
+
if (!allowedEncodings.includes(finalEncoding)) {
|
|
410
|
+
finalEncoding = "quoted-printable";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 3. Map/Validate Charset Support in Node.js
|
|
414
|
+
const cleanCharset = (finalCharset || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
415
|
+
let bufferEncoding = "utf8";
|
|
416
|
+
|
|
417
|
+
if (cleanCharset === "iso88591" || cleanCharset === "latin1") {
|
|
418
|
+
bufferEncoding = "latin1";
|
|
419
|
+
finalCharset = "ISO-8859-1";
|
|
420
|
+
} else if (cleanCharset === "usascii" || cleanCharset === "ascii") {
|
|
421
|
+
bufferEncoding = "ascii";
|
|
422
|
+
finalCharset = "US-ASCII";
|
|
388
423
|
} else {
|
|
424
|
+
bufferEncoding = "utf8";
|
|
425
|
+
finalCharset = "UTF-8";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 4. Content Character Compatibility Checks
|
|
429
|
+
const hasNonAscii = /[^\x00-\x7F]/.test(data);
|
|
430
|
+
const hasNonLatin1 = /[^\x00-\xFF]/.test(data);
|
|
431
|
+
|
|
432
|
+
// If the data contains non-ASCII characters, 7bit encoding/ASCII charset are unsupported -> Upgrade
|
|
433
|
+
if (hasNonAscii) {
|
|
434
|
+
if (bufferEncoding === "ascii") {
|
|
435
|
+
bufferEncoding = "utf8";
|
|
436
|
+
finalCharset = "UTF-8";
|
|
437
|
+
}
|
|
438
|
+
if (finalEncoding === "7bit") {
|
|
439
|
+
finalEncoding = "quoted-printable";
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// If the data contains characters outside Latin-1, ISO-8859-1 is unsupported -> Upgrade
|
|
444
|
+
if (hasNonLatin1) {
|
|
445
|
+
if (bufferEncoding === "latin1") {
|
|
446
|
+
bufferEncoding = "utf8";
|
|
447
|
+
finalCharset = "UTF-8";
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let encodedData;
|
|
452
|
+
if (finalEncoding === "quoted-printable") {
|
|
453
|
+
encodedData = this._encodeQuotedPrintable(data, bufferEncoding);
|
|
454
|
+
} else if (finalEncoding === "base64") {
|
|
389
455
|
encodedData =
|
|
390
|
-
Buffer.from(data,
|
|
456
|
+
Buffer.from(data, bufferEncoding)
|
|
391
457
|
.toString("base64")
|
|
392
458
|
.match(/.{1,76}/g)
|
|
393
459
|
?.join("\r\n") || "";
|
|
460
|
+
} else {
|
|
461
|
+
// For 7bit and 8bit, normalize newlines to CRLF
|
|
462
|
+
encodedData = data.replace(/\r?\n|\r/g, "\r\n");
|
|
394
463
|
}
|
|
395
464
|
|
|
465
|
+
// Ensure charset is wrapped in quotes
|
|
466
|
+
const cleanCharsetName = finalCharset.replace(/"/g, "");
|
|
467
|
+
const formattedCharset = `"${cleanCharsetName}"`;
|
|
468
|
+
|
|
396
469
|
this.bodyParts.push({
|
|
397
470
|
data: encodedData,
|
|
398
471
|
contentType,
|
|
399
|
-
charset:
|
|
400
|
-
encoding,
|
|
472
|
+
charset: formattedCharset,
|
|
473
|
+
encoding: finalEncoding,
|
|
401
474
|
});
|
|
402
475
|
}
|
|
403
476
|
|
|
404
477
|
// Default encoding is now 'quoted-printable'
|
|
405
|
-
addText({ data, encoding = "quoted-printable" }) {
|
|
406
|
-
this._addBodyPart(data, "text/plain", encoding);
|
|
478
|
+
addText({ data, encoding = "quoted-printable", charset = "UTF-8" }) {
|
|
479
|
+
this._addBodyPart(data, "text/plain", encoding, charset);
|
|
407
480
|
}
|
|
408
481
|
|
|
409
482
|
/**
|
|
@@ -420,6 +493,7 @@ class MimeMessage {
|
|
|
420
493
|
autoTextPart = false,
|
|
421
494
|
autoMinify = true,
|
|
422
495
|
markdownToHtml = false,
|
|
496
|
+
charset = "UTF-8",
|
|
423
497
|
}) {
|
|
424
498
|
if (markdownToHtml) {
|
|
425
499
|
// Push the async operation to the queue
|
|
@@ -436,6 +510,7 @@ class MimeMessage {
|
|
|
436
510
|
autoTextPart,
|
|
437
511
|
autoMinify,
|
|
438
512
|
markdownToHtml: false,
|
|
513
|
+
charset,
|
|
439
514
|
});
|
|
440
515
|
})
|
|
441
516
|
.catch((err) => {
|
|
@@ -447,6 +522,7 @@ class MimeMessage {
|
|
|
447
522
|
autoTextPart,
|
|
448
523
|
autoMinify,
|
|
449
524
|
markdownToHtml: false,
|
|
525
|
+
charset,
|
|
450
526
|
});
|
|
451
527
|
});
|
|
452
528
|
|
|
@@ -460,12 +536,12 @@ class MimeMessage {
|
|
|
460
536
|
finalHtml = this._minifyHtml(finalHtml);
|
|
461
537
|
}
|
|
462
538
|
|
|
463
|
-
this._addBodyPart(finalHtml, "text/html", encoding);
|
|
539
|
+
this._addBodyPart(finalHtml, "text/html", encoding, charset);
|
|
464
540
|
|
|
465
541
|
if (autoTextPart) {
|
|
466
542
|
// Use ORIGINAL data for text conversion to ensure readability
|
|
467
543
|
const plainText = this._htmlToText(data);
|
|
468
|
-
this.addText({ data: plainText, encoding: encoding });
|
|
544
|
+
this.addText({ data: plainText, encoding: encoding, charset: charset });
|
|
469
545
|
}
|
|
470
546
|
}
|
|
471
547
|
|
|
@@ -524,7 +600,26 @@ class MimeMessage {
|
|
|
524
600
|
} while (prefix + newPart === oldBoundary);
|
|
525
601
|
return prefix + newPart;
|
|
526
602
|
}
|
|
527
|
-
|
|
603
|
+
|
|
604
|
+
if (this.boundaryStyle === "outlook") {
|
|
605
|
+
// Office 365/Outlook format: _000_HashedIDapcp_
|
|
606
|
+
const middle = randomBytes(24).toString("hex").toUpperCase();
|
|
607
|
+
return `_000_${middle}apcp_`;
|
|
608
|
+
} else if (this.boundaryStyle === "apple-mail") {
|
|
609
|
+
// Apple Mail style: Apple-Mail=_UUID
|
|
610
|
+
const uuid = randomBytes(16).toString("hex").toUpperCase();
|
|
611
|
+
const formattedUuid = [
|
|
612
|
+
uuid.slice(0, 8),
|
|
613
|
+
uuid.slice(8, 12),
|
|
614
|
+
uuid.slice(12, 16),
|
|
615
|
+
uuid.slice(16, 20),
|
|
616
|
+
uuid.slice(20),
|
|
617
|
+
].join("-");
|
|
618
|
+
return `Apple-Mail=_${formattedUuid}`;
|
|
619
|
+
} else {
|
|
620
|
+
// Default Google style
|
|
621
|
+
return "000000000000" + randomBytes(8).toString("hex");
|
|
622
|
+
}
|
|
528
623
|
}
|
|
529
624
|
|
|
530
625
|
// ─── BUILDING THE RAW EMAIL ───────────────────────────────────────
|
|
@@ -533,18 +628,27 @@ class MimeMessage {
|
|
|
533
628
|
// Wait for Attachments AND Markdown Processing
|
|
534
629
|
await Promise.all([...this.attachmentPromises, ...this.processingPromises]);
|
|
535
630
|
|
|
536
|
-
if (!this.headers.some((h) => h.name.toLowerCase() === "mime-version"))
|
|
631
|
+
if (!this.headers.some((h) => h.name.toLowerCase() === "mime-version") && !this.removedHeaders.has("mime-version"))
|
|
537
632
|
this.headers.unshift({ name: "MIME-Version", value: "1.0" });
|
|
538
|
-
if (!this.headers.some((h) => h.name.toLowerCase() === "date")
|
|
539
|
-
|
|
540
|
-
if (!this.headers.some((h) => h.name.toLowerCase() === "
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
633
|
+
if (!this.headers.some((h) => h.name.toLowerCase() === "date") && !this.removedHeaders.has("date"))
|
|
634
|
+
this.setDate();
|
|
635
|
+
if (!this.headers.some((h) => h.name.toLowerCase() === "message-id") && !this.removedHeaders.has("message-id"))
|
|
636
|
+
this.messageId();
|
|
637
|
+
if (!this.headers.some((h) => h.name.toLowerCase() === "subject") && !this.removedHeaders.has("subject"))
|
|
638
|
+
this.setSubject("");
|
|
639
|
+
|
|
640
|
+
// Sort Body: Text parts must come before HTML parts, keeping stable addition order
|
|
641
|
+
const texts = this.bodyParts.filter((p) => p.contentType === "text/plain");
|
|
642
|
+
const htmls = this.bodyParts.filter((p) => p.contentType === "text/html");
|
|
643
|
+
|
|
644
|
+
if (texts.length > 1) {
|
|
645
|
+
console.warn(`[MimeMessage] Warning: Multiple text/plain parts added (${texts.length}). Most email clients will only display the last text/plain part.`);
|
|
646
|
+
}
|
|
647
|
+
if (htmls.length > 1) {
|
|
648
|
+
console.warn(`[MimeMessage] Warning: Multiple text/html parts added (${htmls.length}). Most email clients will only display the last text/html part.`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
this.bodyParts = [...texts, ...htmls];
|
|
548
652
|
|
|
549
653
|
let body = "";
|
|
550
654
|
const numAttachments = this.attachments.length;
|
|
@@ -89,11 +89,37 @@ export const parseMail = async ({ rawData }) => {
|
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
// --- 3. Extract Headers ---
|
|
92
|
-
let
|
|
92
|
+
let envelopeToRaw = mail.headers.get("envelope-to");
|
|
93
|
+
if (Array.isArray(envelopeToRaw)) {
|
|
94
|
+
envelopeToRaw = envelopeToRaw[0];
|
|
95
|
+
}
|
|
96
|
+
let envelopeTo = "";
|
|
97
|
+
if (envelopeToRaw) {
|
|
98
|
+
if (typeof envelopeToRaw === "string") {
|
|
99
|
+
envelopeTo = envelopeToRaw.replace(/[<>]/g, "").trim().toLowerCase();
|
|
100
|
+
} else if (typeof envelopeToRaw === "object") {
|
|
101
|
+
const val = envelopeToRaw.text || envelopeToRaw.value || "";
|
|
102
|
+
envelopeTo = val.toString().replace(/[<>]/g, "").trim().toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
93
105
|
let envelopeToDomain = envelopeTo && envelopeTo.includes("@") ? envelopeTo.split("@")[1].toLowerCase() : null;
|
|
94
106
|
|
|
95
|
-
let
|
|
96
|
-
|
|
107
|
+
let returnPathRaw = mail.headers.get("return-path");
|
|
108
|
+
if (Array.isArray(returnPathRaw)) {
|
|
109
|
+
returnPathRaw = returnPathRaw[0];
|
|
110
|
+
}
|
|
111
|
+
let returnPath = null;
|
|
112
|
+
if (returnPathRaw) {
|
|
113
|
+
if (typeof returnPathRaw === "string") {
|
|
114
|
+
returnPath = returnPathRaw.replace(/[<>]/g, "").trim().toLowerCase();
|
|
115
|
+
} else if (typeof returnPathRaw === "object") {
|
|
116
|
+
const val = returnPathRaw.text || returnPathRaw.value || "";
|
|
117
|
+
returnPath = val.toString().replace(/[<>]/g, "").trim().toLowerCase();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let fromEmail = mail.from?.value?.[0]?.address ? mail.from.value[0].address.toLowerCase() : null;
|
|
122
|
+
let toEmail = mail.to?.value?.[0]?.address ? mail.to.value[0].address.toLowerCase() : null;
|
|
97
123
|
|
|
98
124
|
// New: toEmailArray with Deduplication
|
|
99
125
|
let toEmailArray = [];
|
|
@@ -104,7 +130,7 @@ export const parseMail = async ({ rawData }) => {
|
|
|
104
130
|
toEmailArray = [...new Set(rawEmails)]; // Remove duplicates
|
|
105
131
|
}
|
|
106
132
|
|
|
107
|
-
let replyToEmail = mail.replyTo?.value[0]?.address ? mail.replyTo.value[0].address.toLowerCase() : null;
|
|
133
|
+
let replyToEmail = mail.replyTo?.value?.[0]?.address ? mail.replyTo.value[0].address.toLowerCase() : null;
|
|
108
134
|
|
|
109
135
|
// --- 4. Clean Names ---
|
|
110
136
|
const cleanName = (nameObj, emailObj) => {
|
|
@@ -115,9 +141,9 @@ export const parseMail = async ({ rawData }) => {
|
|
|
115
141
|
return name ? name.trim().substring(0, 100) : "";
|
|
116
142
|
};
|
|
117
143
|
|
|
118
|
-
let fromName = cleanName(mail.from?.value[0]?.name, fromEmail);
|
|
119
|
-
let toName = cleanName(mail.to?.value[0]?.name, toEmail);
|
|
120
|
-
let replyToName = cleanName(mail.replyTo?.value[0]?.name, replyToEmail);
|
|
144
|
+
let fromName = cleanName(mail.from?.value?.[0]?.name, fromEmail);
|
|
145
|
+
let toName = cleanName(mail.to?.value?.[0]?.name, toEmail);
|
|
146
|
+
let replyToName = cleanName(mail.replyTo?.value?.[0]?.name, replyToEmail);
|
|
121
147
|
|
|
122
148
|
// --- 5. Clean HTML & Text ---
|
|
123
149
|
let html = mail.html || mail.textAsHtml || "";
|
|
@@ -179,7 +205,7 @@ export const parseMail = async ({ rawData }) => {
|
|
|
179
205
|
|
|
180
206
|
// --- 8. Traffic Source ---
|
|
181
207
|
const isOfficialMailer = fromEmail === "mailer@mailersp.doublelist.com" || fromEmail === "robot@doublelist.com";
|
|
182
|
-
if (isOfficialMailer && mail.replyTo?.value[0]?.address) {
|
|
208
|
+
if (isOfficialMailer && mail.replyTo?.value?.[0]?.address) {
|
|
183
209
|
fromEmail = mail.replyTo?.value[0]?.address?.toLowerCase();
|
|
184
210
|
fromName = "";
|
|
185
211
|
trafficSource = "dbr-w4m";
|
|
@@ -208,7 +234,7 @@ export const parseMail = async ({ rawData }) => {
|
|
|
208
234
|
messageId: mail.messageId || null,
|
|
209
235
|
inReplyTo: mail.inReplyTo || null,
|
|
210
236
|
references: mail.references || null,
|
|
211
|
-
returnPath
|
|
237
|
+
returnPath,
|
|
212
238
|
|
|
213
239
|
html,
|
|
214
240
|
markdownResult, // This now contains the Cleaned Reply Markdown
|