@getstrata/core 2.2.22 → 2.2.24

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.24
4
+
5
+ Fix Redis cache recovery after transport failures and command deadlines: retire failed connections, abort active fills, and lazily reconnect for later operations without replaying uncertain commands; preserve permanent explicit closure and shared invalidation fencing.
6
+
7
+ ## 2.2.23
8
+
9
+ Fix native SMTP hangs with bounded whole-send deadlines, cancellation, buffered multiline replies, complete writes and verified implicit TLS. Preserve known final DATA acceptance through cleanup failures. Correct DATA transparency, CRLF, UTF-8 MIME/header encoding and long-line framing; reject incomplete AUTH and unsupported SMTPUTF8 envelopes explicitly. Generate documented SMTP timeout and TLS defaults. Real socket/TLS/protocol regressions cover outages, cancellation, accepted sends and framing; SMTP delivery before final acknowledgement remains uncertain.
10
+
3
11
  ## 2.2.22
4
12
 
5
13
  Preserve caller auth, credential sources, request metadata and application async stores through cold/queued native transaction callbacks. Serialize Postgres privilege grants with full provisioning using atomic database-scoped batches, retaining delegated-admin permissions and rollback/retry safety. Add real constrained-pool JSON/HTML bearer/session-CSRF and concurrent grant/atomicity regressions; no automatic workers, auth bypass or production runtime provisioning.
@@ -9,14 +9,16 @@ type RedisCacheOptions = {
9
9
  metadataLimit?: number;
10
10
  };
