@trstudios/discord-notify 0.0.0-stage → 1.0.0

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
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 discord-notify contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # discord-notify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Discord alerts for server-side failures, plus a [ZeptoMail](https://www.zoho.com/zeptomail/) sender that posts an alert whenever an email fails to send.
4
+
5
+ No dependencies. Ships as an ES module with TypeScript types, and needs a global `fetch` (Node 18+, Bun or Deno).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @trstudios/discord-notify
11
+ ```
12
+
13
+ **Server-side only.** It reads secrets from the environment, so never import it into browser or app bundles.
14
+
15
+ ## Configuration
16
+
17
+ | Variable | Used by | Purpose |
18
+ |---|---|---|
19
+ | `ALERTS_DISCORD_WEBHOOK` | `notifyDiscord`, failed `sendMail` | A Discord channel webhook URL. If it's unset, alerts are logged instead. |
20
+ | `ZEPTOMAIL_TOKEN` | `sendMail` | The mail agent's Send Mail token, with or without the `Zoho-enczapikey ` prefix |
21
+ | `ZEPTOMAIL_API_URL` | `sendMail` (optional) | Overrides `https://api.zeptomail.com/v1.1/email`, e.g. for a regional data centre |
22
+
23
+ Each option can also be passed in code instead of through the environment.
24
+
25
+ ## Usage
26
+
27
+ ### Send an alert
28
+
29
+ ```ts
30
+ import { notifyDiscord } from "@trstudios/discord-notify";
31
+
32
+ await notifyDiscord({
33
+ title: "Nightly export failed",
34
+ fields: { Job: "export", Error: String(err) },
35
+ body: "Optional longer text",
36
+ });
37
+ ```
38
+
39
+ `notifyDiscord` never throws. It returns `false` if the alert couldn't be sent and logs why.
40
+
41
+ ### Send an email
42
+
43
+ ```ts
44
+ import { sendMail } from "@trstudios/discord-notify";
45
+
46
+ const result = await sendMail(
47
+ {
48
+ from: { address: "noreply@example.com", name: "Example" },
49
+ to: [{ address: "someone@example.com" }],
50
+ replyTo: { address: "help@example.com" },
51
+ subject: "Your report is ready",
52
+ html: "<p>…</p>",
53
+ },
54
+ { source: "reports", alertContent: "summary" },
55
+ );
56
+
57
+ if (!result.ok) {
58
+ // Already logged, and alerted to Discord. Answer your own request as needed.
59
+ }
60
+ ```
61
+
62
+ `sendMail` never throws. When a send fails, because of a bad token, a rejected sender or IP, a network error or anything else, it:
63
+ 1. logs the error;
64
+ 2. posts a Discord alert;
65
+ 3. returns `{ ok: false, error }`.
66
+
67
+ ### How much a failure alert includes
68
+
69
+ `alertContent` decides how much of the failed message goes to Discord:
70
+
71
+ | Level | Includes |
72
+ |---|---|
73
+ | `"none"` (default) | Source, error, recipients masked as `j***@example.com` |
74
+ | `"summary"` | Adds from, full recipients, reply-to and subject |
75
+ | `"full"` | Adds the message body. If there's no text version, it's converted from the HTML. |
76
+
77
+ Use `"none"` for messages that contain secrets, such as sign-in codes, password resets and invite links. Anyone who can read the alert channel can read whatever an alert includes.
78
+
79
+ ## Safety notes
80
+
81
+ - **No pings:** alerts are posted with mentions disabled, so text in a message can't ping `@everyone` or a role.
82
+ - **Discord's size limits:** long fields and bodies are cut off with a `(truncated)` marker.
83
+ - **Timeouts:** requests time out after 5 seconds (Discord) and 15 seconds (ZeptoMail), so a hung service can't stall your request handler for long.
84
+
85
+ ## Development
86
+
87
+ ```sh
88
+ npm install
89
+ npm test
90
+ npm run typecheck
91
+ ```
92
+
93
+ ## Releasing
94
+
95
+ 1. Bump `version` in `package.json` and commit.
96
+ 2. Push a matching tag, for example `git tag v1.0.1 && git push origin v1.0.1`.
97
+
98
+ GitHub Actions then publishes to npm through Trusted Publishing, with provenance.
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,86 @@
1
+ type FetchFn = typeof fetch;
2
+ export interface DiscordAlert {
3
+ /** One line saying what failed, e.g. "Nightly export failed". */
4
+ title: string;
5
+ /** Short labelled values (source, error, recipient...). */
6
+ fields?: Record<string, string | undefined>;
7
+ /** Longer free text, such as the content of a failed message. */
8
+ body?: string;
9
+ }
10
+ export interface NotifyOptions {
11
+ /** Defaults to the ALERTS_DISCORD_WEBHOOK environment variable. */
12
+ webhookUrl?: string;
13
+ fetch?: FetchFn;
14
+ }
15
+ export declare function truncate(text: string, max: number): string;
16
+ /** Builds the webhook payload. Exported for tests. */
17
+ export declare function discordPayload(alert: DiscordAlert): {
18
+ allowed_mentions: {
19
+ parse: string[];
20
+ };
21
+ embeds: {
22
+ title: string;
23
+ color: number;
24
+ fields: {
25
+ name: string;
26
+ value: string;
27
+ inline: boolean;
28
+ }[];
29
+ description: string | undefined;
30
+ timestamp: string;
31
+ }[];
32
+ };
33
+ /**
34
+ * Posts an alert to a Discord channel webhook. Never throws: it returns false
35
+ * when there is no webhook configured or Discord can't be reached, and logs why.
36
+ */
37
+ export declare function notifyDiscord(alert: DiscordAlert, options?: NotifyOptions): Promise<boolean>;
38
+ export interface Address {
39
+ address: string;
40
+ name?: string;
41
+ }
42
+ export interface Mail {
43
+ from: Address;
44
+ to: Address[];
45
+ replyTo?: Address;
46
+ subject: string;
47
+ text?: string;
48
+ html?: string;
49
+ }
50
+ /**
51
+ * How much of a failed message goes into the alert.
52
+ * - "none": recipients masked (j***@example.com), no subject or content. Use for
53
+ * anything with secrets in it: sign-in codes, password resets, invites.
54
+ * - "summary": recipients and subject, no content.
55
+ * - "full": everything, including the message body.
56
+ */
57
+ export type AlertContent = "none" | "summary" | "full";
58
+ export interface SendOptions {
59
+ /** Defaults to ZEPTOMAIL_TOKEN. With or without the "Zoho-enczapikey " prefix. */
60
+ token?: string;
61
+ /** What the alert should include if this send fails. Defaults to "none". */
62
+ alertContent?: AlertContent;
63
+ /** Names the sender in the alert, e.g. "billing service". */
64
+ source?: string;
65
+ /** Defaults to https://api.zeptomail.com/v1.1/email (ZEPTOMAIL_API_URL overrides). */
66
+ apiUrl?: string;
67
+ /** Options for the failure alert. */
68
+ notify?: NotifyOptions;
69
+ fetch?: FetchFn;
70
+ }
71
+ export type SendResult = {
72
+ ok: true;
73
+ } | {
74
+ ok: false;
75
+ error: string;
76
+ };
77
+ export declare function maskEmail(address: string): string;
78
+ /** Builds the failure alert at the requested level of detail. Exported for tests. */
79
+ export declare function failureAlert(mail: Mail, error: string, level: AlertContent, source?: string): DiscordAlert;
80
+ /**
81
+ * Sends one email through the ZeptoMail API. Never throws. On failure it posts a
82
+ * Discord alert (see AlertContent) and returns { ok: false }, so the caller can
83
+ * still answer its own request.
84
+ */
85
+ export declare function sendMail(mail: Mail, options?: SendOptions): Promise<SendResult>;
86
+ export {};
package/dist/notify.js ADDED
@@ -0,0 +1,171 @@
1
+ // discord-notify — https://github.com/Traumatized-ink/discord-notify
2
+ // Server-side only: it reads secrets from the environment. Never import it from
3
+ // browser or app bundles.
4
+ //
5
+ // No dependencies; needs a global fetch (Node 18+, Bun, Deno).
6
+ // Discord's limits for one embed.
7
+ const TITLE_MAX = 256;
8
+ const FIELD_NAME_MAX = 256;
9
+ const FIELD_VALUE_MAX = 1024;
10
+ const MAX_FIELDS = 25;
11
+ const DESCRIPTION_MAX = 4096;
12
+ const EMBED_TOTAL_MAX = 6000;
13
+ const RED = 0xd93f3f;
14
+ export function truncate(text, max) {
15
+ if (text.length <= max)
16
+ return text;
17
+ const note = "… (truncated)";
18
+ return text.slice(0, max - note.length) + note;
19
+ }
20
+ /** Builds the webhook payload. Exported for tests. */
21
+ export function discordPayload(alert) {
22
+ const title = truncate(alert.title, TITLE_MAX);
23
+ const fields = Object.entries(alert.fields ?? {})
24
+ .filter((entry) => Boolean(entry[1]))
25
+ .slice(0, MAX_FIELDS)
26
+ .map(([name, value]) => ({
27
+ name: truncate(name, FIELD_NAME_MAX),
28
+ value: truncate(value, FIELD_VALUE_MAX),
29
+ inline: value.length <= 40,
30
+ }));
31
+ const used = title.length + fields.reduce((n, f) => n + f.name.length + f.value.length, 0);
32
+ const room = Math.min(DESCRIPTION_MAX, EMBED_TOTAL_MAX - used);
33
+ const description = alert.body && room > 20 ? truncate(alert.body, room) : undefined;
34
+ return {
35
+ // Alert text can contain user input; never let it ping @everyone or roles.
36
+ allowed_mentions: { parse: [] },
37
+ embeds: [{ title, color: RED, fields, description, timestamp: new Date().toISOString() }],
38
+ };
39
+ }
40
+ /**
41
+ * Posts an alert to a Discord channel webhook. Never throws: it returns false
42
+ * when there is no webhook configured or Discord can't be reached, and logs why.
43
+ */
44
+ export async function notifyDiscord(alert, options = {}) {
45
+ const url = options.webhookUrl ?? process.env.ALERTS_DISCORD_WEBHOOK;
46
+ if (!url) {
47
+ console.warn(`[discord-notify] ALERTS_DISCORD_WEBHOOK is not set; alert not sent: ${alert.title}`);
48
+ return false;
49
+ }
50
+ try {
51
+ const res = await (options.fetch ?? fetch)(url, {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json" },
54
+ body: JSON.stringify(discordPayload(alert)),
55
+ signal: AbortSignal.timeout(5000),
56
+ });
57
+ if (!res.ok) {
58
+ console.error(`[discord-notify] Discord returned ${res.status}; alert not sent: ${alert.title}`);
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+ catch (err) {
64
+ console.error(`[discord-notify] could not reach Discord; alert not sent: ${alert.title}`, err);
65
+ return false;
66
+ }
67
+ }
68
+ const PREFIX = "Zoho-enczapikey ";
69
+ export function maskEmail(address) {
70
+ const at = address.lastIndexOf("@");
71
+ if (at < 1)
72
+ return "***";
73
+ return `${address[0]}***${address.slice(at)}`;
74
+ }
75
+ const formatAddress = (a) => (a.name ? `${a.name} <${a.address}>` : a.address);
76
+ function zeptoBody(mail) {
77
+ return {
78
+ from: { address: mail.from.address, name: mail.from.name },
79
+ to: mail.to.map((a) => ({ email_address: { address: a.address, name: a.name } })),
80
+ reply_to: mail.replyTo ? [{ address: mail.replyTo.address, name: mail.replyTo.name }] : undefined,
81
+ subject: mail.subject,
82
+ textbody: mail.text,
83
+ htmlbody: mail.html,
84
+ };
85
+ }
86
+ /** Turns HTML into readable text for the alert when there is no text version. */
87
+ function htmlToText(html) {
88
+ return html
89
+ .replace(/<(br|\/p|\/div|\/h\d|\/li|\/tr)\b[^>]*>/gi, "\n")
90
+ .replace(/<[^>]+>/g, "")
91
+ .replace(/&nbsp;/g, " ")
92
+ .replace(/&lt;/g, "<")
93
+ .replace(/&gt;/g, ">")
94
+ .replace(/&quot;/g, '"')
95
+ .replace(/&#39;/g, "'")
96
+ .replace(/&amp;/g, "&")
97
+ .replace(/\n\s*\n+/g, "\n\n")
98
+ .trim();
99
+ }
100
+ /** Builds the failure alert at the requested level of detail. Exported for tests. */
101
+ export function failureAlert(mail, error, level, source) {
102
+ const fields = { Source: source, Error: error };
103
+ if (level === "none") {
104
+ fields.To = mail.to.map((a) => maskEmail(a.address)).join(", ");
105
+ return { title: "Email failed to send", fields };
106
+ }
107
+ fields.From = formatAddress(mail.from);
108
+ fields.To = mail.to.map(formatAddress).join(", ");
109
+ fields["Reply-To"] = mail.replyTo && formatAddress(mail.replyTo);
110
+ fields.Subject = mail.subject;
111
+ if (level === "summary")
112
+ return { title: "Email failed to send", fields };
113
+ const body = mail.text ?? (mail.html ? htmlToText(mail.html) : undefined);
114
+ return { title: "Email failed to send", fields, body };
115
+ }
116
+ async function describeFailure(res) {
117
+ const text = await res.text().catch(() => "");
118
+ try {
119
+ const err = JSON.parse(text)?.error;
120
+ if (err) {
121
+ const details = Array.isArray(err.details)
122
+ ? err.details.map((d) => [d.target, d.message].filter(Boolean).join(": ")).join("; ")
123
+ : "";
124
+ return [`HTTP ${res.status}`, err.code, err.message, details].filter(Boolean).join(" · ");
125
+ }
126
+ }
127
+ catch {
128
+ // Not JSON; fall through to the raw text.
129
+ }
130
+ return `HTTP ${res.status}${text ? ` · ${text.slice(0, 300)}` : ""}`;
131
+ }
132
+ /**
133
+ * Sends one email through the ZeptoMail API. Never throws. On failure it posts a
134
+ * Discord alert (see AlertContent) and returns { ok: false }, so the caller can
135
+ * still answer its own request.
136
+ */
137
+ export async function sendMail(mail, options = {}) {
138
+ const level = options.alertContent ?? "none";
139
+ const raw = options.token ?? process.env.ZEPTOMAIL_TOKEN ?? "";
140
+ const key = raw.startsWith(PREFIX) ? raw.slice(PREFIX.length) : raw;
141
+ let error;
142
+ if (!key) {
143
+ error = "ZEPTOMAIL_TOKEN is not set";
144
+ }
145
+ else {
146
+ try {
147
+ const res = await (options.fetch ?? fetch)(options.apiUrl ?? process.env.ZEPTOMAIL_API_URL ?? "https://api.zeptomail.com/v1.1/email", {
148
+ method: "POST",
149
+ headers: {
150
+ Accept: "application/json",
151
+ "Content-Type": "application/json",
152
+ Authorization: PREFIX + key,
153
+ },
154
+ body: JSON.stringify(zeptoBody(mail)),
155
+ signal: AbortSignal.timeout(15000),
156
+ });
157
+ if (res.ok)
158
+ return { ok: true };
159
+ error = await describeFailure(res);
160
+ }
161
+ catch (err) {
162
+ error = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
163
+ }
164
+ }
165
+ console.error(`[discord-notify] email failed${options.source ? ` (${options.source})` : ""}: ${error}`);
166
+ await notifyDiscord(failureAlert(mail, error, level, options.source), {
167
+ ...options.notify,
168
+ fetch: options.notify?.fetch ?? options.fetch,
169
+ });
170
+ return { ok: false, error };
171
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@trstudios/discord-notify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Discord alerts for server-side failures, with a ZeptoMail sender that reports failed sends. One file, no dependencies.",
5
+ "keywords": [
6
+ "discord",
7
+ "webhook",
8
+ "alerts",
9
+ "zeptomail",
10
+ "email"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Traumatized-ink/discord-notify.git"
16
+ },
17
+ "type": "module",
18
+ "main": "./dist/notify.js",
19
+ "types": "./dist/notify.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/notify.d.ts",
23
+ "default": "./dist/notify.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json",
37
+ "test": "node --test test/*.test.ts",
38
+ "typecheck": "tsc --noEmit",
39
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "typescript": "^5.6.0"
44
+ }
45
+ }