@trstudios/discord-notify 1.0.1 → 1.2.1

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/README.md CHANGED
@@ -47,6 +47,7 @@ const result = await sendMail(
47
47
  {
48
48
  from: { address: "noreply@example.com", name: "Example" },
49
49
  to: [{ address: "someone@example.com" }],
50
+ bcc: [{ address: "audit@example.com" }], // optional, as is cc
50
51
  replyTo: { address: "help@example.com" },
51
52
  subject: "Your report is ready",
52
53
  html: "<p>…</p>",
@@ -76,6 +77,36 @@ if (!result.ok) {
76
77
 
77
78
  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
 
80
+ ### Check delivery and alerts
81
+
82
+ `sendTestMail` sends a short test email through the same path as real mail. With `fail: true`, it uses a deliberately invalid token instead, so the send fails and the failure alert fires. That checks the alert path without changing any configuration.
83
+
84
+ `secretMatches` compares a request header with a configured secret in constant time. It returns `false` when either value is missing.
85
+
86
+ Here's an example route, shown with Next.js, protected by an admin secret. It sends only to an address from configuration:
87
+
88
+ ```ts
89
+ import { secretMatches, sendTestMail } from "@trstudios/discord-notify";
90
+
91
+ export async function POST(request: Request) {
92
+ if (!secretMatches(request.headers.get("x-admin-token"), process.env.ADMIN_TOKEN)) {
93
+ return Response.json({ error: "Not allowed" }, { status: 403 });
94
+ }
95
+ const to = process.env.TEST_EMAIL_TO;
96
+ if (!to) return Response.json({ error: "TEST_EMAIL_TO is not set" }, { status: 500 });
97
+
98
+ const result = await sendTestMail({
99
+ from: { address: "noreply@example.com", name: "Example" },
100
+ to: [{ address: to }],
101
+ source: "example.com",
102
+ fail: new URL(request.url).searchParams.get("fail") === "1",
103
+ });
104
+ return Response.json(result, { status: result.ok ? 200 : 502 });
105
+ }
106
+ ```
107
+
108
+ Never take the recipient from the request. Otherwise anyone holding the admin secret could use the route to send mail to arbitrary addresses.
109
+
79
110
  ## Safety notes
80
111
 
81
112
  - **No pings:** alerts are posted with mentions disabled, so text in a message can't ping `@everyone` or a role.
@@ -95,7 +126,7 @@ npm run typecheck
95
126
  1. Bump `version` in `package.json` and commit.
96
127
  2. Push a matching tag, for example `git tag v1.0.1 && git push origin v1.0.1`.
97
128
 
98
- GitHub Actions then stages the release on npm through Trusted Publishing, with provenance. A maintainer approves it on npmjs.com to publish it.
129
+ GitHub Actions then publishes it to npm through Trusted Publishing, with provenance.
99
130
 
100
131
  ## License
101
132
 
package/dist/notify.d.ts CHANGED
@@ -42,6 +42,8 @@ export interface Address {
42
42
  export interface Mail {
43
43
  from: Address;
44
44
  to: Address[];
45
+ cc?: Address[];
46
+ bcc?: Address[];
45
47
  replyTo?: Address;
46
48
  subject: string;
47
49
  text?: string;
@@ -83,4 +85,24 @@ export declare function failureAlert(mail: Mail, error: string, level: AlertCont
83
85
  * still answer its own request.
84
86
  */
85
87
  export declare function sendMail(mail: Mail, options?: SendOptions): Promise<SendResult>;
88
+ /**
89
+ * Compares a secret from a request header with the expected value in constant
90
+ * time. Returns false when either is missing, so an unset secret never matches.
91
+ */
92
+ export declare function secretMatches(given: string | null | undefined, expected: string | undefined): boolean;
93
+ export interface TestMailOptions extends SendOptions {
94
+ from: Address;
95
+ /** Where the test goes. Take it from configuration, never from the request. */
96
+ to: Address[];
97
+ /**
98
+ * Send with a deliberately invalid token, so the send fails and the failure
99
+ * alert fires. Checks the alert path without changing any configuration.
100
+ */
101
+ fail?: boolean;
102
+ }
103
+ /**
104
+ * Sends a short test email through the same path as real mail, to check
105
+ * delivery (or, with `fail`, the failure alert). Never throws.
106
+ */
107
+ export declare function sendTestMail(options: TestMailOptions): Promise<SendResult>;
86
108
  export {};
package/dist/notify.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // Server-side only: it reads secrets from the environment. Never import it from
3
3
  // browser or app bundles.
4
4
  //
5
- // No dependencies; needs a global fetch (Node 18+, Bun, Deno).
5
+ // No dependencies; needs a global fetch and node:crypto (Node 18+, Bun, Deno).
6
+ import { createHash, timingSafeEqual } from "node:crypto";
6
7
  // Discord's limits for one embed.
7
8
  const TITLE_MAX = 256;
8
9
  const FIELD_NAME_MAX = 256;
@@ -73,10 +74,13 @@ export function maskEmail(address) {
73
74
  return `${address[0]}***${address.slice(at)}`;
74
75
  }
75
76
  const formatAddress = (a) => (a.name ? `${a.name} <${a.address}>` : a.address);
77
+ const recipients = (list) => list.map((a) => ({ email_address: { address: a.address, name: a.name } }));
76
78
  function zeptoBody(mail) {
77
79
  return {
78
80
  from: { address: mail.from.address, name: mail.from.name },
79
- to: mail.to.map((a) => ({ email_address: { address: a.address, name: a.name } })),
81
+ to: recipients(mail.to),
82
+ cc: mail.cc?.length ? recipients(mail.cc) : undefined,
83
+ bcc: mail.bcc?.length ? recipients(mail.bcc) : undefined,
80
84
  reply_to: mail.replyTo ? [{ address: mail.replyTo.address, name: mail.replyTo.name }] : undefined,
81
85
  subject: mail.subject,
82
86
  textbody: mail.text,
@@ -102,10 +106,18 @@ export function failureAlert(mail, error, level, source) {
102
106
  const fields = { Source: source, Error: error };
103
107
  if (level === "none") {
104
108
  fields.To = mail.to.map((a) => maskEmail(a.address)).join(", ");
109
+ if (mail.cc?.length)
110
+ fields.Cc = mail.cc.map((a) => maskEmail(a.address)).join(", ");
111
+ if (mail.bcc?.length)
112
+ fields.Bcc = mail.bcc.map((a) => maskEmail(a.address)).join(", ");
105
113
  return { title: "Email failed to send", fields };
106
114
  }
107
115
  fields.From = formatAddress(mail.from);
108
116
  fields.To = mail.to.map(formatAddress).join(", ");
117
+ if (mail.cc?.length)
118
+ fields.Cc = mail.cc.map(formatAddress).join(", ");
119
+ if (mail.bcc?.length)
120
+ fields.Bcc = mail.bcc.map(formatAddress).join(", ");
109
121
  fields["Reply-To"] = mail.replyTo && formatAddress(mail.replyTo);
110
122
  fields.Subject = mail.subject;
111
123
  if (level === "summary")
@@ -169,3 +181,38 @@ export async function sendMail(mail, options = {}) {
169
181
  });
170
182
  return { ok: false, error };
171
183
  }
184
+ // ---------------------------------------------------------------------------
185
+ // Delivery checks
186
+ // ---------------------------------------------------------------------------
187
+ /**
188
+ * Compares a secret from a request header with the expected value in constant
189
+ * time. Returns false when either is missing, so an unset secret never matches.
190
+ */
191
+ export function secretMatches(given, expected) {
192
+ if (!given || !expected)
193
+ return false;
194
+ const a = createHash("sha256").update(given).digest();
195
+ const b = createHash("sha256").update(expected).digest();
196
+ return timingSafeEqual(a, b);
197
+ }
198
+ /**
199
+ * Sends a short test email through the same path as real mail, to check
200
+ * delivery (or, with `fail`, the failure alert). Never throws.
201
+ */
202
+ export async function sendTestMail(options) {
203
+ const { from, to, fail, ...send } = options;
204
+ const when = new Date().toISOString();
205
+ const what = fail ? "Failure alert test" : "Delivery test";
206
+ return sendMail({
207
+ from,
208
+ to,
209
+ subject: `${what}${send.source ? ` (${send.source})` : ""}: ${when}`,
210
+ text: fail
211
+ ? `This send used a deliberately invalid token, so it should fail and raise an alert.\n\nSent ${when}.`
212
+ : `If you can read this, email from ${from.address} is being delivered.\n\nSent ${when}.`,
213
+ }, {
214
+ alertContent: "summary",
215
+ ...send,
216
+ ...(fail && { token: "invalid-token-for-failure-test" }),
217
+ });
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trstudios/discord-notify",
3
- "version": "1.0.1",
3
+ "version": "1.2.1",
4
4
  "description": "Discord alerts for server-side failures, with a ZeptoMail sender that reports failed sends. No dependencies.",
5
5
  "keywords": [
6
6
  "discord",