11
11
  declare class RedisCacheStore implements CacheStore {
12
+ private readonly redisUrl;
12
13
  private readonly ttlMs;
13
14
  private readonly maxEntries;
14
- private readonly client;
15
+ private client;
15
16
  private readonly keys;
16
17
  private readonly options;
17
18
  private readonly fills;
18
19
  private closed;
19
20
  constructor(redisUrl: string, ttlMs: number, maxEntries: number, options?: RedisCacheOptions);
21
+ private createClient;
20
22
  close(): void;
21
23
  get<T>(key: string): Promise<T | undefined>;
22
24
  set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
@@ -1,8 +1,10 @@
1
+ import type { TLSOptions } from "bun";
1
2
  interface MailMessage {
2
3
  to: string;
3
4
  subject: string;
4
5
  body: string;
5
6
  html?: string;
7
+ signal?: AbortSignal;
6
8
  }
7
9
  interface MailDriver {
8
10
  send(message: MailMessage): Promise<void>;
@@ -14,6 +16,8 @@ interface SmtpConfig {
14
16
  password?: string;
15
17
  from: string;
16
18
  secure: boolean;
19
+ timeoutMs?: number;
20
+ tls?: Pick<TLSOptions, "ca" | "serverName">;
17
21
  }
18
22
  type SmtpTransport = (config: SmtpConfig, message: MailMessage) => Promise<void>;
19
23
  declare function resolveSmtpConfig(): SmtpConfig;
@@ -289,6 +289,7 @@ return redis.error_reply('Unknown cache operation')
289
289
  `;
290
290
 
291
291
  class RedisCacheStore {
292
+ redisUrl;
292
293
  ttlMs;
293
294
  maxEntries;
294
295
  client;
@@ -297,6 +298,7 @@ class RedisCacheStore {
297
298
  fills = new Set;
298
299
  closed = false;
299
300
  constructor(redisUrl, ttlMs, maxEntries, options = {}) {
301
+ this.redisUrl = redisUrl;
300
302
  this.ttlMs = ttlMs;
301
303
  this.maxEntries = maxEntries;
302
304
  if (!Number.isSafeInteger(ttlMs) || ttlMs < 0)
@@ -321,7 +323,10 @@ class RedisCacheStore {
321
323
  const namespace = namespacedRedisKey("cache:v3:");
322
324
  const slot = new Bun.CryptoHasher("sha256").update(namespace).digest("hex");
323
325
  this.keys = ["entries", "expiry", "order", "metadata", "metadata-expiry"].map((name) => `${namespace}{${slot}}:${name}`);
324
- this.client = new RedisClient(redisUrl, {
326
+ this.client = this.createClient();
327
+ }
328
+ createClient() {
329
+ return new RedisClient(this.redisUrl, {
325
330
  connectionTimeout: this.options.commandTimeoutMs,
326
331
  maxRetries: 1
327
332
  });
@@ -330,7 +335,8 @@ class RedisCacheStore {
330
335
  this.closed = true;
331
336
  for (const fill of this.fills)
332
337
  fill.abort(new Error("Redis cache store is closed."));
333
- this.client.close();
338
+ this.client?.close();
339
+ this.client = undefined;
334
340
  }
335
341
  async get(key) {
336
342
  const result = await this.execute("get", key);
@@ -409,6 +415,7 @@ class RedisCacheStore {
409
415
  Promise.resolve().then(() => loader(controller.signal)),
410
416
  aborted
411
417
  ]);
418
+ controller.signal.throwIfAborted();
412
419
  done = true;
413
420
  clearInterval(renew);
414
421
  await this.execute("publish", key, {
@@ -450,16 +457,28 @@ class RedisCacheStore {
450
457
  const ttlMs = extra.ttlMs ?? this.ttlMs;
451
458
  if (typeof ttlMs !== "number" || !Number.isSafeInteger(ttlMs) || ttlMs < 0)
452
459
  throw new RangeError("ttlMs must be a non-negative safe integer.");
460
+ if (!this.client)
461
+ this.client = this.createClient();
462
+ const client = this.client;
463
+ const retire = (reason) => {
464
+ if (this.client !== client)
465
+ return;
466
+ this.client = undefined;
467
+ for (const fill of this.fills)
468
+ fill.abort(reason);
469
+ client.close();
470
+ };
453
471
  let timer;
454
472
  const timeout = new Promise((_, reject) => {
455
473
  timer = setTimeout(() => {
456
- reject(new Error("Redis cache command deadline exceeded; recreate the store."));
457
- this.close();
474
+ const error = new Error("Redis cache command deadline exceeded.");
475
+ reject(error);
476
+ retire(error);
458
477
  }, this.options.commandTimeoutMs);
459
478
  });
460
479
  try {
461
480
  const raw = await Promise.race([
462
- this.client.send("EVAL", [
481
+ client.send("EVAL", [
463
482
  CACHE_SCRIPT,
464
483
  String(this.keys.length),
465
484
  ...this.keys,
@@ -477,7 +496,13 @@ class RedisCacheStore {
477
496
  ]),
478
497
  timeout
479
498
  ]);
499
+ if (this.closed || this.client !== client)
500
+ throw new Error("Redis cache connection was retired.");
480
501
  return JSON.parse(String(raw));
502
+ } catch (error) {
503
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string" && error.code.startsWith("ERR_REDIS_CONNECTION"))
504
+ retire(error);
505
+ throw error;
481
506
  } finally {
482
507
  clearTimeout(timer);
483
508
  }
@@ -107,8 +107,8 @@ function resolveSmtpConfig() {
107
107
  if (!from) {
108
108
  throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
109
109
  }
110
- const port = Number.parseInt(process.env.MAIL_PORT ?? "587", 10);
111
- if (!Number.isInteger(port) || port <= 0) {
110
+ const port = Number(process.env.MAIL_PORT ?? "587");
111
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
112
112
  throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
113
113
  }
114
114
  return {
@@ -116,6 +116,7 @@ function resolveSmtpConfig() {
116
116
  port,
117
117
  from,
118
118
  secure: (process.env.MAIL_SECURE ?? "false") === "true",
119
+ ...process.env.MAIL_TIMEOUT_MS !== undefined ? { timeoutMs: smtpTimeout(Number(process.env.MAIL_TIMEOUT_MS)) } : {},
119
120
  ...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
120
121
  ...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
121
122
  };
@@ -125,6 +126,12 @@ function assertSafeSmtpField(value, field) {
125
126
  if (/[\r\n]/u.test(value)) {
126
127
  throw new Error(`Mail ${field} must not contain CR or LF.`);
127
128
  }
129
+ for (const character of value) {
130
+ const code = character.charCodeAt(0);
131
+ if (code < 32 && code !== 9 || code === 127) {
132
+ throw new Error(`Mail ${field} must not contain control characters.`);
133
+ }
134
+ }
128
135
  return value;
129
136
  }
130
137
  function smtpEnvelopeAddress(value, field) {
@@ -145,148 +152,307 @@ function redactMailBody(body) {
145
152
  function encodeBase64(value) {
146
153
  return Buffer.from(value, "utf8").toString("base64");
147
154
  }
148
- function parseSmtpResponses(buffer) {
149
- const responses = [];
150
- let remainder = buffer;
151
- while (remainder.includes(`\r
152
- `)) {
153
- const index = remainder.indexOf(`\r
154
- `);
155
- const line = remainder.slice(0, index);
156
- remainder = remainder.slice(index + 2);
157
- if (line.length >= 4 && line[3] === "-") {
158
- continue;
159
- }
160
- responses.push(line);
155
+ function smtpTimeout(value = 1e4) {
156
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647) {
157
+ throw new RangeError("SMTP timeoutMs must be a positive integer within the timer range.");
161
158
  }
162
- return { responses, remainder };
159
+ return value;
163
160
  }
164
- async function waitForSmtpResponse(readResponse, expectedCodes) {
165
- const response = await readResponse();
166
- const code = response.slice(0, 3);
167
- if (!expectedCodes.includes(code)) {
168
- throw new Error(`Unexpected SMTP response: ${response}`);
161
+ async function defaultSmtpTransport(config, message) {
162
+ const timeoutMs = smtpTimeout(config.timeoutMs);
163
+ if (!config.host.trim() || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
164
+ throw new RangeError("SMTP requires a host and a port between 1 and 65535.");
169
165
  }
170
- return response;
171
- }
172
- async function openSmtpConnection(config) {
173
- let buffer = "";
166
+ if (Boolean(config.username) !== Boolean(config.password)) {
167
+ throw new Error("SMTP authentication requires both username and password.");
168
+ }
169
+ if (config.username)
170
+ assertSafeSmtpField(config.username, "username");
171
+ if (config.password)
172
+ assertSafeSmtpField(config.password, "password");
173
+ const fromHeader = assertSafeSmtpAddress(config.from, "from");
174
+ const toHeader = assertSafeSmtpAddress(message.to, "to");
175
+ assertSafeSmtpField(message.subject, "subject");
176
+ const fromEnvelope = smtpEnvelopeAddress(config.from, "from");
177
+ const toEnvelope = smtpEnvelopeAddress(message.to, "to");
178
+ if (/[^\x21-\x7e]/u.test(fromEnvelope + toEnvelope)) {
179
+ throw new Error("Default SMTP transport requires ASCII envelopes; SMTPUTF8 is not negotiated.");
180
+ }
181
+ for (const address of [fromEnvelope, toEnvelope]) {
182
+ const at = address.lastIndexOf("@");
183
+ if (Buffer.byteLength(address) > 254 || Buffer.byteLength(address.slice(0, at)) > 64) {
184
+ throw new RangeError("SMTP envelope address exceeds the path/local-part length limit.");
185
+ }
186
+ }
187
+ message.signal?.throwIfAborted();
188
+ let socket;
189
+ let finished = false;
190
+ let failed = false;
191
+ let failure;
192
+ let rejectFailure;
193
+ const failurePromise = new Promise((_, reject) => {
194
+ rejectFailure = reject;
195
+ });
196
+ failurePromise.catch(() => {});
197
+ const responses = [];
174
198
  const waiters = [];
175
- const readResponse = () => new Promise((resolve, reject) => {
176
- const parsed = parseSmtpResponses(buffer);
177
- if (parsed.responses.length > 0) {
178
- buffer = parsed.remainder;
179
- resolve(parsed.responses.shift());
199
+ let drain;
200
+ let drainVersion = 0;
201
+ let buffer = "";
202
+ let multilineCode;
203
+ let multilineLines = 0;
204
+ const decoder = new TextDecoder;
205
+ const fail = (reason) => {
206
+ if (finished || failed)
180
207
  return;
208
+ failed = true;
209
+ failure = reason;
210
+ rejectFailure(reason);
211
+ waiters.length = 0;
212
+ drain?.();
213
+ drain = undefined;
214
+ socket?.terminate();
215
+ };
216
+ const onAbort = () => fail(message.signal?.reason ?? new DOMException("Mail send aborted.", "AbortError"));
217
+ const timer = setTimeout(() => fail(new DOMException("SMTP send deadline exceeded.", "TimeoutError")), timeoutMs);
218
+ message.signal?.addEventListener("abort", onAbort, { once: true });
219
+ if (message.signal?.aborted)
220
+ onAbort();
221
+ const readResponse = async (expected) => {
222
+ const queued = responses.shift();
223
+ const response = queued ?? await Promise.race([
224
+ failed ? Promise.reject(failure) : new Promise((resolve) => waiters.push(resolve)),
225
+ failurePromise
226
+ ]);
227
+ const code = response.slice(0, 3);
228
+ if (!expected.includes(code))
229
+ throw new Error(`Unexpected SMTP response code: ${code}`);
230
+ };
231
+ const write = async (text) => {
232
+ const bytes = Buffer.from(text, "utf8");
233
+ let offset = 0;
234
+ while (offset < bytes.length) {
235
+ if (failed)
236
+ throw failure;
237
+ const version = drainVersion;
238
+ if (!socket)
239
+ throw new Error("SMTP socket is not connected.");
240
+ const written = socket.write(bytes.subarray(offset));
241
+ if (written < 0)
242
+ throw new Error("SMTP socket write failed.");
243
+ offset += written;
244
+ if (offset < bytes.length && version === drainVersion) {
245
+ await Promise.race([
246
+ new Promise((resolve) => {
247
+ drain = resolve;
248
+ }),
249
+ failurePromise
250
+ ]);
251
+ }
181
252
  }
182
- waiters.push({ resolve, reject });
183
- });
184
- const socket = await Bun.connect({
185
- hostname: config.host,
186
- port: config.port,
187
- socket: {
188
- open() {},
189
- data(_socket, chunk) {
190
- buffer += Buffer.from(chunk).toString("utf8");
191
- const parsed = parseSmtpResponses(buffer);
192
- buffer = parsed.remainder;
193
- while (parsed.responses.length > 0 && waiters.length > 0) {
194
- const response = parsed.responses.shift();
195
- waiters.shift()?.resolve(response);
253
+ };
254
+ try {
255
+ const connecting = Bun.connect({
256
+ hostname: config.host,
257
+ port: config.port,
258
+ ...config.secure ? {
259
+ tls: {
260
+ ...config.tls,
261
+ serverName: config.tls?.serverName ?? config.host,
262
+ rejectUnauthorized: true
196
263
  }
197
- },
198
- error(_socket, error) {
199
- const pending = waiters.splice(0);
200
- for (const waiter of pending) {
201
- waiter.reject(error instanceof Error ? error : new Error(String(error)));
264
+ } : {},
265
+ socket: {
266
+ open(value) {
267
+ socket = value;
268
+ if (failed || finished)
269
+ value.terminate();
270
+ },
271
+ data(_socket, chunk) {
272
+ if (failed || finished)
273
+ return;
274
+ buffer += decoder.decode(chunk, { stream: true });
275
+ if (Buffer.byteLength(buffer, "utf8") > 16384) {
276
+ fail(new Error("SMTP response exceeded the buffer limit."));
277
+ return;
278
+ }
279
+ while (buffer.includes(`\r
280
+ `)) {
281
+ const index = buffer.indexOf(`\r
282
+ `);
283
+ const line = buffer.slice(0, index);
284
+ buffer = buffer.slice(index + 2);
285
+ const match = /^([2-5]\d{2})([- ]|$)/.exec(line);
286
+ if (!match || multilineCode !== undefined && match[1] !== multilineCode) {
287
+ fail(new Error("Malformed SMTP reply."));
288
+ return;
289
+ }
290
+ if (++multilineLines > 128) {
291
+ fail(new Error("SMTP reply exceeded the line limit."));
292
+ return;
293
+ }
294
+ if (match[2] === "-") {
295
+ multilineCode = match[1];
296
+ continue;
297
+ }
298
+ multilineCode = undefined;
299
+ multilineLines = 0;
300
+ const waiter = waiters.shift();
301
+ if (waiter)
302
+ waiter(line);
303
+ else {
304
+ responses.push(line);
305
+ if (responses.length > 32) {
306
+ fail(new Error("Too many buffered SMTP replies."));
307
+ return;
308
+ }
309
+ }
310
+ }
311
+ },
312
+ drain() {
313
+ drainVersion++;
314
+ const waiting = drain;
315
+ drain = undefined;
316
+ waiting?.();
317
+ },
318
+ end() {
319
+ fail(new Error("SMTP peer closed before the send completed."));
320
+ },
321
+ close() {
322
+ fail(new Error("SMTP connection closed before the send completed."));
323
+ },
324
+ error() {
325
+ fail(new Error("SMTP socket failed."));
326
+ },
327
+ connectError() {
328
+ fail(new Error("SMTP connection failed."));
329
+ },
330
+ handshake(_socket, success) {
331
+ if (!success)
332
+ fail(new Error("SMTP TLS verification failed."));
202
333
  }
203
334
  }
204
- }
205
- });
206
- return { socket, readResponse };
207
- }
208
- async function defaultSmtpTransport(config, message) {
209
- if (config.username) {
210
- assertSafeSmtpField(config.username, "username");
211
- }
212
- if (config.password) {
213
- assertSafeSmtpField(config.password, "password");
214
- }
215
- const { socket, readResponse } = await openSmtpConnection(config);
216
- try {
217
- await waitForSmtpResponse(readResponse, ["220"]);
218
- await socket.write(`EHLO ${smtpEhloHost()}\r
335
+ });
336
+ connecting.then((value) => {
337
+ socket = value;
338
+ if (failed || finished)
339
+ value.terminate();
340
+ }, () => {});
341
+ socket = await Promise.race([connecting, failurePromise]);
342
+ await readResponse(["220"]);
343
+ await write(`EHLO ${smtpEhloHost()}\r
219
344
  `);
