@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 +32 -1
- package/dist/notify.d.ts +22 -0
- package/dist/notify.js +49 -2
- package/package.json +1 -1
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
|
|
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
|
|
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