arn-rawmime 0.0.5 → 0.0.7

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,9 +1,12 @@
1
1
  {
2
2
  "name": "arn-rawmime",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "A lightweight, dependency-free raw MIME email builder with DKIM support.",
5
5
  "author": "ARNDESK",
6
6
  "type": "module",
7
+ "engines": {
8
+ "node": ">=16.0.0"
9
+ },
7
10
  "main": "src/index.js",
8
11
  "types": "src/index.d.ts",
9
12
  "files": [
@@ -13,22 +16,11 @@
13
16
  "test": "test"
14
17
  },
15
18
  "dependencies": {
16
- "deepmerge": "^4.3.1",
17
- "dom-serializer": "^2.0.0",
18
- "domelementtype": "^2.3.0",
19
- "domhandler": "^5.0.3",
20
- "domutils": "^3.2.2",
21
- "entities": "^4.5.0",
22
19
  "he": "^1.2.0",
23
- "html-to-text": "^9.0.5",
24
- "htmlparser2": "^8.0.2",
25
- "leac": "^0.6.0",
20
+ "html-to-text": "^10.0.0",
26
21
  "mailparser": "^3.9.0",
27
- "marked": "^17.0.1",
22
+ "marked": "^18.0.3",
28
23
  "mime-types": "^3.0.2",
29
- "parseley": "^0.12.1",
30
- "peberminta": "^0.9.0",
31
- "selderee": "^0.11.0",
32
24
  "turndown": "^7.2.2"
33
25
  },
34
26
  "scripts": {
@@ -68,12 +68,12 @@ function DKIMSign(email, options) {
68
68
  options = options || {};
69
69
  email = (email || "").toString("utf-8");
70
70
 
71
- var match = email.match(/^\r?\n|(?:\r?\n){2}/),
71
+ let match = email.match(/^\r?\n|(?:\r?\n){2}/),
72
72
  headers = (match && email.substring(0, match.index)) || "",
73
73
  body = (match && email.substring(match.index + match[0].length)) || email;
74
74
 
75
75
  // All listed fields from RFC4871 #5.5
76
- var defaultFieldNames =
76
+ const defaultFieldNames =
77
77
  "From:Sender:Reply-To:Subject:To:" +
78
78
  "Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:" +
79
79
  "Content-Description:Resent-Date:Resent-From:Resent-Sender:" +
@@ -82,7 +82,7 @@ function DKIMSign(email, options) {
82
82
  "List-Owner:List-Archive:" +
83
83
  "List-Unsubscribe-Post:Date:Message-ID:Feedback-ID:DKIM-Signature:";
84
84
 
85
- var dkim = generateDKIMHeader(
85
+ let dkim = generateDKIMHeader(
86
86
  options.domainName,
87
87
  options.keySelector,
88
88
  options.headerFieldNames || defaultFieldNames,
@@ -117,7 +117,7 @@ function DKIMSign(email, options) {
117
117
  * @return {String} Mime folded DKIM-Signature string
118
118
  */
119
119
  function generateDKIMHeader(domainName, keySelector, headerFieldNames, headers, body) {
120
- var canonicalizedBody = DKIMCanonicalizer.relaxedBody(body),
120
+ let canonicalizedBody = DKIMCanonicalizer.relaxedBody(body),
121
121
  canonicalizedBodyHash = sha256(canonicalizedBody, "base64"),
122
122
  canonicalizedHeaderData = DKIMCanonicalizer.relaxedHeaders(headers, headerFieldNames),
123
123
  dkim;
@@ -176,7 +176,7 @@ const DKIMCanonicalizer = {
176
176
  * @return {Object} Canonicalized headers and field names
177
177
  */
178
178
  relaxedHeaders: function (headers, fieldNames) {
179
- var includedFields = (fieldNames || "").split(":").map(function (field) {
179
+ let includedFields = (fieldNames || "").split(":").map(function (field) {
180
180
  return field.trim();
181
181
  }),
182
182
  includedFieldsLower = includedFields.map(function (field) {
@@ -197,7 +197,7 @@ const DKIMCanonicalizer = {
197
197
  // Process header lines
198
198
  for (i = 0; i < headerLines.length; i++) {
199
199
  line = DKIMCanonicalizer.relaxedHeaderLine(headerLines[i]);
200
- var keyLower = line.key.toLowerCase();
200
+ let keyLower = line.key.toLowerCase();
201
201
 
202
202
  if (includedFieldsLower.indexOf(keyLower) >= 0 && !(keyLower in headerFields)) {
203
203
  headerFields[keyLower] = {
@@ -207,20 +207,20 @@ const DKIMCanonicalizer = {
207
207
  }
208
208
  }
209
209
 
210
- var headers = [];
211
- var usedFields = [];
210
+ let resultHeaders = [];
211
+ let usedFields = [];
212
212
  for (i = includedFields.length - 1; i >= 0; i--) {
213
- var keyLower = includedFields[i].toLowerCase();
213
+ let keyLower = includedFields[i].toLowerCase();
214
214
  if (!headerFields[keyLower]) {
215
215
  includedFields.splice(i, 1);
216
216
  } else {
217
- headers.unshift(headerFields[keyLower].key.toLowerCase() + ":" + headerFields[keyLower].value);
217
+ resultHeaders.unshift(headerFields[keyLower].key.toLowerCase() + ":" + headerFields[keyLower].value);
218
218
  usedFields.unshift(headerFields[keyLower].key);
219
219
  }
220
220
  }
221
221
 
222
222
  return {
223
- headers: headers.join("\r\n") + "\r\n",
223
+ headers: resultHeaders.join("\r\n") + "\r\n",
224
224
  fieldNames: usedFields.join(":"),
225
225
  };
226
226
  },
@@ -231,7 +231,7 @@ const DKIMCanonicalizer = {
231
231
  * @return {Object} Parsed header line with key and value
232
232
  */
233
233
  relaxedHeaderLine: function (line) {
234
- var parts = line.split(":"),
234
+ let parts = line.split(":"),
235
235
  key = (parts.shift() || "").trim(),
236
236
  value = parts.join(":").replace(/\s+/g, " ").trim();
237
237
 
@@ -250,7 +250,7 @@ const DKIMCanonicalizer = {
250
250
  * @return {String} SHA-256 hash in the selected output encoding
251
251
  */
252
252
  function sha256(str, encoding) {
253
- var shasum = crypto.createHash("sha256");
253
+ const shasum = crypto.createHash("sha256");
254
254
  shasum.update(str);
255
255
  return shasum.digest(encoding || "hex");
256
256
  }
@@ -262,7 +262,7 @@ function sha256(str, encoding) {
262
262
  * @return {Boolean} true if the string contains non-ASCII symbols
263
263
  */
264
264
  function hasUTFChars(str) {
265
- var rforeign = /[^\u0000-\u007f]/;
265
+ const rforeign = /[^\u0000-\u007f]/;
266
266
  return !!rforeign.test(str);
267
267
  }
268
268
 
@@ -4,7 +4,7 @@ import path from "path";
4
4
  import { randomBytes } from "crypto";
5
5
  import { convert } from "html-to-text";
6
6
  import { DKIMSign } from "./dkim-signer.js";
7
- import { processMarkDown } from "./processMarkDown.js"; // <--- IMPORT ADDED
7
+ import { processMarkDown } from "./processMarkDown.js";
8
8
 
9
9
  const desiredOrder = [
10
10
  "List-Unsubscribe-Post",
@@ -27,7 +27,7 @@ class MimeMessage {
27
27
  this.bodyParts = [];
28
28
  this.attachments = [];
29
29
  this.attachmentPromises = [];
30
- this.processingPromises = []; // <--- NEW: Track async ops
30
+ this.processingPromises = [];
31
31
  this.removedHeaders = new Set();
32
32
  }
33
33
 
@@ -38,7 +38,7 @@ class MimeMessage {
38
38
  wordwrap: 130,
39
39
  selectors: [
40
40
  { selector: "img", format: "skip" },
41
- { selector: "a", options: { hideLinkHrefIfSameAsText: true } },
41
+ { selector: "a", options: { hideLinkHrefIfSameAsText: true, linkBrackets: ["<", ">"] } },
42
42
  ],
43
43
  });
44
44
  }
@@ -78,7 +78,12 @@ class MimeMessage {
78
78
  if (isSafe) {
79
79
  tempStr += String.fromCharCode(byte);
80
80
  } else if (isSpaceOrTab) {
81
- tempStr += String.fromCharCode(byte);
81
+ // RFC 2045 §6.7 Rule 3: trailing whitespace MUST be encoded
82
+ if (i === buffer.length - 1) {
83
+ tempStr += "=" + byte.toString(16).toUpperCase().padStart(2, "0");
84
+ } else {
85
+ tempStr += String.fromCharCode(byte);
86
+ }
82
87
  } else {
83
88
  tempStr += "=" + byte.toString(16).toUpperCase().padStart(2, "0");
84
89
  }
@@ -123,6 +128,39 @@ class MimeMessage {
123
128
  .join("\r\n");
124
129
  }
125
130
 
131
+ // ─── HELPER: RFC 2047 Encoded-Word Chunking ──────────────────────
132
+ _encodeRFC2047(str) {
133
+ const MAX_ENCODED_LENGTH = 75;
134
+ const prefix = "=?UTF-8?B?";
135
+ const suffix = "?=";
136
+ const maxB64Len = MAX_ENCODED_LENGTH - prefix.length - suffix.length; // 63
137
+ const maxBytes = Math.floor(maxB64Len * 3 / 4); // 47 bytes
138
+
139
+ let result = [];
140
+ let currentChunk = "";
141
+ let currentChunkBytes = 0;
142
+
143
+ for (const char of str) {
144
+ const charBytes = Buffer.byteLength(char, "utf8");
145
+ if (currentChunkBytes + charBytes > maxBytes) {
146
+ const b64 = Buffer.from(currentChunk, "utf8").toString("base64");
147
+ result.push(`${prefix}${b64}${suffix}`);
148
+ currentChunk = char;
149
+ currentChunkBytes = charBytes;
150
+ } else {
151
+ currentChunk += char;
152
+ currentChunkBytes += charBytes;
153
+ }
154
+ }
155
+ if (currentChunk.length > 0) {
156
+ const b64 = Buffer.from(currentChunk, "utf8").toString("base64");
157
+ result.push(`${prefix}${b64}${suffix}`);
158
+ }
159
+
160
+ // Separate encoded words by space. Standard ASCII folding will wrap them if needed.
161
+ return result.join(" ");
162
+ }
163
+
126
164
  // ─── HELPER: Header Folding (RFC 5322) ──────────────────────────
127
165
  _foldHeader(name, value) {
128
166
  const hasNonAscii = /[^\x00-\x7F]/.test(value);
@@ -130,45 +168,47 @@ class MimeMessage {
130
168
  // 1. Unstructured headers (Subject, etc) -> Full Encode
131
169
  const unstructured = ["subject", "x-report-abuse", "thread-topic"];
132
170
  if (hasNonAscii && unstructured.includes(name.toLowerCase())) {
133
- const encodedValue = Buffer.from(value, "utf8").toString("base64");
134
- return `${name}: =?UTF-8?B?${encodedValue}?=`;
171
+ value = this._encodeRFC2047(value);
135
172
  }
136
173
 
137
174
  // 2. Structured headers (From, To) -> Smart Replace
138
- // Finds quoted strings with special chars, e.g. "René", and encodes JUST that part.
139
- if (hasNonAscii) {
140
- const encodedStruct = value.replace(/"([^"]*)"/g, (match, content) => {
141
- if (/[^\x00-\x7F]/.test(content)) {
142
- const b64 = Buffer.from(content, "utf8").toString("base64");
143
- return `=?UTF-8?B?${b64}?=`;
175
+ // Encodes quoted non-ASCII strings AND unquoted non-ASCII words
176
+ if (hasNonAscii && !unstructured.includes(name.toLowerCase())) {
177
+ value = value.replace(/"([^"]*)"|([^\s<>]*[^\x00-\x7F][^\s<>]*)/g, (match, quoted, unquoted) => {
178
+ if (quoted !== undefined && /[^\x00-\x7F]/.test(quoted)) {
179
+ return this._encodeRFC2047(quoted);
180
+ }
181
+ if (unquoted !== undefined && /[^\x00-\x7F]/.test(unquoted)) {
182
+ return this._encodeRFC2047(unquoted);
144
183
  }
145
184
  return match;
146
185
  });
147
- // If we changed anything, return it. If not (e.g. unquoted special chars), fallback to old folding
148
- if (encodedStruct !== value) {
149
- return `${name}: ${encodedStruct}`;
150
- }
151
186
  }
152
187
 
153
188
  // 3. Standard folding for ASCII-only (or unhandled) headers
154
189
  const line = `${name}: ${value}`;
155
190
  if (line.length <= 76) return line;
156
191
 
157
- const headerKey = `${name}: `;
158
- let remaining = value;
159
- let result = headerKey;
160
- let available = 76 - headerKey.length;
161
-
162
- if (remaining.length < available) return result + remaining;
192
+ let result = `${name}: `;
193
+ const tokens = value.split(/(?=[ \t])/);
194
+ let currentLineLength = result.length;
195
+ const prefixLength = result.length;
163
196
 
164
- result += remaining.substring(0, available) + "\r\n ";
165
- remaining = remaining.substring(available);
197
+ tokens.forEach((token) => {
198
+ if (currentLineLength + token.length > 76 && currentLineLength > prefixLength) {
199
+ if (/^[ \t]/.test(token)) {
200
+ result += "\r\n" + token;
201
+ currentLineLength = token.length;
202
+ } else {
203
+ result += token;
204
+ currentLineLength += token.length;
205
+ }
206
+ } else {
207
+ result += token;
208
+ currentLineLength += token.length;
209
+ }
210
+ });
166
211
 
167
- while (remaining.length > 75) {
168
- result += remaining.substring(0, 75) + "\r\n ";
169
- remaining = remaining.substring(75);
170
- }
171
- result += remaining;
172
212
  return result;
173
213
  }
174
214
 
@@ -212,7 +252,10 @@ class MimeMessage {
212
252
  formatMailboxes(mailboxes) {
213
253
  if (!mailboxes) return "";
214
254
  const mbs = Array.isArray(mailboxes) ? mailboxes : [mailboxes];
215
- return mbs.map((mb) => (mb.name ? `"${mb.name}" <${mb.email}>` : mb.email)).join(", ");
255
+ return mbs.map((mb) => {
256
+ if (typeof mb === "string") return mb;
257
+ return mb.name ? `"${mb.name}" <${mb.email}>` : mb.email;
258
+ }).join(", ");
216
259
  }
217
260
 
218
261
  setFrom(sender) {
@@ -376,7 +419,7 @@ class MimeMessage {
376
419
  encoding = "quoted-printable",
377
420
  autoTextPart = false,
378
421
  autoMinify = true,
379
- markdownToHtml = false, // <--- NEW OPTION
422
+ markdownToHtml = false,
380
423
  }) {
381
424
  if (markdownToHtml) {
382
425
  // Push the async operation to the queue
@@ -604,15 +647,20 @@ class MimeMessage {
604
647
  // UPDATE THIS ARRAY BELOW
605
648
  // Add "list-unsubscribe" and "list-unsubscribe-post" to this list
606
649
  const doNotFold = [
650
+ "subject",
607
651
  "message-id",
608
652
  "references",
609
653
  "in-reply-to",
610
654
  "content-id",
611
- "list-unsubscribe", // <--- Added
612
- "list-unsubscribe-post", // <--- Added
655
+ "list-unsubscribe",
656
+ "list-unsubscribe-post",
613
657
  ];
614
658
 
615
659
  if (doNotFold.includes(lowerName)) {
660
+ // Still encode non-ASCII, just skip line folding
661
+ if (/[^\x00-\x7F]/.test(h.value)) {
662
+ return `${h.name}: ${this._encodeRFC2047(h.value)}`;
663
+ }
616
664
  return `${h.name}: ${h.value}`;
617
665
  }
618
666
  return this._foldHeader(h.name, h.value);
@@ -1,5 +1,5 @@
1
1
  // src/utility/mailParser.d.ts
2
- import type { ProcessMarkDownResult } from "../processMarkDown";
2
+ import type { MarkdownResult } from "../processMarkDown";
3
3
 
4
4
  /**
5
5
  * Generates a random numeric ID string for Doublelist posts.
@@ -50,7 +50,7 @@ export interface ParsedMailData {
50
50
  /** HTML body with tracking images removed */
51
51
  html: string;
52
52
  /** Processed markdown result from processMarkDown */
53
- markdownResult: ProcessMarkDownResult;
53
+ markdownResult: MarkdownResult;
54
54
  /** Plain text body */
55
55
  text: string;
56
56
  /** Number of attachments */
@@ -11,8 +11,8 @@ export const randomizeDoublelistPostID = (length = 11) => {
11
11
  // 1. Pick the first digit (1-9)
12
12
  let result = firstDigit.charAt(Math.floor(Math.random() * firstDigit.length));
13
13
  // 2. Generate the rest, stopping 1 digit early
14
- // If length is 11, this loop generates 9 more digits (Total: 10)
15
- for (let i = 1; i < length - 1; i++) {
14
+ // If length is 11, this loop generates 10 more digits (Total: 11)
15
+ for (let i = 1; i < length; i++) {
16
16
  result += restDigits.charAt(Math.floor(Math.random() * restDigits.length));
17
17
  }
18
18
  return result;