arn-rawmime 0.0.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/LICENSE +0 -0
- package/README.md +3 -0
- package/package.json +38 -0
- package/src/dkim-signer.d.ts +13 -0
- package/src/dkim-signer.js +269 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +6 -0
- package/src/processMarkDown.d.ts +32 -0
- package/src/processMarkDown.js +283 -0
- package/src/rawmimeBuilder.d.ts +91 -0
- package/src/rawmimeBuilder.js +622 -0
package/LICENSE
ADDED
|
File without changes
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "arn-rawmime",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A lightweight, dependency-free raw MIME email builder with DKIM support.",
|
|
5
|
+
"author": "ARNDESK",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"types": "src/index.d.ts",
|
|
9
|
+
"directories": {
|
|
10
|
+
"test": "test"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"deepmerge": "^4.3.1",
|
|
14
|
+
"dom-serializer": "^2.0.0",
|
|
15
|
+
"domelementtype": "^2.3.0",
|
|
16
|
+
"domhandler": "^5.0.3",
|
|
17
|
+
"domutils": "^3.2.2",
|
|
18
|
+
"entities": "^4.5.0",
|
|
19
|
+
"he": "^1.2.0",
|
|
20
|
+
"html-to-text": "^9.0.5",
|
|
21
|
+
"htmlparser2": "^8.0.2",
|
|
22
|
+
"leac": "^0.6.0",
|
|
23
|
+
"marked": "^17.0.1",
|
|
24
|
+
"mime-types": "^3.0.2",
|
|
25
|
+
"parseley": "^0.12.1",
|
|
26
|
+
"peberminta": "^0.9.0",
|
|
27
|
+
"selderee": "^0.11.0",
|
|
28
|
+
"turndown": "^7.2.2"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mime",
|
|
35
|
+
"email"
|
|
36
|
+
],
|
|
37
|
+
"license": "ISC"
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/dkim-signer.d.ts
|
|
2
|
+
|
|
3
|
+
export interface DKIMOptions {
|
|
4
|
+
headerFieldNames?: string;
|
|
5
|
+
privateKey: string;
|
|
6
|
+
domainName: string;
|
|
7
|
+
keySelector: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Signs a raw email string with DKIM.
|
|
12
|
+
*/
|
|
13
|
+
export function DKIMSign(email: string, options: DKIMOptions): string;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Folds a string into multiple lines that don't exceed the given length
|
|
5
|
+
* @param {String} str String to be folded
|
|
6
|
+
* @param {Number} [lineLength=76] Maximum length of a line
|
|
7
|
+
* @param {Boolean} [afterSpace=false] If true, break after spaces
|
|
8
|
+
* @return {String} Folded string
|
|
9
|
+
*/
|
|
10
|
+
function foldLines(str, lineLength = 76, afterSpace = false) {
|
|
11
|
+
str = (str || "").toString();
|
|
12
|
+
let pos = 0;
|
|
13
|
+
let len = str.length;
|
|
14
|
+
let result = "";
|
|
15
|
+
|
|
16
|
+
while (pos < len) {
|
|
17
|
+
let line = str.substring(pos, pos + lineLength);
|
|
18
|
+
|
|
19
|
+
if (line.length < lineLength) {
|
|
20
|
+
result += line;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check for existing line breaks
|
|
25
|
+
let lineBreakMatch = line.match(/^[^\n\r]*(\r?\n|\r)/);
|
|
26
|
+
if (lineBreakMatch) {
|
|
27
|
+
line = lineBreakMatch[0];
|
|
28
|
+
result += line;
|
|
29
|
+
pos += line.length;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Find appropriate breaking point
|
|
34
|
+
let wsMatch = line.match(/(\s+)[^\s]*$/);
|
|
35
|
+
if (wsMatch && wsMatch[0].length - (afterSpace ? (wsMatch[1] || "").length : 0) < line.length) {
|
|
36
|
+
line = line.substring(0, line.length - (wsMatch[0].length - (afterSpace ? (wsMatch[1] || "").length : 0)));
|
|
37
|
+
} else {
|
|
38
|
+
let nextMatch = str.substring(pos + line.length).match(/^[^\s]+(\s*)/);
|
|
39
|
+
if (nextMatch) {
|
|
40
|
+
line = line + nextMatch[0].substring(0, nextMatch[0].length - (!afterSpace ? (nextMatch[1] || "").length : 0));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
result += line;
|
|
45
|
+
pos += line.length;
|
|
46
|
+
|
|
47
|
+
if (pos < len) {
|
|
48
|
+
result += "\r\n";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sign an email with provided DKIM key, uses RSA-SHA256.
|
|
57
|
+
*
|
|
58
|
+
* @param {String} email Full e-mail source complete with headers and body to sign
|
|
59
|
+
* @param {Object} options DKIM options
|
|
60
|
+
* @param {String} [options.headerFieldNames='from:to:cc:subject'] Header fields to sign
|
|
61
|
+
* @param {String} options.privateKey DKIM private key
|
|
62
|
+
* @param {String} options.domainName Domain name to use for signing (e.g., 'domain.com')
|
|
63
|
+
* @param {String} options.keySelector Selector for the DKIM public key (e.g., 'dkim' if you have set up a TXT record for 'dkim._domainkey.domain.com')
|
|
64
|
+
*
|
|
65
|
+
* @return {String} Signed DKIM-Signature header field for prepending
|
|
66
|
+
*/
|
|
67
|
+
function DKIMSign(email, options) {
|
|
68
|
+
options = options || {};
|
|
69
|
+
email = (email || "").toString("utf-8");
|
|
70
|
+
|
|
71
|
+
var match = email.match(/^\r?\n|(?:\r?\n){2}/),
|
|
72
|
+
headers = (match && email.substring(0, match.index)) || "",
|
|
73
|
+
body = (match && email.substring(match.index + match[0].length)) || email;
|
|
74
|
+
|
|
75
|
+
// All listed fields from RFC4871 #5.5
|
|
76
|
+
var defaultFieldNames =
|
|
77
|
+
"From:Sender:Reply-To:Subject:To:" +
|
|
78
|
+
"Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:" +
|
|
79
|
+
"Content-Description:Resent-Date:Resent-From:Resent-Sender:" +
|
|
80
|
+
"Resent-To:Resent-Cc:Resent-Message-ID:In-Reply-To:References:" +
|
|
81
|
+
"List-Id:List-Help:List-Unsubscribe:List-Subscribe:List-Post:" +
|
|
82
|
+
"List-Owner:List-Archive:" +
|
|
83
|
+
"List-Unsubscribe-Post:Date:Message-ID:Feedback-ID:DKIM-Signature:";
|
|
84
|
+
|
|
85
|
+
var dkim = generateDKIMHeader(
|
|
86
|
+
options.domainName,
|
|
87
|
+
options.keySelector,
|
|
88
|
+
options.headerFieldNames || defaultFieldNames,
|
|
89
|
+
headers,
|
|
90
|
+
body
|
|
91
|
+
),
|
|
92
|
+
canonicalizedHeaderData = DKIMCanonicalizer.relaxedHeaders(headers, options.headerFieldNames || defaultFieldNames),
|
|
93
|
+
canonicalizedDKIMHeader = DKIMCanonicalizer.relaxedHeaderLine(dkim),
|
|
94
|
+
signer,
|
|
95
|
+
signature;
|
|
96
|
+
|
|
97
|
+
// Add the DKIM-Signature header to the canonicalized headers
|
|
98
|
+
canonicalizedHeaderData.headers += canonicalizedDKIMHeader.key.toLowerCase() + ":" + canonicalizedDKIMHeader.value;
|
|
99
|
+
|
|
100
|
+
signer = crypto.createSign("RSA-SHA256");
|
|
101
|
+
signer.update(canonicalizedHeaderData.headers);
|
|
102
|
+
signature = signer.sign(options.privateKey, "base64");
|
|
103
|
+
|
|
104
|
+
return dkim + signature.replace(/(^.{73}|.{75}(?!\r?\n|\r))/g, "$&\r\n ").trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generates a DKIM-Signature header field without the signature part ('b=' is empty)
|
|
109
|
+
*
|
|
110
|
+
* @private
|
|
111
|
+
* @param {String} domainName Domain name to use for signing
|
|
112
|
+
* @param {String} keySelector Selector for the DKIM public key
|
|
113
|
+
* @param {String} headerFieldNames Header fields to sign
|
|
114
|
+
* @param {String} headers E-mail headers
|
|
115
|
+
* @param {String} body E-mail body
|
|
116
|
+
*
|
|
117
|
+
* @return {String} Mime folded DKIM-Signature string
|
|
118
|
+
*/
|
|
119
|
+
function generateDKIMHeader(domainName, keySelector, headerFieldNames, headers, body) {
|
|
120
|
+
var canonicalizedBody = DKIMCanonicalizer.relaxedBody(body),
|
|
121
|
+
canonicalizedBodyHash = sha256(canonicalizedBody, "base64"),
|
|
122
|
+
canonicalizedHeaderData = DKIMCanonicalizer.relaxedHeaders(headers, headerFieldNames),
|
|
123
|
+
dkim;
|
|
124
|
+
|
|
125
|
+
if (hasUTFChars(domainName)) {
|
|
126
|
+
try {
|
|
127
|
+
const url = new URL(`http://${domainName}`);
|
|
128
|
+
domainName = url.hostname;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn("Failed to convert domain to ASCII:", err);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
dkim = [
|
|
135
|
+
"v=1",
|
|
136
|
+
"a=rsa-sha256",
|
|
137
|
+
"c=relaxed/relaxed",
|
|
138
|
+
"d=" + domainName,
|
|
139
|
+
"q=dns/txt",
|
|
140
|
+
"s=" + keySelector,
|
|
141
|
+
"bh=" + canonicalizedBodyHash,
|
|
142
|
+
"h=" + canonicalizedHeaderData.fieldNames,
|
|
143
|
+
].join("; ");
|
|
144
|
+
|
|
145
|
+
return foldLines("DKIM-Signature: " + dkim, 76) + ";\r\n b=";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* DKIM canonicalization functions
|
|
150
|
+
*/
|
|
151
|
+
const DKIMCanonicalizer = {
|
|
152
|
+
/**
|
|
153
|
+
* Applies "relaxed" canonicalization to message body
|
|
154
|
+
* @param {String} body Message body
|
|
155
|
+
* @return {String} Canonicalized body
|
|
156
|
+
*/
|
|
157
|
+
relaxedBody: function (body) {
|
|
158
|
+
return (body || "")
|
|
159
|
+
.toString()
|
|
160
|
+
.replace(/\r?\n|\r/g, "\n") // Normalize line endings to \n
|
|
161
|
+
.split("\n")
|
|
162
|
+
.map(function (line) {
|
|
163
|
+
return line
|
|
164
|
+
.replace(/\s*$/, "") // Remove trailing whitespace
|
|
165
|
+
.replace(/\s+/g, " "); // Collapse multiple whitespace to single space
|
|
166
|
+
})
|
|
167
|
+
.join("\n")
|
|
168
|
+
.replace(/\n*$/, "\n") // Ensure exactly one trailing newline
|
|
169
|
+
.replace(/\n/g, "\r\n"); // Convert line endings to CRLF
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Applies "relaxed" canonicalization to headers
|
|
174
|
+
* @param {String} headers Header string
|
|
175
|
+
* @param {String} fieldNames List of headers to include
|
|
176
|
+
* @return {Object} Canonicalized headers and field names
|
|
177
|
+
*/
|
|
178
|
+
relaxedHeaders: function (headers, fieldNames) {
|
|
179
|
+
var includedFields = (fieldNames || "").split(":").map(function (field) {
|
|
180
|
+
return field.trim();
|
|
181
|
+
}),
|
|
182
|
+
includedFieldsLower = includedFields.map(function (field) {
|
|
183
|
+
return field.toLowerCase();
|
|
184
|
+
}),
|
|
185
|
+
headerFields = {},
|
|
186
|
+
headerLines = headers.split(/\r?\n|\r/),
|
|
187
|
+
line,
|
|
188
|
+
i;
|
|
189
|
+
|
|
190
|
+
// Join continuation lines
|
|
191
|
+
for (i = headerLines.length - 1; i >= 0; i--) {
|
|
192
|
+
if (i && headerLines[i].match(/^\s/)) {
|
|
193
|
+
headerLines[i - 1] += "\r\n" + headerLines.splice(i, 1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Process header lines
|
|
198
|
+
for (i = 0; i < headerLines.length; i++) {
|
|
199
|
+
line = DKIMCanonicalizer.relaxedHeaderLine(headerLines[i]);
|
|
200
|
+
var keyLower = line.key.toLowerCase();
|
|
201
|
+
|
|
202
|
+
if (includedFieldsLower.indexOf(keyLower) >= 0 && !(keyLower in headerFields)) {
|
|
203
|
+
headerFields[keyLower] = {
|
|
204
|
+
key: line.key,
|
|
205
|
+
value: line.value,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
var headers = [];
|
|
211
|
+
var usedFields = [];
|
|
212
|
+
for (i = includedFields.length - 1; i >= 0; i--) {
|
|
213
|
+
var keyLower = includedFields[i].toLowerCase();
|
|
214
|
+
if (!headerFields[keyLower]) {
|
|
215
|
+
includedFields.splice(i, 1);
|
|
216
|
+
} else {
|
|
217
|
+
headers.unshift(headerFields[keyLower].key.toLowerCase() + ":" + headerFields[keyLower].value);
|
|
218
|
+
usedFields.unshift(headerFields[keyLower].key);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
headers: headers.join("\r\n") + "\r\n",
|
|
224
|
+
fieldNames: usedFields.join(":"),
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Applies "relaxed" canonicalization to a single header line
|
|
230
|
+
* @param {String} line Header line
|
|
231
|
+
* @return {Object} Parsed header line with key and value
|
|
232
|
+
*/
|
|
233
|
+
relaxedHeaderLine: function (line) {
|
|
234
|
+
var parts = line.split(":"),
|
|
235
|
+
key = (parts.shift() || "").trim(),
|
|
236
|
+
value = parts.join(":").replace(/\s+/g, " ").trim();
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
key: key,
|
|
240
|
+
value: value,
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Generates a SHA-256 hash
|
|
247
|
+
*
|
|
248
|
+
* @param {String} str String to be hashed
|
|
249
|
+
* @param {String} [encoding='hex'] Output encoding
|
|
250
|
+
* @return {String} SHA-256 hash in the selected output encoding
|
|
251
|
+
*/
|
|
252
|
+
function sha256(str, encoding) {
|
|
253
|
+
var shasum = crypto.createHash("sha256");
|
|
254
|
+
shasum.update(str);
|
|
255
|
+
return shasum.digest(encoding || "hex");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Detects if a string includes Unicode symbols
|
|
260
|
+
*
|
|
261
|
+
* @param {String} str String to be checked
|
|
262
|
+
* @return {Boolean} true if the string contains non-ASCII symbols
|
|
263
|
+
*/
|
|
264
|
+
function hasUTFChars(str) {
|
|
265
|
+
var rforeign = /[^\u0000-\u007f]/;
|
|
266
|
+
return !!rforeign.test(str);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export { DKIMSign };
|
package/src/index.d.ts
ADDED
package/src/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/processMarkDown.d.ts
|
|
2
|
+
|
|
3
|
+
export interface MarkdownOptions {
|
|
4
|
+
/** The raw input string (HTML or Markdown) */
|
|
5
|
+
inputData: string;
|
|
6
|
+
/** Tag used to identify Markdown input. Default: "[m_d]" */
|
|
7
|
+
mdTag?: string;
|
|
8
|
+
/** Legacy tag for backward compatibility. Default: "[mark_down]" */
|
|
9
|
+
legacyMdTag?: string;
|
|
10
|
+
/** Convert URLs to visible text links? Default: false */
|
|
11
|
+
showLinks?: boolean;
|
|
12
|
+
/** Convert image tags to markdown image syntax? Default: false */
|
|
13
|
+
showImages?: boolean;
|
|
14
|
+
/** Max words for the reply/summary. Default: 100 */
|
|
15
|
+
maxWords?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MarkdownResult {
|
|
19
|
+
fullMarkdown: string;
|
|
20
|
+
fullHtml: string;
|
|
21
|
+
fullText: string;
|
|
22
|
+
replyMarkdown: string;
|
|
23
|
+
replyHtml: string;
|
|
24
|
+
replyText: string;
|
|
25
|
+
error: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Processes text/html/markdown input and returns structured data
|
|
30
|
+
* (Full HTML, Reply HTML, Text versions, etc.)
|
|
31
|
+
*/
|
|
32
|
+
export function processMarkDown(options: MarkdownOptions): Promise<MarkdownResult>;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import TurndownService from "turndown";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
import he from "he";
|
|
4
|
+
|
|
5
|
+
// ==========================================
|
|
6
|
+
// 1. GLOBAL CONSTANTS
|
|
7
|
+
// ==========================================
|
|
8
|
+
|
|
9
|
+
const CUTOFF_MARKERS = [
|
|
10
|
+
// --- Starts With ---
|
|
11
|
+
{ type: "startsWith", value: "* * *" },
|
|
12
|
+
{ type: "startsWith", value: ">" },
|
|
13
|
+
{ type: "startsWith", value: "On " },
|
|
14
|
+
{ type: "startsWith", value: "Sent from" },
|
|
15
|
+
{ type: "startsWith", value: "-----Original Message-----" },
|
|
16
|
+
{ type: "startsWith", value: "this reply" },
|
|
17
|
+
|
|
18
|
+
// --- Includes/Contains ---
|
|
19
|
+
{ type: "includes", value: "Yahoo Mail:" },
|
|
20
|
+
{ type: "includes", value: "Forwarded Message" },
|
|
21
|
+
{ type: "includes", value: "Original message" },
|
|
22
|
+
{ type: "includes", value: "> On " },
|
|
23
|
+
{ type: "includes", value: "Sent:" },
|
|
24
|
+
{ type: "includes", value: "From:" },
|
|
25
|
+
{ type: "includes", value: "To:" },
|
|
26
|
+
{ type: "includes", value: "Subject:" },
|
|
27
|
+
{ type: "includes", value: " wrote:" },
|
|
28
|
+
{ type: "includes", value: "Re:" },
|
|
29
|
+
|
|
30
|
+
// --- dbr / Platform Specific ---
|
|
31
|
+
{ type: "includes", value: "Someone sent you a message" },
|
|
32
|
+
{ type: "includes", value: "If you would like to block" },
|
|
33
|
+
{ type: "includes", value: "If this message is spam" },
|
|
34
|
+
{ type: "includes", value: "If the message contains" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// ==========================================
|
|
38
|
+
// 2. FACTORIES
|
|
39
|
+
// ==========================================
|
|
40
|
+
|
|
41
|
+
const createTurndownService = (showLinks, showImages) => {
|
|
42
|
+
const service = new TurndownService({
|
|
43
|
+
headingStyle: "atx",
|
|
44
|
+
bulletListMarker: "-",
|
|
45
|
+
codeBlockStyle: "fenced",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
service.remove(["script", "style", "iframe", "object"]);
|
|
49
|
+
|
|
50
|
+
service.addRule("keepExplicitBr", {
|
|
51
|
+
filter: "br",
|
|
52
|
+
replacement: () => "<br />",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
service.addRule("processImages", {
|
|
56
|
+
filter: "img",
|
|
57
|
+
replacement: (content, node) => {
|
|
58
|
+
const alt = node.getAttribute("alt") || "";
|
|
59
|
+
const src = node.getAttribute("src") || "";
|
|
60
|
+
if (showImages && src) {
|
|
61
|
+
return ``;
|
|
62
|
+
} else {
|
|
63
|
+
return ``;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
service.addRule("processLinks", {
|
|
69
|
+
filter: "a",
|
|
70
|
+
replacement: (content, node) => {
|
|
71
|
+
const href = node.getAttribute("href");
|
|
72
|
+
const text = content.trim();
|
|
73
|
+
|
|
74
|
+
if (showLinks && href) {
|
|
75
|
+
return `[${text}](${href})`;
|
|
76
|
+
} else {
|
|
77
|
+
return `[${text}](#)`;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return service;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Custom Renderer for HTML Output
|
|
87
|
+
* Forces paragraphs to be <div> instead of <p>
|
|
88
|
+
*/
|
|
89
|
+
const createHtmlRenderer = () => {
|
|
90
|
+
const renderer = new marked.Renderer();
|
|
91
|
+
|
|
92
|
+
// OVERRIDE: Use <div> instead of <p>
|
|
93
|
+
renderer.paragraph = ({ text }) => {
|
|
94
|
+
return `<div>${text}</div>\n`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return renderer;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const createTextRenderer = (showLinks, showImages) => {
|
|
101
|
+
const renderer = new marked.Renderer();
|
|
102
|
+
|
|
103
|
+
renderer.paragraph = ({ text }) => text + "\n\n";
|
|
104
|
+
renderer.heading = ({ text }) => text + "\n\n";
|
|
105
|
+
renderer.list = ({ body }) => body + "\n";
|
|
106
|
+
renderer.listitem = ({ text }) => "- " + text + "\n";
|
|
107
|
+
renderer.code = ({ text }) => text + "\n\n";
|
|
108
|
+
renderer.blockquote = ({ text }) => "> " + text + "\n";
|
|
109
|
+
|
|
110
|
+
renderer.strong = ({ text }) => text;
|
|
111
|
+
renderer.em = ({ text }) => text;
|
|
112
|
+
renderer.codespan = ({ text }) => text;
|
|
113
|
+
renderer.del = ({ text }) => text;
|
|
114
|
+
|
|
115
|
+
renderer.link = ({ href, text }) => {
|
|
116
|
+
if (showLinks && href) return `${text} (${href})`;
|
|
117
|
+
return text;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
renderer.image = ({ href, text }) => {
|
|
121
|
+
if (showImages && href) return `[Image: ${text || "image"} - ${href}]`;
|
|
122
|
+
return text ? `[Image: ${text}]` : "[Image]";
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
renderer.html = ({ text }) => {
|
|
126
|
+
if (text && text.includes("<br")) return "\n";
|
|
127
|
+
return "";
|
|
128
|
+
};
|
|
129
|
+
renderer.br = () => "\n";
|
|
130
|
+
|
|
131
|
+
return renderer;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ==========================================
|
|
135
|
+
// 3. HELPER FUNCTIONS
|
|
136
|
+
// ==========================================
|
|
137
|
+
|
|
138
|
+
const isHtml = (text) => {
|
|
139
|
+
if (!text) return false;
|
|
140
|
+
if (/^\s*<!DOCTYPE/i.test(text) || /^\s*<html/i.test(text)) return true;
|
|
141
|
+
return /<(\/?(br|div|p|span|a|b|i|strong|ul|ol|li|blockquote|table|img)|!DOCTYPE|html|body)[^>]*>/i.test(text);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const getLatestReplyMarkdown = (fullMarkdown) => {
|
|
145
|
+
const lines = fullMarkdown.split("\n");
|
|
146
|
+
let cutoffIndex = lines.length;
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < lines.length; i++) {
|
|
149
|
+
const line = lines[i].toLowerCase().trim();
|
|
150
|
+
if (!line) continue;
|
|
151
|
+
|
|
152
|
+
const isMatch = CUTOFF_MARKERS.some((marker) => {
|
|
153
|
+
if (marker.type === "startsWith") return line.startsWith(marker.value.toLowerCase());
|
|
154
|
+
if (marker.type === "includes") return line.includes(marker.value.toLowerCase());
|
|
155
|
+
return false;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (isMatch) {
|
|
159
|
+
cutoffIndex = i;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return lines.slice(0, cutoffIndex).join("\n").trim();
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const cleanTextOutput = (text) => {
|
|
167
|
+
if (!text) return "";
|
|
168
|
+
let cleaned = text.replace(/<br\s*\/?>/gi, "\n");
|
|
169
|
+
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, "");
|
|
170
|
+
|
|
171
|
+
return he.decode(cleaned).trim();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const decodeSafeHtml = (html) => {
|
|
175
|
+
if (!html) return "";
|
|
176
|
+
|
|
177
|
+
return html.replace(/&[^;]+;/g, (match) => {
|
|
178
|
+
if (match === "<" || match === ">") {
|
|
179
|
+
return match;
|
|
180
|
+
}
|
|
181
|
+
return he.decode(match);
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const truncateWords = (text, maxWords) => {
|
|
186
|
+
if (!text || !maxWords || maxWords <= 0) return text;
|
|
187
|
+
const words = text.trim().split(/\s+/);
|
|
188
|
+
if (words.length <= maxWords) return text;
|
|
189
|
+
return words.slice(0, maxWords).join(" ") + " ...";
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const getDefaultEmpty = () => ({
|
|
193
|
+
fullMarkdown: "",
|
|
194
|
+
fullHtml: "",
|
|
195
|
+
fullText: "",
|
|
196
|
+
replyMarkdown: "",
|
|
197
|
+
replyHtml: "",
|
|
198
|
+
replyText: "",
|
|
199
|
+
error: "Invalid Input",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ==========================================
|
|
203
|
+
// 4. MAIN EXPORT FUNCTION
|
|
204
|
+
// ==========================================
|
|
205
|
+
|
|
206
|
+
export async function processMarkDown({
|
|
207
|
+
inputData,
|
|
208
|
+
mdTag = "[m_d]",
|
|
209
|
+
legacyMdTag = "[mark_down]",
|
|
210
|
+
showLinks = false,
|
|
211
|
+
showImages = false,
|
|
212
|
+
maxWords = 100, // Default 0 = Unlimited
|
|
213
|
+
} = {}) {
|
|
214
|
+
if (!inputData || typeof inputData !== "string") return getDefaultEmpty();
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const turndownService = createTurndownService(showLinks, showImages);
|
|
218
|
+
const textRenderer = createTextRenderer(showLinks, showImages);
|
|
219
|
+
const htmlRenderer = createHtmlRenderer(); // <--- Initialize the new renderer
|
|
220
|
+
|
|
221
|
+
let masterMarkdown = "";
|
|
222
|
+
let intermediateHtml = "";
|
|
223
|
+
const cleanedInput = inputData.trim();
|
|
224
|
+
|
|
225
|
+
if (cleanedInput.startsWith(mdTag)) {
|
|
226
|
+
const rawMd = cleanedInput.replace(mdTag, "").trimStart();
|
|
227
|
+
intermediateHtml = marked.parse(rawMd);
|
|
228
|
+
} else if (cleanedInput.startsWith(legacyMdTag)) {
|
|
229
|
+
const rawMd = cleanedInput.replace(legacyMdTag, "").trimStart();
|
|
230
|
+
intermediateHtml = marked.parse(rawMd);
|
|
231
|
+
} else if (isHtml(cleanedInput)) {
|
|
232
|
+
intermediateHtml = cleanedInput;
|
|
233
|
+
} else {
|
|
234
|
+
intermediateHtml = marked.parse(cleanedInput);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
masterMarkdown = turndownService.turndown(intermediateHtml);
|
|
238
|
+
masterMarkdown = masterMarkdown.replace(/(<br\s*\/?>)\n+/gi, "$1\n");
|
|
239
|
+
|
|
240
|
+
let replyMarkdownRaw = getLatestReplyMarkdown(masterMarkdown);
|
|
241
|
+
|
|
242
|
+
if (maxWords > 0) {
|
|
243
|
+
replyMarkdownRaw = truncateWords(replyMarkdownRaw, maxWords);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Generate Versions ---
|
|
247
|
+
|
|
248
|
+
// 1. HTML: Use the custom htmlRenderer (uses <div> instead of <p>)
|
|
249
|
+
const rawFullHtml = marked.parse(masterMarkdown, { renderer: htmlRenderer, gfm: true, breaks: true });
|
|
250
|
+
|
|
251
|
+
// 2. TEXT: Use the custom textRenderer (strips tags)
|
|
252
|
+
const rawFullText = marked.parse(masterMarkdown, { renderer: textRenderer, breaks: true });
|
|
253
|
+
|
|
254
|
+
// 3. HTML Reply: Use custom htmlRenderer
|
|
255
|
+
const rawReplyHtml = marked.parse(replyMarkdownRaw, { renderer: htmlRenderer, gfm: true, breaks: true });
|
|
256
|
+
|
|
257
|
+
// 4. TEXT Reply: Use custom textRenderer
|
|
258
|
+
const rawReplyText = marked.parse(replyMarkdownRaw, { renderer: textRenderer, breaks: true });
|
|
259
|
+
|
|
260
|
+
// --- Clean & Decode ---
|
|
261
|
+
|
|
262
|
+
const fullHtml = decodeSafeHtml(rawFullHtml.trim());
|
|
263
|
+
const replyHtml = decodeSafeHtml(rawReplyHtml.trim());
|
|
264
|
+
|
|
265
|
+
const fullText = cleanTextOutput(rawFullText);
|
|
266
|
+
const replyText = cleanTextOutput(rawReplyText);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
fullMarkdown: `${mdTag}\n${masterMarkdown}`,
|
|
270
|
+
fullHtml: fullHtml,
|
|
271
|
+
fullText: fullText,
|
|
272
|
+
|
|
273
|
+
replyMarkdown: `${mdTag}\n${replyMarkdownRaw}`,
|
|
274
|
+
replyHtml: replyHtml,
|
|
275
|
+
replyText: replyText,
|
|
276
|
+
|
|
277
|
+
error: null,
|
|
278
|
+
};
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error("Error processing content:", err);
|
|
281
|
+
return { ...getDefaultEmpty(), error: err.message };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/rawmimeBuilder.d.ts
|
|
2
|
+
|
|
3
|
+
import type { DKIMOptions } from "./dkim-signer";
|
|
4
|
+
|
|
5
|
+
export interface Mailbox {
|
|
6
|
+
name?: string;
|
|
7
|
+
email: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type MailboxInput = string | Mailbox | (string | Mailbox)[];
|
|
11
|
+
|
|
12
|
+
export class MimeMessage {
|
|
13
|
+
constructor();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Removes specific headers from the message.
|
|
17
|
+
* @param headerNames Single header name or array of names.
|
|
18
|
+
*/
|
|
19
|
+
removeHeaders(headerNames: string | string[]): void;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Overwrites an existing header or adds it if it doesn't exist.
|
|
23
|
+
*/
|
|
24
|
+
updateHeader(name: string, value: string): void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Adds a header. Handles singleton headers (From, Date, etc) by overwriting.
|
|
28
|
+
*/
|
|
29
|
+
addCustomHeader(name: string, value: string): void;
|
|
30
|
+
|
|
31
|
+
setFrom(sender: MailboxInput): void;
|
|
32
|
+
setTo(recipients: MailboxInput): void;
|
|
33
|
+
setCc(recipients: MailboxInput): void;
|
|
34
|
+
setBcc(recipients: MailboxInput): void;
|
|
35
|
+
setReplyTo(replyTo: MailboxInput): void;
|
|
36
|
+
setSubject(subject: string): void;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sets the Date header using a specific Time Zone or a random US Time Zone.
|
|
40
|
+
* @param options Configuration object.
|
|
41
|
+
* @param options.timeZone The IANA time zone identifier (e.g., "America/New_York", "UTC"). Defaults to "UTC".
|
|
42
|
+
* @param options.randomUSA If true, selects a random major US time zone (ignoring the `timeZone` parameter). Defaults to false.
|
|
43
|
+
*/
|
|
44
|
+
setDate(options?: { timeZone?: string; randomUSA?: boolean }): void;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generates or sets the Message-ID.
|
|
48
|
+
*/
|
|
49
|
+
messageId(options?: { id?: string; domain?: string }): void;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Adds the plain text version of the email.
|
|
53
|
+
*/
|
|
54
|
+
addText(options: { data: string; encoding?: "quoted-printable" | "base64" }): void;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Adds the HTML version of the email.
|
|
58
|
+
*/
|
|
59
|
+
addHtml(options: {
|
|
60
|
+
data: string;
|
|
61
|
+
encoding?: "quoted-printable" | "base64";
|
|
62
|
+
autoTextPart?: boolean;
|
|
63
|
+
autoMinify?: boolean;
|
|
64
|
+
/** * If true, the `data` is treated as input for the markdown processor.
|
|
65
|
+
* It will be converted to HTML, sanitized, and used to generate the body.
|
|
66
|
+
*/
|
|
67
|
+
markdownToHtml?: boolean;
|
|
68
|
+
}): void;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Adds an attachment from a file path.
|
|
72
|
+
*/
|
|
73
|
+
addAttachment(options: { path: string; name?: string; cid?: string }): void;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generates the raw MIME string (headers + body).
|
|
77
|
+
* @returns Promise resolving to the full email string.
|
|
78
|
+
*/
|
|
79
|
+
asRaw(): Promise<string>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generates the Base64URL encoded string (suitable for Gmail API).
|
|
83
|
+
* @returns Promise resolving to the encoded string.
|
|
84
|
+
*/
|
|
85
|
+
asEncoded(): Promise<string>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Signs the email using DKIM.
|
|
89
|
+
*/
|
|
90
|
+
addDKIM(options: DKIMOptions | DKIMOptions[], dkimOptions?: { chain: boolean }): Promise<string>;
|
|
91
|
+
}
|
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import mime from "mime-types";
|
|
2
|
+
import { promises as fs } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
5
|
+
import { convert } from "html-to-text";
|
|
6
|
+
import { DKIMSign } from "./dkim-signer.js";
|
|
7
|
+
import { processMarkDown } from "./processMarkDown.js"; // <--- IMPORT ADDED
|
|
8
|
+
|
|
9
|
+
const desiredOrder = [
|
|
10
|
+
"List-Unsubscribe-Post",
|
|
11
|
+
"List-Unsubscribe",
|
|
12
|
+
"Feedback-ID",
|
|
13
|
+
"MIME-Version",
|
|
14
|
+
"References",
|
|
15
|
+
"In-Reply-To",
|
|
16
|
+
"Reply-To",
|
|
17
|
+
"From",
|
|
18
|
+
"Date",
|
|
19
|
+
"Message-ID",
|
|
20
|
+
"Subject",
|
|
21
|
+
"To",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
class MimeMessage {
|
|
25
|
+
constructor() {
|
|
26
|
+
this.headers = [];
|
|
27
|
+
this.bodyParts = [];
|
|
28
|
+
this.attachments = [];
|
|
29
|
+
this.attachmentPromises = [];
|
|
30
|
+
this.processingPromises = []; // <--- NEW: Track async ops
|
|
31
|
+
this.removedHeaders = new Set();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── HELPER: HTML to Text Converter ─────────────────────────────
|
|
35
|
+
_htmlToText(html) {
|
|
36
|
+
if (!html) return "";
|
|
37
|
+
return convert(html, {
|
|
38
|
+
wordwrap: 130,
|
|
39
|
+
selectors: [
|
|
40
|
+
{ selector: "img", format: "skip" },
|
|
41
|
+
{ selector: "a", options: { hideLinkHrefIfSameAsText: true } },
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── HELPER: Safe HTML Minifier (FIXED) ─────────────────────────
|
|
47
|
+
_minifyHtml(html) {
|
|
48
|
+
if (!html) return "";
|
|
49
|
+
let minified = html;
|
|
50
|
+
|
|
51
|
+
// 1. Collapse all whitespace (newlines, tabs, spaces) into a single space
|
|
52
|
+
minified = minified.replace(/\s+/g, " ");
|
|
53
|
+
|
|
54
|
+
// 2. Remove space between tags (e.g. "</div> <div" -> "</div><div")
|
|
55
|
+
minified = minified.replace(/>\s+</g, "><");
|
|
56
|
+
|
|
57
|
+
return minified.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── HELPER: Smart Quoted-Printable Encoder (Word-Wrap) ─────────
|
|
61
|
+
_encodeQuotedPrintable(str) {
|
|
62
|
+
if (!str) return "";
|
|
63
|
+
|
|
64
|
+
// 1. Split into paragraphs first to preserve user's hard returns
|
|
65
|
+
const paragraphs = str.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
66
|
+
|
|
67
|
+
return paragraphs
|
|
68
|
+
.map((line) => {
|
|
69
|
+
const buffer = Buffer.from(line, "utf8");
|
|
70
|
+
|
|
71
|
+
// 2. Pre-encode characters
|
|
72
|
+
let tempStr = "";
|
|
73
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
74
|
+
const byte = buffer[i];
|
|
75
|
+
const isSafe = byte >= 33 && byte <= 126 && byte !== 61;
|
|
76
|
+
const isSpaceOrTab = byte === 32 || byte === 9;
|
|
77
|
+
|
|
78
|
+
if (isSafe) {
|
|
79
|
+
tempStr += String.fromCharCode(byte);
|
|
80
|
+
} else if (isSpaceOrTab) {
|
|
81
|
+
tempStr += String.fromCharCode(byte);
|
|
82
|
+
} else {
|
|
83
|
+
tempStr += "=" + byte.toString(16).toUpperCase().padStart(2, "0");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 3. Apply Soft-Wrapping (76 chars max)
|
|
88
|
+
let result = "";
|
|
89
|
+
let currentLineLength = 0;
|
|
90
|
+
const tokens = tempStr.match(/([ \t]+)|([^ \t]+)/g) || [];
|
|
91
|
+
|
|
92
|
+
tokens.forEach((token) => {
|
|
93
|
+
if (currentLineLength + token.length > 75) {
|
|
94
|
+
if (currentLineLength > 0) {
|
|
95
|
+
result += "=\r\n";
|
|
96
|
+
currentLineLength = 0;
|
|
97
|
+
}
|
|
98
|
+
if (token.length > 75) {
|
|
99
|
+
// Hard split for giant tokens
|
|
100
|
+
const chunks = token.match(/.{1,73}/g);
|
|
101
|
+
chunks.forEach((chunk, idx) => {
|
|
102
|
+
if (idx < chunks.length - 1) {
|
|
103
|
+
result += chunk + "=\r\n";
|
|
104
|
+
} else {
|
|
105
|
+
result += chunk;
|
|
106
|
+
currentLineLength = chunk.length;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
result += token;
|
|
111
|
+
currentLineLength = token.length;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
result += token;
|
|
115
|
+
currentLineLength += token.length;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return result;
|
|
120
|
+
})
|
|
121
|
+
.join("\r\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── HELPER: Header Folding (RFC 5322) ──────────────────────────
|
|
125
|
+
_foldHeader(name, value) {
|
|
126
|
+
const line = `${name}: ${value}`;
|
|
127
|
+
if (line.length <= 76) return line;
|
|
128
|
+
|
|
129
|
+
const headerKey = `${name}: `;
|
|
130
|
+
let remaining = value;
|
|
131
|
+
let result = headerKey;
|
|
132
|
+
let available = 76 - headerKey.length;
|
|
133
|
+
|
|
134
|
+
if (remaining.length < available) return result + remaining;
|
|
135
|
+
|
|
136
|
+
result += remaining.substring(0, available) + "\r\n ";
|
|
137
|
+
remaining = remaining.substring(available);
|
|
138
|
+
|
|
139
|
+
while (remaining.length > 75) {
|
|
140
|
+
result += remaining.substring(0, 75) + "\r\n ";
|
|
141
|
+
remaining = remaining.substring(75);
|
|
142
|
+
}
|
|
143
|
+
result += remaining;
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
removeHeaders(headerNames) {
|
|
148
|
+
if (!Array.isArray(headerNames)) headerNames = [headerNames];
|
|
149
|
+
headerNames.forEach((name) => this.removedHeaders.add(name.toLowerCase()));
|
|
150
|
+
this.headers = this.headers.filter((h) => !this.removedHeaders.has(h.name.toLowerCase()));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
updateHeader(name, value) {
|
|
154
|
+
this.headers = this.headers.filter((h) => h.name.toLowerCase() !== name.toLowerCase());
|
|
155
|
+
this.headers.push({ name, value });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
addCustomHeader(name, value) {
|
|
159
|
+
const singletonHeaders = [
|
|
160
|
+
"date",
|
|
161
|
+
"from",
|
|
162
|
+
"sender",
|
|
163
|
+
"reply-to",
|
|
164
|
+
"to",
|
|
165
|
+
"cc",
|
|
166
|
+
"bcc",
|
|
167
|
+
"message-id",
|
|
168
|
+
"in-reply-to",
|
|
169
|
+
"references",
|
|
170
|
+
"subject",
|
|
171
|
+
"mime-version",
|
|
172
|
+
];
|
|
173
|
+
if (singletonHeaders.includes(name.toLowerCase())) {
|
|
174
|
+
const exists = this.headers.some((h) => h.name.toLowerCase() === name.toLowerCase());
|
|
175
|
+
if (exists) {
|
|
176
|
+
console.warn(`[MimeMessage] Warning: Overwriting singleton header "${name}".`);
|
|
177
|
+
}
|
|
178
|
+
this.updateHeader(name, value);
|
|
179
|
+
} else {
|
|
180
|
+
this.headers.push({ name, value });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
formatMailboxes(mailboxes) {
|
|
185
|
+
if (!mailboxes) return "";
|
|
186
|
+
const mbs = Array.isArray(mailboxes) ? mailboxes : [mailboxes];
|
|
187
|
+
return mbs.map((mb) => (mb.name ? `"${mb.name}" <${mb.email}>` : mb.email)).join(", ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
setFrom(sender) {
|
|
191
|
+
this.updateHeader("From", this.formatMailboxes(sender));
|
|
192
|
+
}
|
|
193
|
+
setTo(recipients) {
|
|
194
|
+
this.updateHeader("To", this.formatMailboxes(recipients));
|
|
195
|
+
}
|
|
196
|
+
setCc(recipients) {
|
|
197
|
+
this.updateHeader("Cc", this.formatMailboxes(recipients));
|
|
198
|
+
}
|
|
199
|
+
setBcc(recipients) {
|
|
200
|
+
this.updateHeader("Bcc", this.formatMailboxes(recipients));
|
|
201
|
+
}
|
|
202
|
+
setSubject(subject) {
|
|
203
|
+
this.updateHeader("Subject", subject);
|
|
204
|
+
}
|
|
205
|
+
setReplyTo(replyTo) {
|
|
206
|
+
this.updateHeader("Reply-To", this.formatMailboxes(replyTo));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
setDate({ timeZone = "UTC", randomUSA = false } = {}) {
|
|
210
|
+
let selectedZone = timeZone;
|
|
211
|
+
|
|
212
|
+
if (randomUSA) {
|
|
213
|
+
const usaZones = [
|
|
214
|
+
"America/New_York", // Eastern
|
|
215
|
+
"America/Chicago", // Central
|
|
216
|
+
"America/Denver", // Mountain
|
|
217
|
+
"America/Phoenix", // Mountain (No DST)
|
|
218
|
+
"America/Los_Angeles", // Pacific
|
|
219
|
+
"America/Anchorage", // Alaska
|
|
220
|
+
"Pacific/Honolulu", // Hawaii
|
|
221
|
+
];
|
|
222
|
+
selectedZone = usaZones[Math.floor(Math.random() * usaZones.length)];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const date = new Date();
|
|
226
|
+
|
|
227
|
+
// 1. Use Intl to format the date for the specific Time Zone
|
|
228
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
229
|
+
timeZone: selectedZone,
|
|
230
|
+
weekday: "short",
|
|
231
|
+
day: "numeric",
|
|
232
|
+
month: "short",
|
|
233
|
+
year: "numeric",
|
|
234
|
+
hour: "2-digit",
|
|
235
|
+
minute: "2-digit",
|
|
236
|
+
second: "2-digit",
|
|
237
|
+
hour12: false,
|
|
238
|
+
timeZoneName: "longOffset", // e.g., "GMT-05:00" or just "GMT"
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// 2. Extract the parts
|
|
242
|
+
const parts = formatter.formatToParts(date);
|
|
243
|
+
const getPart = (type) => parts.find((p) => p.type === type)?.value;
|
|
244
|
+
|
|
245
|
+
const weekday = getPart("weekday");
|
|
246
|
+
const day = getPart("day");
|
|
247
|
+
const month = getPart("month");
|
|
248
|
+
const year = getPart("year");
|
|
249
|
+
|
|
250
|
+
// Handle midnight "24" edge case
|
|
251
|
+
let hour = getPart("hour");
|
|
252
|
+
if (hour === "24") hour = "00";
|
|
253
|
+
|
|
254
|
+
const minute = getPart("minute");
|
|
255
|
+
const second = getPart("second");
|
|
256
|
+
|
|
257
|
+
// 3. Fix the Timezone format
|
|
258
|
+
const tzRaw = getPart("timeZoneName") || "";
|
|
259
|
+
|
|
260
|
+
// Remove "GMT" and the colon (":")
|
|
261
|
+
// Example: "GMT-05:00" -> "-0500"
|
|
262
|
+
// Example: "GMT" -> ""
|
|
263
|
+
let tzClean = tzRaw.replace("GMT", "").replace(":", "").trim();
|
|
264
|
+
|
|
265
|
+
// If the result is empty (common for UTC), force +0000
|
|
266
|
+
if (!tzClean) {
|
|
267
|
+
tzClean = "+0000";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 4. Construct valid RFC 5322 Date Header
|
|
271
|
+
const dateStr = `${weekday}, ${day} ${month} ${year} ${hour}:${minute}:${second} ${tzClean}`;
|
|
272
|
+
|
|
273
|
+
this.updateHeader("Date", dateStr);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
messageId(options = {}) {
|
|
277
|
+
const { id, domain } = options;
|
|
278
|
+
let localPart = id ? id : "CA" + this._generateRandomString(49);
|
|
279
|
+
let msgDomain = domain;
|
|
280
|
+
if (!msgDomain) {
|
|
281
|
+
const fromHeader = this.headers.find((h) => h.name.toLowerCase() === "from");
|
|
282
|
+
if (fromHeader) {
|
|
283
|
+
const match = fromHeader.value.match(/<([^>]+)>/);
|
|
284
|
+
let emailAddress = match ? match[1] : fromHeader.value;
|
|
285
|
+
const atIndex = emailAddress.lastIndexOf("@");
|
|
286
|
+
msgDomain = atIndex !== -1 ? emailAddress.slice(atIndex + 1) : "mail.gmail.com";
|
|
287
|
+
} else {
|
|
288
|
+
msgDomain = "mail.gmail.com";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const fullMessageId = `<${localPart.trim()}@${msgDomain.trim()}>`;
|
|
292
|
+
this.updateHeader("Message-ID", fullMessageId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_generateRandomString(length) {
|
|
296
|
+
const allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789=";
|
|
297
|
+
const bytes = randomBytes(length);
|
|
298
|
+
let result = "";
|
|
299
|
+
for (let i = 0; i < length; i++) result += allowed[bytes[i] % allowed.length];
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
_validateEncoding(encoding) {
|
|
304
|
+
const allowed = ["quoted-printable", "base64"];
|
|
305
|
+
if (!allowed.includes(encoding)) {
|
|
306
|
+
throw new Error(`Invalid encoding: ${encoding}. Allowed options: ${allowed.join(", ")}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
_addBodyPart(data, contentType, encoding) {
|
|
311
|
+
this._validateEncoding(encoding);
|
|
312
|
+
if (typeof data !== "string") data = "";
|
|
313
|
+
|
|
314
|
+
let encodedData;
|
|
315
|
+
if (encoding === "quoted-printable") {
|
|
316
|
+
encodedData = this._encodeQuotedPrintable(data);
|
|
317
|
+
} else {
|
|
318
|
+
encodedData =
|
|
319
|
+
Buffer.from(data, "utf8")
|
|
320
|
+
.toString("base64")
|
|
321
|
+
.match(/.{1,76}/g)
|
|
322
|
+
?.join("\r\n") || "";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.bodyParts.push({
|
|
326
|
+
data: encodedData,
|
|
327
|
+
contentType,
|
|
328
|
+
charset: '"UTF-8"', // Added quotes for compliance
|
|
329
|
+
encoding,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Default encoding is now 'quoted-printable'
|
|
334
|
+
addText({ data, encoding = "quoted-printable" }) {
|
|
335
|
+
this._addBodyPart(data, "text/plain", encoding);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @param {Object} opts
|
|
340
|
+
* @param {string} opts.data - The HTML string
|
|
341
|
+
* @param {string} opts.encoding - Default "quoted-printable"
|
|
342
|
+
* @param {boolean} opts.autoTextPart - Auto-generates text/plain
|
|
343
|
+
* @param {boolean} opts.autoMinify - Minifies HTML before encoding
|
|
344
|
+
* @param {boolean} opts.markdownToHtml - Process markdown before adding
|
|
345
|
+
*/
|
|
346
|
+
addHtml({
|
|
347
|
+
data,
|
|
348
|
+
encoding = "quoted-printable",
|
|
349
|
+
autoTextPart = false,
|
|
350
|
+
autoMinify = true,
|
|
351
|
+
markdownToHtml = false, // <--- NEW OPTION
|
|
352
|
+
}) {
|
|
353
|
+
if (markdownToHtml) {
|
|
354
|
+
// Push the async operation to the queue
|
|
355
|
+
const promise = processMarkDown({
|
|
356
|
+
inputData: data,
|
|
357
|
+
showLinks: true,
|
|
358
|
+
showImages: true,
|
|
359
|
+
})
|
|
360
|
+
.then((body_data) => {
|
|
361
|
+
// Call addHtml again, but with processed HTML and flag set to false
|
|
362
|
+
this.addHtml({
|
|
363
|
+
data: body_data.fullHtml,
|
|
364
|
+
encoding,
|
|
365
|
+
autoTextPart,
|
|
366
|
+
autoMinify,
|
|
367
|
+
markdownToHtml: false,
|
|
368
|
+
});
|
|
369
|
+
})
|
|
370
|
+
.catch((err) => {
|
|
371
|
+
console.error("[MimeMessage] Markdown processing failed:", err);
|
|
372
|
+
// Fallback to original data if processing fails
|
|
373
|
+
this.addHtml({
|
|
374
|
+
data: data,
|
|
375
|
+
encoding,
|
|
376
|
+
autoTextPart,
|
|
377
|
+
autoMinify,
|
|
378
|
+
markdownToHtml: false,
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
this.processingPromises.push(promise);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let finalHtml = data;
|
|
387
|
+
|
|
388
|
+
if (autoMinify) {
|
|
389
|
+
finalHtml = this._minifyHtml(finalHtml);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this._addBodyPart(finalHtml, "text/html", encoding);
|
|
393
|
+
|
|
394
|
+
if (autoTextPart) {
|
|
395
|
+
// Use ORIGINAL data for text conversion to ensure readability
|
|
396
|
+
const plainText = this._htmlToText(data);
|
|
397
|
+
this.addText({ data: plainText, encoding: encoding });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_generateRandomNumericString(length) {
|
|
402
|
+
const bytes = randomBytes(length);
|
|
403
|
+
let result = "";
|
|
404
|
+
for (let i = 0; i < length; i++) result += (bytes[i] % 10).toString();
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
addAttachment({ path: filePath, name: customName, cid: customCid }) {
|
|
409
|
+
const promise = fs
|
|
410
|
+
.readFile(filePath)
|
|
411
|
+
.then((fileData) => {
|
|
412
|
+
const originalExt = path.extname(filePath);
|
|
413
|
+
let fileName;
|
|
414
|
+
if (customName) {
|
|
415
|
+
const customExt = path.extname(customName);
|
|
416
|
+
fileName = customExt ? customName : customName + originalExt;
|
|
417
|
+
} else {
|
|
418
|
+
fileName = this._generateRandomNumericString(12) + originalExt;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const contentType = mime.lookup(filePath) || "application/octet-stream";
|
|
422
|
+
const encodedData = fileData
|
|
423
|
+
.toString("base64")
|
|
424
|
+
.match(/.{1,76}/g)
|
|
425
|
+
.join("\r\n");
|
|
426
|
+
const cleanCid = customCid ? customCid.replace(/[<>]/g, "") : `f_${randomBytes(4).toString("hex")}`;
|
|
427
|
+
|
|
428
|
+
this.attachments.push({
|
|
429
|
+
data: encodedData,
|
|
430
|
+
contentType,
|
|
431
|
+
encoding: "base64",
|
|
432
|
+
fileName,
|
|
433
|
+
headers: {
|
|
434
|
+
"Content-Disposition": customCid ? `inline; filename="${fileName}"` : `attachment; filename="${fileName}"`,
|
|
435
|
+
"X-Attachment-Id": cleanCid,
|
|
436
|
+
"Content-ID": `<${cleanCid}>`,
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
})
|
|
440
|
+
.catch((error) => {
|
|
441
|
+
console.error("Attachment Error:", error);
|
|
442
|
+
throw error;
|
|
443
|
+
});
|
|
444
|
+
this.attachmentPromises.push(promise);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
generateBoundary(oldBoundary) {
|
|
448
|
+
if (oldBoundary) {
|
|
449
|
+
const prefix = oldBoundary.slice(0, 17);
|
|
450
|
+
let newPart;
|
|
451
|
+
do {
|
|
452
|
+
newPart = randomBytes(6).toString("hex").slice(0, 11);
|
|
453
|
+
} while (prefix + newPart === oldBoundary);
|
|
454
|
+
return prefix + newPart;
|
|
455
|
+
}
|
|
456
|
+
return "000000000000" + randomBytes(8).toString("hex");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ─── BUILDING THE RAW EMAIL ───────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
async asRaw() {
|
|
462
|
+
// Wait for Attachments AND Markdown Processing
|
|
463
|
+
await Promise.all([...this.attachmentPromises, ...this.processingPromises]);
|
|
464
|
+
|
|
465
|
+
if (!this.headers.some((h) => h.name.toLowerCase() === "mime-version"))
|
|
466
|
+
this.headers.unshift({ name: "MIME-Version", value: "1.0" });
|
|
467
|
+
if (!this.headers.some((h) => h.name.toLowerCase() === "date")) this.setDate();
|
|
468
|
+
if (!this.headers.some((h) => h.name.toLowerCase() === "message-id")) this.messageId();
|
|
469
|
+
if (!this.headers.some((h) => h.name.toLowerCase() === "subject")) this.setSubject("");
|
|
470
|
+
|
|
471
|
+
// Sort Body: Text MUST be first
|
|
472
|
+
this.bodyParts.sort((a, b) => {
|
|
473
|
+
if (a.contentType === "text/plain") return -1;
|
|
474
|
+
if (b.contentType === "text/plain") return 1;
|
|
475
|
+
return 0;
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
let body = "";
|
|
479
|
+
const numAttachments = this.attachments.length;
|
|
480
|
+
const numBodyParts = this.bodyParts.length;
|
|
481
|
+
|
|
482
|
+
// NOTE: Double \r\n\r\n added after content parts for visual separation
|
|
483
|
+
|
|
484
|
+
// CASE 1: No Attachments (Multipart/Alternative)
|
|
485
|
+
if (numAttachments === 0) {
|
|
486
|
+
if (numBodyParts > 1) {
|
|
487
|
+
const boundary = this.generateBoundary();
|
|
488
|
+
this.updateHeader("Content-Type", `multipart/alternative; boundary="${boundary}"`);
|
|
489
|
+
body += "\r\n\r\n";
|
|
490
|
+
for (let part of this.bodyParts) {
|
|
491
|
+
body += `--${boundary}\r\n`;
|
|
492
|
+
body += `Content-Type: ${part.contentType}; charset=${part.charset}\r\n`;
|
|
493
|
+
body += `Content-Transfer-Encoding: ${part.encoding}\r\n\r\n`;
|
|
494
|
+
body += part.data + "\r\n\r\n"; // Visual Gap
|
|
495
|
+
}
|
|
496
|
+
body += `--${boundary}--`;
|
|
497
|
+
} else if (numBodyParts === 1) {
|
|
498
|
+
const part = this.bodyParts[0];
|
|
499
|
+
this.updateHeader("Content-Type", `${part.contentType}; charset=${part.charset}`);
|
|
500
|
+
body += `\r\nContent-Transfer-Encoding: ${part.encoding}\r\n\r\n${part.data}`;
|
|
501
|
+
} else {
|
|
502
|
+
// throw new Error("No body content found.");
|
|
503
|
+
// Removed throw to prevent crashing on empty bodies (optional)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// CASE 2: With Attachments (Multipart/Mixed)
|
|
507
|
+
else {
|
|
508
|
+
const mixedBoundary = this.generateBoundary();
|
|
509
|
+
this.updateHeader("Content-Type", `multipart/mixed; boundary="${mixedBoundary}"`);
|
|
510
|
+
body += "\r\n\r\n";
|
|
511
|
+
|
|
512
|
+
// A. Inner Body (Alternative)
|
|
513
|
+
if (numBodyParts > 0) {
|
|
514
|
+
if (numBodyParts > 1) {
|
|
515
|
+
const altBoundary = this.generateBoundary(mixedBoundary);
|
|
516
|
+
body += `--${mixedBoundary}\r\n`;
|
|
517
|
+
body += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n\r\n`;
|
|
518
|
+
for (let part of this.bodyParts) {
|
|
519
|
+
body += `--${altBoundary}\r\n`;
|
|
520
|
+
body += `Content-Type: ${part.contentType}; charset=${part.charset}\r\n`;
|
|
521
|
+
body += `Content-Transfer-Encoding: ${part.encoding}\r\n\r\n`;
|
|
522
|
+
body += part.data + "\r\n\r\n"; // Visual Gap
|
|
523
|
+
}
|
|
524
|
+
body += `--${altBoundary}--\r\n\r\n`; // Gap after inner close
|
|
525
|
+
} else {
|
|
526
|
+
const part = this.bodyParts[0];
|
|
527
|
+
body += `--${mixedBoundary}\r\n`;
|
|
528
|
+
body += `Content-Type: ${part.contentType}; charset=${part.charset}\r\n`;
|
|
529
|
+
body += `Content-Transfer-Encoding: ${part.encoding}\r\n\r\n`;
|
|
530
|
+
body += part.data + "\r\n\r\n"; // Visual Gap
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// B. Attachments
|
|
535
|
+
for (let att of this.attachments) {
|
|
536
|
+
body += `--${mixedBoundary}\r\n`;
|
|
537
|
+
body += `Content-Type: ${att.contentType}; name="${att.fileName}"\r\n`;
|
|
538
|
+
body += `Content-Disposition: ${att.headers["Content-Disposition"]}\r\n`;
|
|
539
|
+
body += `Content-Transfer-Encoding: ${att.encoding}\r\n`;
|
|
540
|
+
body += `Content-ID: ${att.headers["Content-ID"]}\r\n`;
|
|
541
|
+
body += `X-Attachment-Id: ${att.headers["X-Attachment-Id"]}\r\n\r\n`;
|
|
542
|
+
body += att.data + "\r\n\r\n"; // Visual Gap
|
|
543
|
+
}
|
|
544
|
+
body += `--${mixedBoundary}--`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ─── HEADER SORTING & FOLDING ───
|
|
548
|
+
let contentTypeHeader = null;
|
|
549
|
+
const recognized = [];
|
|
550
|
+
const unrecognized = [];
|
|
551
|
+
|
|
552
|
+
this.headers.forEach((h) => {
|
|
553
|
+
if (this.removedHeaders.has(h.name.toLowerCase())) return;
|
|
554
|
+
if (h.name.toLowerCase() === "content-type") {
|
|
555
|
+
contentTypeHeader = h;
|
|
556
|
+
} else if (desiredOrder.some((key) => key.toLowerCase() === h.name.toLowerCase())) {
|
|
557
|
+
recognized.push(h);
|
|
558
|
+
} else {
|
|
559
|
+
unrecognized.push(h);
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
recognized.sort((a, b) => {
|
|
564
|
+
const idxA = desiredOrder.findIndex((k) => k.toLowerCase() === a.name.toLowerCase());
|
|
565
|
+
const idxB = desiredOrder.findIndex((k) => k.toLowerCase() === b.name.toLowerCase());
|
|
566
|
+
return idxA - idxB;
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const finalHeaders = [...unrecognized, ...recognized];
|
|
570
|
+
if (contentTypeHeader) finalHeaders.push(contentTypeHeader);
|
|
571
|
+
|
|
572
|
+
const headerStr = finalHeaders
|
|
573
|
+
.map((h) => {
|
|
574
|
+
const lowerName = h.name.toLowerCase();
|
|
575
|
+
|
|
576
|
+
// UPDATE THIS ARRAY BELOW
|
|
577
|
+
// Add "list-unsubscribe" and "list-unsubscribe-post" to this list
|
|
578
|
+
const doNotFold = [
|
|
579
|
+
"message-id",
|
|
580
|
+
"references",
|
|
581
|
+
"in-reply-to",
|
|
582
|
+
"content-id",
|
|
583
|
+
"list-unsubscribe", // <--- Added
|
|
584
|
+
"list-unsubscribe-post", // <--- Added
|
|
585
|
+
];
|
|
586
|
+
|
|
587
|
+
if (doNotFold.includes(lowerName)) {
|
|
588
|
+
return `${h.name}: ${h.value}`;
|
|
589
|
+
}
|
|
590
|
+
return this._foldHeader(h.name, h.value);
|
|
591
|
+
})
|
|
592
|
+
.join("\r\n");
|
|
593
|
+
|
|
594
|
+
return headerStr + body;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async asEncoded() {
|
|
598
|
+
const raw = await this.asRaw();
|
|
599
|
+
let encoded = Buffer.from(raw).toString("base64");
|
|
600
|
+
return encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async addDKIM(options, dkimOptions = { chain: true }) {
|
|
604
|
+
const rawEmail = await this.asRaw();
|
|
605
|
+
if (Array.isArray(options)) {
|
|
606
|
+
if (dkimOptions.chain) {
|
|
607
|
+
let emailToSign = rawEmail;
|
|
608
|
+
for (const opt of options) {
|
|
609
|
+
emailToSign = DKIMSign(emailToSign, opt) + "\r\n" + emailToSign;
|
|
610
|
+
}
|
|
611
|
+
return emailToSign;
|
|
612
|
+
} else {
|
|
613
|
+
return options.map((opt) => DKIMSign(rawEmail, opt) + "\r\n").join("") + rawEmail;
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
return DKIMSign(rawEmail, options) + "\r\n" + rawEmail;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 2. Add this line at the very bottom of the file
|
|
622
|
+
export { MimeMessage };
|