@zerotal/notifications 1.15.1 → 1.15.3

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
@@ -8,6 +8,23 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.15.2] — 2026-10-23
12
+
13
+ ### Fixed
14
+
15
+ - **`SmtpDriver` transfer-encodes text and HTML parts.** They went on the wire raw,
16
+ with no `Content-Transfer-Encoding`, so a body line over RFC 5322's 998-octet limit
17
+ was cut by a relay wherever the limit fell — a single-line HTML email reached a
18
+ customer reading "sup pliers" — and a non-ASCII body was sent as undeclared 8-bit
19
+ on a connection that never negotiated `8BITMIME`. A part that is plain ASCII with
20
+ lines of 78 characters or fewer is still sent as written, now declared `7bit`;
21
+ anything else is quoted-printable, which keeps ASCII readable where base64 would
22
+ not.
23
+
24
+ - **`DatabaseChannel` runs on Bun 1.4.4.** It built its query templates with an enumerable
25
+ `raw`, which Bun 1.4.4 refuses. It uses the ORM's `_templateStrings()` now; see
26
+ `@zerotal/orm`.
27
+
11
28
  ## [1.11.1] — 2026-08-31
12
29
 
13
30
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/notifications",
3
- "version": "1.15.1",
3
+ "version": "1.15.3",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,12 +30,12 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.15.1",
34
- "@zerotal/orm": "1.15.1",
35
- "@zerotal/queue": "1.15.1"
33
+ "@zerotal/core": "1.15.3",
34
+ "@zerotal/orm": "1.15.3",
35
+ "@zerotal/queue": "1.15.3"
36
36
  },
37
37
  "devDependencies": {
38
- "@zerotal/broadcasting": "1.15.1",
38
+ "@zerotal/broadcasting": "1.15.3",
39
39
  "typescript": "^5.8.0"
40
40
  },
41
41
  "description": "Multi-channel notifications for Zerotal — mail (SMTP/Resend), database, broadcast, Slack, and SMS.",
@@ -1,23 +1,16 @@
1
- import type { SQLInstance } from "@zerotal/orm";
1
+ import { _templateStrings, type SQLInstance } from "@zerotal/orm";
2
2
  import type { Notifiable } from "./types.ts";
3
3
  import { NotificationError } from "./errors.ts";
4
4
  import { notifiableType } from "./serialization.ts";
5
5
  import type { Notification } from "./Notification.ts";
6
6
 
7
- /** Build a TemplateStringsArray from a plain string array (mirrors ORM helper). */
8
- function tpl(strings: string[]): TemplateStringsArray {
9
- return Object.freeze(
10
- Object.assign([...strings], { raw: [...strings] }),
11
- ) as unknown as TemplateStringsArray;
12
- }
13
-
14
7
  /** Execute sql with a dynamically-constructed template (table name in static parts). */
15
8
  async function run<T = Record<string, unknown>>(
16
9
  conn: SQLInstance,
17
10
  parts: string[],
18
11
  ...values: unknown[]
19
12
  ): Promise<T[]> {
20
- return conn<T>(tpl(parts), ...values);
13
+ return conn<T>(_templateStrings(parts), ...values);
21
14
  }
22
15
 
23
16
  /** Allow only simple identifier characters — guards against injection in table names. */