220
- await waitForSmtpResponse(readResponse, ["250"]);
345
+ await readResponse(["250"]);
221
346
  if (config.username && config.password) {
222
- await socket.write(`AUTH LOGIN\r
347
+ await write(`AUTH LOGIN\r
223
348
  `);
224
- await waitForSmtpResponse(readResponse, ["334"]);
225
- await socket.write(`${encodeBase64(config.username)}\r
349
+ await readResponse(["334"]);
350
+ await write(`${encodeBase64(config.username)}\r
226
351
  `);
227
- await waitForSmtpResponse(readResponse, ["334"]);
228
- await socket.write(`${encodeBase64(config.password)}\r
352
+ await readResponse(["334"]);
353
+ await write(`${encodeBase64(config.password)}\r
229
354
  `);
230
- await waitForSmtpResponse(readResponse, ["235"]);
355
+ await readResponse(["235"]);
231
356
  }
232
- const fromHeader = assertSafeSmtpAddress(config.from, "from");
233
- const toHeader = assertSafeSmtpAddress(message.to, "to");
234
- const from = smtpEnvelopeAddress(config.from, "from");
235
- const to = smtpEnvelopeAddress(message.to, "to");
236
- await socket.write(`MAIL FROM:<${from}>\r
357
+ await write(`MAIL FROM:<${fromEnvelope}>\r
237
358
  `);
238
- await waitForSmtpResponse(readResponse, ["250"]);
239
- await socket.write(`RCPT TO:<${to}>\r
359
+ await readResponse(["250"]);
360
+ await write(`RCPT TO:<${toEnvelope}>\r
240
361
  `);
241
- await waitForSmtpResponse(readResponse, ["250", "251"]);
242
- await socket.write(`DATA\r
362
+ await readResponse(["250", "251"]);
363
+ await write(`DATA\r
243
364
  `);
244
- await waitForSmtpResponse(readResponse, ["354"]);
245
- const payload = buildSmtpPayload(fromHeader, {
246
- ...message,
247
- to: toHeader,
248
- subject: assertSafeSmtpField(message.subject, "subject")
249
- });
250
- await socket.write(payload);
251
- await waitForSmtpResponse(readResponse, ["250"]);
252
- await socket.write(`QUIT\r
365
+ await readResponse(["354"]);
366
+ await write(buildSmtpPayload(fromHeader, { ...message, to: toHeader }));
367
+ await readResponse(["250"]);
368
+ finished = true;
369
+ try {
370
+ socket.write(`QUIT\r
253
371
  `);
254
- await waitForSmtpResponse(readResponse, ["221"]);
372
+ } catch {}
255
373
  } finally {
256
- socket.end();
257
- }
374
+ finished = true;
375
+ clearTimeout(timer);
376
+ message.signal?.removeEventListener("abort", onAbort);
377
+ waiters.length = 0;
378
+ responses.length = 0;
379
+ drain = undefined;
380
+ socket?.end();
381
+ }
382
+ }
383
+ function encodeSmtpHeader(value) {
384
+ if (/^[\x20-\x7e]{0,60}$/u.test(value))
385
+ return value;
386
+ const words = [];
387
+ let part = "";
388
+ for (const character of value) {
389
+ if (Buffer.byteLength(part + character, "utf8") > 45) {
390
+ words.push(`=?UTF-8?B?${encodeBase64(part)}?=`);
391
+ part = "";
392
+ }
393
+ part += character;
394
+ }
395
+ if (part)
396
+ words.push(`=?UTF-8?B?${encodeBase64(part)}?=`);
397
+ return words.join(`\r
398
+ `);
399
+ }
400
+ function smtpAddressHeader(value) {
401
+ if (!value.includes("<"))
402
+ return value;
403
+ const name = value.slice(0, value.lastIndexOf("<")).trim();
404
+ const address = smtpEnvelopeAddress(value, "address");
405
+ if (!name)
406
+ return address;
407
+ const encoded = encodeSmtpHeader(name);
408
+ const display = encoded.startsWith("=?UTF-8?B?") ? encoded : `"${encoded.replace(/[\\"]/gu, "\\$&")}"`;
409
+ return `${display} <${address}>`;
410
+ }
411
+ function smtpMimePart(type, value) {
412
+ const normalized = value.replace(/\r\n|\r|\n/gu, `\r
413
+ `);
414
+ const base64 = /[^\t\r\n\x20-\x7e]/u.test(normalized) || normalized.split(`\r
415
+ `).some((line) => line.length > 998);
416
+ return [
417
+ `Content-Type: ${type}; charset=utf-8`,
418
+ `Content-Transfer-Encoding: ${base64 ? "base64" : "7bit"}`,
419
+ "",
420
+ base64 ? (encodeBase64(normalized).match(/.{1,76}/gu) ?? []).join(`\r
421
+ `) : normalized
422
+ ];
258
423
  }
259
424
  function buildSmtpPayload(from, message) {
260
- const safeFrom = assertSafeSmtpAddress(from, "from");
261
- const safeTo = assertSafeSmtpAddress(message.to, "to");
262
- const safeSubject = assertSafeSmtpField(message.subject, "subject");
263
425
  const headers = [
264
- `From: ${safeFrom}`,
265
- `To: ${safeTo}`,
266
- `Subject: ${safeSubject}`,
426
+ `From: ${smtpAddressHeader(assertSafeSmtpAddress(from, "from"))}`,
427
+ `To: ${smtpAddressHeader(assertSafeSmtpAddress(message.to, "to"))}`,
428
+ `Subject: ${encodeSmtpHeader(assertSafeSmtpField(message.subject, "subject"))}`,
267
429
  "MIME-Version: 1.0"
268
430
  ];
431
+ let lines;
269
432
  if (message.html) {
270
- const boundary = `strata-${Date.now().toString(36)}`;
271
- headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
272
- const parts = [
273
- `--${boundary}`,
274
- "Content-Type: text/plain; charset=utf-8",
433
+ let boundary;
434
+ do {
435
+ boundary = `strata-${crypto.randomUUID()}`;
436
+ } while (message.body.includes(boundary) || message.html.includes(boundary));
437
+ lines = [
438
+ ...headers,
439
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
275
440
  "",
276
- message.body,
277
441
  `--${boundary}`,
278
- "Content-Type: text/html; charset=utf-8",
279
- "",
280
- message.html,
442
+ ...smtpMimePart("text/plain", message.body),
443
+ `--${boundary}`,
444
+ ...smtpMimePart("text/html", message.html),
281
445
  `--${boundary}--`,
282
446
  ""
283
447
  ];
284
- return [...headers, "", ...parts, ".", ""].join(`\r
285
- `);
286
- }
287
- headers.push("Content-Type: text/plain; charset=utf-8");
288
- return [...headers, "", message.body, ".", ""].join(`\r
289
- `);
448
+ } else
449
+ lines = [...headers, ...smtpMimePart("text/plain", message.body)];
450
+ const content = lines.join(`\r
451
+ `).replace(/(^|\r\n)\./gu, "$1..");
452
+ return `${content}${content.endsWith(`\r
453
+ `) ? "" : `\r
454
+ `}.\r
455
+ `;
290
456
  }
291
457
 
292
458
  class LogMailDriver {