arn-rawmime 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arn-rawmime",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "A lightweight, dependency-free raw MIME email builder with DKIM support.",
5
5
  "author": "ARNDESK",
6
6
  "type": "module",
@@ -78,7 +78,12 @@ class MimeMessage {
78
78
  if (isSafe) {
79
79
  tempStr += String.fromCharCode(byte);
80
80
  } else if (isSpaceOrTab) {
81
- tempStr += String.fromCharCode(byte);
81
+ // RFC 2045 §6.7 Rule 3: trailing whitespace MUST be encoded
82
+ if (i === buffer.length - 1) {
83
+ tempStr += "=" + byte.toString(16).toUpperCase().padStart(2, "0");
84
+ } else {
85
+ tempStr += String.fromCharCode(byte);
86
+ }
82
87
  } else {
83
88
  tempStr += "=" + byte.toString(16).toUpperCase().padStart(2, "0");
84
89
  }
@@ -123,6 +128,39 @@ class MimeMessage {
123
128
  .join("\r\n");
124
129
  }
125
130
 
131
+ // ─── HELPER: RFC 2047 Encoded-Word Chunking ──────────────────────
132
+ _encodeRFC2047(str) {
133
+ const MAX_ENCODED_LENGTH = 75;
134
+ const prefix = "=?UTF-8?B?";
135
+ const suffix = "?=";
136
+ const maxB64Len = MAX_ENCODED_LENGTH - prefix.length - suffix.length; // 63
137
+ const maxBytes = Math.floor(maxB64Len * 3 / 4); // 47 bytes
138
+
139
+ let result = [];
140
+ let currentChunk = "";
141
+ let currentChunkBytes = 0;
142
+
143
+ for (const char of str) {
144
+ const charBytes = Buffer.byteLength(char, "utf8");
145
+ if (currentChunkBytes + charBytes > maxBytes) {
146
+ const b64 = Buffer.from(currentChunk, "utf8").toString("base64");
147
+ result.push(`${prefix}${b64}${suffix}`);
148
+ currentChunk = char;
149
+ currentChunkBytes = charBytes;
150
+ } else {
151
+ currentChunk += char;
152
+ currentChunkBytes += charBytes;
153
+ }
154
+ }
155
+ if (currentChunk.length > 0) {
156
+ const b64 = Buffer.from(currentChunk, "utf8").toString("base64");
157
+ result.push(`${prefix}${b64}${suffix}`);
158
+ }
159
+
160
+ // Separate encoded words by space. Standard ASCII folding will wrap them if needed.
161
+ return result.join(" ");
162
+ }
163
+
126
164
  // ─── HELPER: Header Folding (RFC 5322) ──────────────────────────
127
165
  _foldHeader(name, value) {
128
166
  const hasNonAscii = /[^\x00-\x7F]/.test(value);
@@ -130,45 +168,47 @@ class MimeMessage {
130
168
  // 1. Unstructured headers (Subject, etc) -> Full Encode
131
169
  const unstructured = ["subject", "x-report-abuse", "thread-topic"];
132
170
  if (hasNonAscii && unstructured.includes(name.toLowerCase())) {
133
- const encodedValue = Buffer.from(value, "utf8").toString("base64");
134
- return `${name}: =?UTF-8?B?${encodedValue}?=`;
171
+ value = this._encodeRFC2047(value);
135
172
  }
136
173
 
137
174
  // 2. Structured headers (From, To) -> Smart Replace
138
- // Finds quoted strings with special chars, e.g. "René", and encodes JUST that part.
139
- if (hasNonAscii) {
140
- const encodedStruct = value.replace(/"([^"]*)"/g, (match, content) => {
141
- if (/[^\x00-\x7F]/.test(content)) {
142
- const b64 = Buffer.from(content, "utf8").toString("base64");
143
- return `=?UTF-8?B?${b64}?=`;
175
+ // Encodes quoted non-ASCII strings AND unquoted non-ASCII words
176
+ if (hasNonAscii && !unstructured.includes(name.toLowerCase())) {
177
+ value = value.replace(/"([^"]*)"|([^\s<>]*[^\x00-\x7F][^\s<>]*)/g, (match, quoted, unquoted) => {
178
+ if (quoted !== undefined && /[^\x00-\x7F]/.test(quoted)) {
179
+ return this._encodeRFC2047(quoted);
180
+ }
181
+ if (unquoted !== undefined && /[^\x00-\x7F]/.test(unquoted)) {
182
+ return this._encodeRFC2047(unquoted);
144
183
  }
145
184
  return match;
146
185
  });
147
- // If we changed anything, return it. If not (e.g. unquoted special chars), fallback to old folding
148
- if (encodedStruct !== value) {
149
- return `${name}: ${encodedStruct}`;
150
- }
151
186
  }
152
187
 
153
188
  // 3. Standard folding for ASCII-only (or unhandled) headers
154
189
  const line = `${name}: ${value}`;
155
190
  if (line.length <= 76) return line;
156
191
 
157
- const headerKey = `${name}: `;
158
- let remaining = value;
159
- let result = headerKey;
160
- let available = 76 - headerKey.length;
161
-
162
- if (remaining.length < available) return result + remaining;
192
+ let result = `${name}: `;
193
+ const tokens = value.split(/(?=[ \t])/);
194
+ let currentLineLength = result.length;
195
+ const prefixLength = result.length;
163
196
 
164
- result += remaining.substring(0, available) + "\r\n ";
165
- remaining = remaining.substring(available);
197
+ tokens.forEach((token) => {
198
+ if (currentLineLength + token.length > 76 && currentLineLength > prefixLength) {
199
+ if (/^[ \t]/.test(token)) {
200
+ result += "\r\n" + token;
201
+ currentLineLength = token.length;
202
+ } else {
203
+ result += token;
204
+ currentLineLength += token.length;
205
+ }
206
+ } else {
207
+ result += token;
208
+ currentLineLength += token.length;
209
+ }
210
+ });
166
211
 
167
- while (remaining.length > 75) {
168
- result += remaining.substring(0, 75) + "\r\n ";
169
- remaining = remaining.substring(75);
170
- }
171
- result += remaining;
172
212
  return result;
173
213
  }
174
214
 
@@ -607,6 +647,7 @@ class MimeMessage {
607
647
  // UPDATE THIS ARRAY BELOW
608
648
  // Add "list-unsubscribe" and "list-unsubscribe-post" to this list
609
649
  const doNotFold = [
650
+ "subject",
610
651
  "message-id",
611
652
  "references",
612
653
  "in-reply-to",
@@ -616,6 +657,10 @@ class MimeMessage {
616
657
  ];
617
658
 
618
659
  if (doNotFold.includes(lowerName)) {
660
+ // Still encode non-ASCII, just skip line folding
661
+ if (/[^\x00-\x7F]/.test(h.value)) {
662
+ return `${h.name}: ${this._encodeRFC2047(h.value)}`;
663
+ }
619
664
  return `${h.name}: ${h.value}`;
620
665
  }
621
666
  return this._foldHeader(h.name, h.value);