@@ -669,28 +669,91 @@ function buildRawMessage(msg: MailPayload): string {
669
669
  function renderBody(msg: MailPayload): { headers: string; content: string } {
670
670
  if (msg.html && msg.text) {
671
671
  const alt = `zerotal-alt-${crypto.randomUUID()}`;
672
+ const text = encodeTextPart(msg.text);
673
+ const html = encodeTextPart(msg.html);
672
674
  const content = [
673
675
  `--${alt}`,
674
676
  "Content-Type: text/plain; charset=utf-8",
677
+ `Content-Transfer-Encoding: ${text.encoding}`,
675
678
  "",
676
- dotStuff(msg.text),
679
+ text.content,
677
680
  `--${alt}`,
678
681
  "Content-Type: text/html; charset=utf-8",
682
+ `Content-Transfer-Encoding: ${html.encoding}`,
679
683
  "",
680
- dotStuff(msg.html),
684
+ html.content,
681
685
  `--${alt}--`,
682
686
  ].join("\r\n");
683
687
  return { headers: `Content-Type: multipart/alternative; boundary="${alt}"`, content };
684
688
  }
685
- if (msg.html) {
686
- return { headers: "Content-Type: text/html; charset=utf-8", content: dotStuff(msg.html) };
687
- }
689
+ const type = msg.html ? "text/html" : "text/plain";
690
+ const part = encodeTextPart(msg.html || msg.text || "");
688
691
  return {
689
- headers: "Content-Type: text/plain; charset=utf-8",
690
- content: dotStuff(msg.text ?? ""),
692
+ headers: `Content-Type: ${type}; charset=utf-8\r\nContent-Transfer-Encoding: ${part.encoding}`,
693
+ content: part.content,
691
694
  };
692
695
  }
693
696
 
697
+ /**
698
+ * RFC 5322 §2.1.1's recommended line length. A part whose lines all fit, in plain
699
+ * ASCII, goes on the wire as written; anything else is quoted-printable.
700
+ */
701
+ const PLAIN_LINE_LIMIT = 78;
702
+
703
+ /**
704
+ * Pick a transfer encoding for a text part and apply it.
705
+ *
706
+ * Sent raw, a part breaks two rules at once. A line may not exceed 998 octets
707
+ * (RFC 5322 §2.1.1), and relays enforce that by cutting the line wherever the
708
+ * limit falls — a single-line HTML email arrived with "sup pliers" in it. And a
709
+ * part with no Content-Transfer-Encoding is 7bit by default, which raw UTF-8 is
710
+ * not; this driver never negotiates 8BITMIME. Quoted-printable fixes both and
711
+ * leaves ASCII readable, which base64 would not — and spam filters score a
712
+ * base64-wrapped ASCII body as disguised.
713
+ *
714
+ * The result is dot-stuffed, since quoted-printable lines can still begin with
715
+ * a period.
716
+ */
717
+ function encodeTextPart(text: string): { encoding: "7bit" | "quoted-printable"; content: string } {
718
+ const lines = text.split(/\r?\n/);
719
+ // eslint-disable-next-line no-control-regex -- matching non-ASCII by definition
720
+ const plain = !/[^\x00-\x7F]/.test(text) && lines.every((l) => l.length <= PLAIN_LINE_LIMIT);
721
+ if (plain) return { encoding: "7bit", content: dotStuff(text) };
722
+ return {
723
+ encoding: "quoted-printable",
724
+ content: dotStuff(lines.map(quotedPrintableLine).join("\r\n")),
725
+ };
726
+ }
727
+
728
+ /**
729
+ * Quoted-printable encode one line of text (RFC 2045 §6.7), with soft breaks
730
+ * keeping every output line at 76 characters or fewer.
731
+ */
732
+ function quotedPrintableLine(line: string): string {
733
+ const bytes = Buffer.from(line, "utf8");
734
+ const out: string[] = [];
735
+ let current = "";
736
+ for (let i = 0; i < bytes.length; i++) {
737
+ const byte = bytes[i]!;
738
+ // Printable ASCII bar `=` is literal, as are space and tab — except at the
739
+ // end of the line, where a relay may strip them, so they are escaped there.
740
+ const literal =
741
+ (byte >= 33 && byte <= 126 && byte !== 61) ||
742
+ ((byte === 32 || byte === 9) && i < bytes.length - 1);
743
+ const token = literal
744
+ ? String.fromCharCode(byte)
745
+ : `=${byte.toString(16).toUpperCase().padStart(2, "0")}`;
746
+ // 75 leaves room for the `=` of the soft break, and never splits an escape.
747
+ if (current.length + token.length > 75) {
748
+ out.push(`${current}=`);
749
+ current = "";
750
+ }
751
+ current += token;
752
+ }
753
+ out.push(current);
754
+ return out.join("\r\n");
755
+ }
756
+
694
757
  function toBase64(content: string | Uint8Array): string {
695
758
  return typeof content === "string"
696
759
  ? Buffer.from(content, "utf8").toString("base64")