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 ADDED
File without changes
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # 📧 arn-rawmime
2
+
3
+ A lightweight, robust, and fully typed library for generating raw MIME emails. Built for modern Node.js environments with built-in **DKIM signing**, **HTML minification**, and **Attachment handling**.
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
@@ -0,0 +1,6 @@
1
+ // src/index.d.ts
2
+ // Re-exports all types from individual module files
3
+
4
+ export * from "./dkim-signer";
5
+ export * from "./processMarkDown";
6
+ export * from "./rawmimeBuilder";
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import { DKIMSign } from "./dkim-signer.js";
2
+ import { processMarkDown } from "./processMarkDown.js";
3
+ import { MimeMessage } from "./rawmimeBuilder.js";
4
+
5
+ // 2. Add this line at the very bottom of the file
6
+ export { processMarkDown, MimeMessage, DKIMSign };
@@ -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 `![${alt}](${src})`;
62
+ } else {
63
+ return `![${alt}](#)`;
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 === "&lt;" || match === "&gt;") {
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 };