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 +6 -14
- package/src/dkim-signer.js +14 -14
- package/src/rawmimeBuilder.js +81 -33
- package/src/utility/mailParser.d.ts +2 -2
- package/src/utility/mailParser.js +2 -2
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arn-rawmime",
|
|
3
|
-
"version": "0.0.
|
|
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": "^
|
|
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": "^
|
|
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": {
|
package/src/dkim-signer.js
CHANGED
|
@@ -68,12 +68,12 @@ function DKIMSign(email, options) {
|
|
|
68
68
|
options = options || {};
|
|
69
69
|
email = (email || "").toString("utf-8");
|
|
70
70
|
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
210
|
+
let resultHeaders = [];
|
|
211
|
+
let usedFields = [];
|
|
212
212
|
for (i = includedFields.length - 1; i >= 0; i--) {
|
|
213
|
-
|
|
213
|
+
let keyLower = includedFields[i].toLowerCase();
|
|
214
214
|
if (!headerFields[keyLower]) {
|
|
215
215
|
includedFields.splice(i, 1);
|
|
216
216
|
} else {
|
|
217
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
+
const rforeign = /[^\u0000-\u007f]/;
|
|
266
266
|
return !!rforeign.test(str);
|
|
267
267
|
}
|
|
268
268
|
|
package/src/rawmimeBuilder.js
CHANGED
|
@@ -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";
|
|
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 = [];
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
139
|
-
if (hasNonAscii) {
|
|
140
|
-
|
|
141
|
-
if (/[^\x00-\x7F]/.test(
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
let
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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) =>
|
|
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,
|
|
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",
|
|
612
|
-
"list-unsubscribe-post",
|
|
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 {
|
|
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:
|
|
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
|
|
15
|
-
for (let i = 1; i < length
|
|
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;
|