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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arn-rawmime",
3
- "version": "0.0.9",
3
+ "version": "0.1.1",
4
4
  "description": "A lightweight, dependency-free raw MIME email builder with DKIM support.",
5
5
  "author": "ARNDESK",
6
6
  "type": "module",
@@ -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: { data: string; encoding?: "quoted-printable" | "base64" }): void;
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
  /**
@@ -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
- // ─── HELPER: Smart Quoted-Printable Encoder (Word-Wrap) ─────────
61
- _encodeQuotedPrintable(str) {
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, "utf8");
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
- this._validateEncoding(encoding);
383
- if (typeof data !== "string") data = "";
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
- let encodedData;
386
- if (encoding === "quoted-printable") {
387
- encodedData = this._encodeQuotedPrintable(data);
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, "utf8")
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: '"UTF-8"', // Added quotes for compliance
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
- return "000000000000" + randomBytes(8).toString("hex");
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")) this.setDate();
539
- if (!this.headers.some((h) => h.name.toLowerCase() === "message-id")) this.messageId();
540
- if (!this.headers.some((h) => h.name.toLowerCase() === "subject")) this.setSubject("");
541
-
542
- // Sort Body: Text MUST be first
543
- this.bodyParts.sort((a, b) => {
544
- if (a.contentType === "text/plain") return -1;
545
- if (b.contentType === "text/plain") return 1;
546
- return 0;
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 envelopeTo = (mail.headers.get("envelope-to") || "").toLowerCase();
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 fromEmail = mail.from?.value[0]?.address ? mail.from.value[0].address.toLowerCase() : null;
96
- let toEmail = mail.to?.value[0]?.address ? mail.to.value[0].address.toLowerCase() : null;
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: mail.headers.get("return-path")?.text?.toLowerCase() || null,
237
+ returnPath,
212
238
 
213
239
  html,
214
240
  markdownResult, // This now contains the Cleaned Reply Markdown