@trstudios/discord-notify 1.1.0 → 1.3.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/README.md CHANGED
@@ -48,6 +48,7 @@ const result = await sendMail(
48
48
  from: { address: "noreply@example.com", name: "Example" },
49
49
  to: [{ address: "someone@example.com" }],
50
50
  bcc: [{ address: "audit@example.com" }], // optional, as is cc
51
+ attachments: [{ filename: "report.pdf", content: pdfBytes, contentType: "application/pdf" }], // optional
51
52
  replyTo: { address: "help@example.com" },
52
53
  subject: "Your report is ready",
53
54
  html: "<p>…</p>",
@@ -77,6 +78,36 @@ if (!result.ok) {
77
78
 
78
79
  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.
79
80
 
81
+ ### Check delivery and alerts
82
+
83
+ `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.
84
+
85
+ `secretMatches` compares a request header with a configured secret in constant time. It returns `false` when either value is missing.
86
+
87
+ Here's an example route, shown with Next.js, protected by an admin secret. It sends only to an address from configuration:
88
+
89
+ ```ts
90
+ import { secretMatches, sendTestMail } from "@trstudios/discord-notify";
91
+
92
+ export async function POST(request: Request) {
93
+ if (!secretMatches(request.headers.get("x-admin-token"), process.env.ADMIN_TOKEN)) {
94
+ return Response.json({ error: "Not allowed" }, { status: 403 });
95
+ }
96
+ const to = process.env.TEST_EMAIL_TO;
97
+ if (!to) return Response.json({ error: "TEST_EMAIL_TO is not set" }, { status: 500 });
98
+
99
+ const result = await sendTestMail({
100
+ from: { address: "noreply@example.com", name: "Example" },
101
+ to: [{ address: to }],
102
+ source: "example.com",
103
+ fail: new URL(request.url).searchParams.get("fail") === "1",
104
+ });
105
+ return Response.json(result, { status: result.ok ? 200 : 500 });
106
+ }
107
+ ```
108
+
109
+ Never take the recipient from the request. Otherwise anyone holding the admin secret could use the route to send mail to arbitrary addresses.
110
+
80
111
  ## Safety notes
81
112
 
82
113
  - **No pings:** alerts are posted with mentions disabled, so text in a message can't ping `@everyone` or a role.
@@ -96,7 +127,7 @@ npm run typecheck
96
127
  1. Bump `version` in `package.json` and commit.
97
128
  2. Push a matching tag, for example `git tag v1.0.1 && git push origin v1.0.1`.
98
129
 
99
- GitHub Actions then stages the release on npm through Trusted Publishing, with provenance. A maintainer approves it on npmjs.com to publish it.
130
+ GitHub Actions then publishes it to npm through Trusted Publishing, with provenance.
100
131
 
101
132
  ## License
102
133
 
package/dist/notify.d.ts CHANGED
@@ -39,6 +39,14 @@ export interface Address {
39
39
  address: string;
40
40
  name?: string;
41
41
  }
42
+ export interface Attachment {
43
+ /** File name shown to the recipient, e.g. "report.pdf". */
44
+ filename: string;
45
+ /** The file's bytes. Strings are taken as already base64-encoded. */
46
+ content: Uint8Array | string;
47
+ /** MIME type, e.g. "application/pdf". */
48
+ contentType: string;
49
+ }
42
50
  export interface Mail {
43
51
  from: Address;
44
52
  to: Address[];
@@ -48,6 +56,7 @@ export interface Mail {
48
56
  subject: string;
49
57
  text?: string;
50
58
  html?: string;
59
+ attachments?: Attachment[];
51
60
  }
52
61
  /**
53
62
  * How much of a failed message goes into the alert.
@@ -85,4 +94,24 @@ export declare function failureAlert(mail: Mail, error: string, level: AlertCont
85
94
  * still answer its own request.
86
95
  */
87
96
  export declare function sendMail(mail: Mail, options?: SendOptions): Promise<SendResult>;
97
+ /**
98
+ * Compares a secret from a request header with the expected value in constant
99
+ * time. Returns false when either is missing, so an unset secret never matches.
100
+ */
101
+ export declare function secretMatches(given: string | null | undefined, expected: string | undefined): boolean;
102
+ export interface TestMailOptions extends SendOptions {
103
+ from: Address;
104
+ /** Where the test goes. Take it from configuration, never from the request. */
105
+ to: Address[];
106
+ /**
107
+ * Send with a deliberately invalid token, so the send fails and the failure
108
+ * alert fires. Checks the alert path without changing any configuration.
109
+ */
110
+ fail?: boolean;
111
+ }
112
+ /**
113
+ * Sends a short test email through the same path as real mail, to check
114
+ * delivery (or, with `fail`, the failure alert). Never throws.
115
+ */
116
+ export declare function sendTestMail(options: TestMailOptions): Promise<SendResult>;
88
117
  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,6 +74,7 @@ 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 base64 = (content) => typeof content === "string" ? content : Buffer.from(content).toString("base64");
76
78
  const recipients = (list) => list.map((a) => ({ email_address: { address: a.address, name: a.name } }));
77
79
  function zeptoBody(mail) {
78
80
  return {
@@ -84,6 +86,9 @@ function zeptoBody(mail) {
84
86
  subject: mail.subject,
85
87
  textbody: mail.text,
86
88
  htmlbody: mail.html,
89
+ attachments: mail.attachments?.length
90
+ ? mail.attachments.map((a) => ({ name: a.filename, mime_type: a.contentType, content: base64(a.content) }))
91
+ : undefined,
87
92
  };
88
93
  }
89
94
  /** Turns HTML into readable text for the alert when there is no text version. */
@@ -119,6 +124,9 @@ export function failureAlert(mail, error, level, source) {
119
124
  fields.Bcc = mail.bcc.map(formatAddress).join(", ");
120
125
  fields["Reply-To"] = mail.replyTo && formatAddress(mail.replyTo);
121
126
  fields.Subject = mail.subject;
127
+ if (mail.attachments?.length) {
128
+ fields.Attachments = mail.attachments.map((a) => `${a.filename} (${a.contentType})`).join(", ");
129
+ }
122
130
  if (level === "summary")
123
131
  return { title: "Email failed to send", fields };
124
132
  const body = mail.text ?? (mail.html ? htmlToText(mail.html) : undefined);
@@ -180,3 +188,38 @@ export async function sendMail(mail, options = {}) {
180
188
  });
181
189
  return { ok: false, error };
182
190
  }
191
+ // ---------------------------------------------------------------------------
192
+ // Delivery checks
193
+ // ---------------------------------------------------------------------------
194
+ /**
195
+ * Compares a secret from a request header with the expected value in constant
196
+ * time. Returns false when either is missing, so an unset secret never matches.
197
+ */
198
+ export function secretMatches(given, expected) {
199
+ if (!given || !expected)
200
+ return false;
201
+ const a = createHash("sha256").update(given).digest();
202
+ const b = createHash("sha256").update(expected).digest();
203
+ return timingSafeEqual(a, b);
204
+ }
205
+ /**
206
+ * Sends a short test email through the same path as real mail, to check
207
+ * delivery (or, with `fail`, the failure alert). Never throws.
208
+ */
209
+ export async function sendTestMail(options) {
210
+ const { from, to, fail, ...send } = options;
211
+ const when = new Date().toISOString();
212
+ const what = fail ? "Failure alert test" : "Delivery test";
213
+ return sendMail({
214
+ from,
215
+ to,
216
+ subject: `${what}${send.source ? ` (${send.source})` : ""}: ${when}`,
217
+ text: fail
218
+ ? `This send used a deliberately invalid token, so it should fail and raise an alert.\n\nSent ${when}.`
219
+ : `If you can read this, email from ${from.address} is being delivered.\n\nSent ${when}.`,
220
+ }, {
221
+ alertContent: "summary",
222
+ ...send,
223
+ ...(fail && { token: "invalid-token-for-failure-test" }),
224
+ });
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trstudios/discord-notify",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
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",