@zerotal/notifications 1.15.0 → 1.15.2
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 +17 -0
- package/package.json +5 -5
- package/src/DatabaseChannel.ts +2 -9
- package/src/drivers/SmtpDriver.ts +70 -7
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.
|
|
3
|
+
"version": "1.15.2",
|
|
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.
|
|
34
|
-
"@zerotal/orm": "1.15.
|
|
35
|
-
"@zerotal/queue": "1.15.
|
|
33
|
+
"@zerotal/core": "1.15.2",
|
|
34
|
+
"@zerotal/orm": "1.15.2",
|
|
35
|
+
"@zerotal/queue": "1.15.2"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@zerotal/broadcasting": "1.15.
|
|
38
|
+
"@zerotal/broadcasting": "1.15.2",
|
|
39
39
|
"typescript": "^5.8.0"
|
|
40
40
|
},
|
|
41
41
|
"description": "Multi-channel notifications for Zerotal — mail (SMTP/Resend), database, broadcast, Slack, and SMS.",
|
package/src/DatabaseChannel.ts
CHANGED
|
@@ -1,23 +1,16 @@
|
|
|
1
|
-
import type
|
|
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>(
|
|
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
|
-
|
|
679
|
+
text.content,
|
|
677
680
|
`--${alt}`,
|
|
678
681
|
"Content-Type: text/html; charset=utf-8",
|
|
682
|
+
`Content-Transfer-Encoding: ${html.encoding}`,
|
|
679
683
|
"",
|
|
680
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
}
|
|
689
|
+
const type = msg.html ? "text/html" : "text/plain";
|
|
690
|
+
const part = encodeTextPart(msg.html || msg.text || "");
|
|
688
691
|
return {
|
|
689
|
-
headers:
|
|
690
|
-
content:
|
|
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")
|