@crewhaus/tool-notify 0.7.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 +271 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2167 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +217 -0
- package/dist/lib/digest.d.ts +56 -0
- package/dist/lib/digest.js +140 -0
- package/dist/lib/dns-records.d.ts +179 -0
- package/dist/lib/dns-records.js +431 -0
- package/dist/lib/mime.d.ts +125 -0
- package/dist/lib/mime.js +618 -0
- package/dist/lib/preflight.d.ts +71 -0
- package/dist/lib/preflight.js +291 -0
- package/dist/lib/quiet.d.ts +59 -0
- package/dist/lib/quiet.js +217 -0
- package/dist/lib/ratelimit.d.ts +57 -0
- package/dist/lib/ratelimit.js +87 -0
- package/dist/lib/sign.d.ts +8 -0
- package/dist/lib/sign.js +39 -0
- package/dist/lib/template.d.ts +41 -0
- package/dist/lib/template.js +85 -0
- package/dist/net.d.ts +391 -0
- package/dist/net.js +1131 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/smtp.d.ts +56 -0
- package/dist/smtp.js +479 -0
- package/package.json +45 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The questions worth asking about a message BEFORE a socket opens.
|
|
3
|
+
*
|
|
4
|
+
* `EmailSend` refuses a message it cannot build, and refuses it one reason
|
|
5
|
+
* at a time, at the moment the caller has already decided to send. That is
|
|
6
|
+
* the right behaviour for a send and the wrong one for a check: what a
|
|
7
|
+
* caller wants before committing is every reason at once, including the ones
|
|
8
|
+
* that are not refusals at all - an empty body, a subject still carrying
|
|
9
|
+
* `{{release}}`, the same mailbox on both To and Bcc.
|
|
10
|
+
*
|
|
11
|
+
* So the rules live here, pure, and the tool composes them. Two properties
|
|
12
|
+
* keep the result honest:
|
|
13
|
+
*
|
|
14
|
+
* - **The composer stays the authority.** Everything here is a report;
|
|
15
|
+
* the real `composeMessage` runs afterwards and its verdict is the one
|
|
16
|
+
* that decides whether the message can be built at all. Where a rule
|
|
17
|
+
* below and the composer ask the same question - a header name, a
|
|
18
|
+
* message id - they call the SAME predicate out of `./mime`, because a
|
|
19
|
+
* preflight that passes against its own copy of a rule and a send that
|
|
20
|
+
* then refuses is a preflight nobody can act on.
|
|
21
|
+
* - **A check that could not run says so.** `unknown` is a status, not an
|
|
22
|
+
* absent `fail`. An attachment that could not be read and an attachment
|
|
23
|
+
* that is fine are different answers, and only one of them means the
|
|
24
|
+
* message is ready.
|
|
25
|
+
*/
|
|
26
|
+
import { isMessageId, isReservedHeaderName, isValidAddress, isValidHeaderName } from "./mime";
|
|
27
|
+
import { templatePlaceholders } from "./template";
|
|
28
|
+
/** Locale-free order, so two runs list the same faults in the same place. */
|
|
29
|
+
function byString(a, b) {
|
|
30
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
31
|
+
}
|
|
32
|
+
/** A value a header or a body can hold and still be empty. */
|
|
33
|
+
const isBlank = (text) => text.trim() === "";
|
|
34
|
+
/**
|
|
35
|
+
* The comparison key for "is this the same mailbox twice".
|
|
36
|
+
*
|
|
37
|
+
* Case only, deliberately. Gmail folds `a.b@` and `ab@` to one mailbox and
|
|
38
|
+
* strips `+tags`; most providers do neither, so folding them here would
|
|
39
|
+
* report two genuinely separate recipients as one duplicate on every domain
|
|
40
|
+
* that keeps them apart. Which domains fold is configuration this package
|
|
41
|
+
* does not carry, so the narrow, always-true rule is the one implemented -
|
|
42
|
+
* and it never rewrites an address, only the key two addresses are compared
|
|
43
|
+
* on.
|
|
44
|
+
*/
|
|
45
|
+
const mailboxKey = (address) => address.trim().toLowerCase();
|
|
46
|
+
/**
|
|
47
|
+
* How the envelope folds, counted the way the composer counts it.
|
|
48
|
+
*
|
|
49
|
+
* The trap this exists to avoid: comparing on a case-folded key and then
|
|
50
|
+
* reporting a conclusion about a composer that de-duplicates on the exact
|
|
51
|
+
* string. `ops@example.com` on To and `OPS@example.com` on Bcc is ONE
|
|
52
|
+
* mailbox by the key above and TWO entries in `composeMessage`'s envelope
|
|
53
|
+
* Set, so "it will be sent one copy, not two" is false for exactly that
|
|
54
|
+
* case - which is the case a caller runs a preflight to find. Both facts are
|
|
55
|
+
* returned so the caller is told which one applies, and the count reported
|
|
56
|
+
* is the composer's, never the key's.
|
|
57
|
+
*/
|
|
58
|
+
export function envelopeFolding(lists) {
|
|
59
|
+
const groups = new Map();
|
|
60
|
+
for (const list of lists) {
|
|
61
|
+
for (const mailbox of list) {
|
|
62
|
+
const key = mailboxKey(mailbox.address);
|
|
63
|
+
const spellings = groups.get(key);
|
|
64
|
+
if (spellings === undefined)
|
|
65
|
+
groups.set(key, [mailbox.address]);
|
|
66
|
+
else
|
|
67
|
+
spellings.push(mailbox.address);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const folded = [];
|
|
71
|
+
const variants = [];
|
|
72
|
+
let envelopeSize = 0;
|
|
73
|
+
for (const [key, spellings] of groups) {
|
|
74
|
+
const distinct = [...new Set(spellings)].sort(byString);
|
|
75
|
+
envelopeSize += distinct.length;
|
|
76
|
+
if (spellings.length > distinct.length)
|
|
77
|
+
folded.push(key);
|
|
78
|
+
if (distinct.length > 1)
|
|
79
|
+
variants.push(distinct);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
folded: folded.sort(byString),
|
|
83
|
+
// Sorted by first spelling, so two runs list the groups in one order.
|
|
84
|
+
variants: variants.sort((a, b) => byString(a[0], b[0])),
|
|
85
|
+
envelopeSize,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** `{{placeholder}}` names still standing in the text a recipient would read. */
|
|
89
|
+
export function leftoverPlaceholders(draft) {
|
|
90
|
+
const names = new Set();
|
|
91
|
+
for (const field of [draft.subject, draft.text, draft.html ?? ""]) {
|
|
92
|
+
for (const name of templatePlaceholders(field))
|
|
93
|
+
names.add(name);
|
|
94
|
+
}
|
|
95
|
+
return [...names].sort(byString);
|
|
96
|
+
}
|
|
97
|
+
function checkSender(draft) {
|
|
98
|
+
if (!isValidAddress(draft.from.address)) {
|
|
99
|
+
return {
|
|
100
|
+
check: "sender",
|
|
101
|
+
status: "fail",
|
|
102
|
+
// The address is echoed with its line breaks escaped: an unparseable
|
|
103
|
+
// From is usually unparseable for a reason worth seeing.
|
|
104
|
+
detail: `From "${draft.from.address.replace(/[\r\n]/g, "\\n")}" is not a deliverable addr-spec, so there is no sender and no domain to build a Message-ID from`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
check: "sender",
|
|
109
|
+
status: "pass",
|
|
110
|
+
detail: `From is ${draft.from.address}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function checkEnvelope(draft) {
|
|
114
|
+
const all = [...draft.to, ...draft.cc, ...draft.bcc];
|
|
115
|
+
if (all.length === 0) {
|
|
116
|
+
return {
|
|
117
|
+
check: "envelope",
|
|
118
|
+
status: "fail",
|
|
119
|
+
detail: "no recipients: to, cc and bcc are all empty, so there is no envelope to send",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const bad = all
|
|
123
|
+
.map((m) => m.address)
|
|
124
|
+
.filter((address) => !isValidAddress(address))
|
|
125
|
+
.map((address) => address.replace(/[\r\n]/g, "\\n"))
|
|
126
|
+
.sort(byString);
|
|
127
|
+
if (bad.length > 0) {
|
|
128
|
+
return {
|
|
129
|
+
check: "envelope",
|
|
130
|
+
status: "fail",
|
|
131
|
+
detail: `${bad.length} recipient address${bad.length === 1 ? " is" : "es are"} not deliverable: ${bad.join(", ")}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const replyToBad = draft.replyTo
|
|
135
|
+
.map((m) => m.address)
|
|
136
|
+
.filter((address) => !isValidAddress(address))
|
|
137
|
+
.map((address) => address.replace(/[\r\n]/g, "\\n"))
|
|
138
|
+
.sort(byString);
|
|
139
|
+
if (replyToBad.length > 0) {
|
|
140
|
+
return {
|
|
141
|
+
check: "envelope",
|
|
142
|
+
status: "fail",
|
|
143
|
+
detail: `reply-to is not deliverable: ${replyToBad.join(", ")}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const folding = envelopeFolding([draft.to, draft.cc, draft.bcc]);
|
|
147
|
+
const size = folding.envelopeSize;
|
|
148
|
+
const rcpt = `${size} envelope recipient${size === 1 ? "" : "s"}`;
|
|
149
|
+
// Said in the order of how wrong it can go. A spelling variant is the one
|
|
150
|
+
// that costs a real second delivery, so it is reported even when an exact
|
|
151
|
+
// repeat is also present - the caller fixes a different thing for each.
|
|
152
|
+
if (folding.variants.length > 0) {
|
|
153
|
+
const groups = folding.variants.map((group) => group.join(" and ")).join("; ");
|
|
154
|
+
return {
|
|
155
|
+
check: "envelope",
|
|
156
|
+
status: "warn",
|
|
157
|
+
detail: `${rcpt}: ${groups} differ only in case or surrounding space. EmailSend builds its envelope from the exact addresses, so each spelling gets its own RCPT TO and the mailbox is likely to receive one copy per spelling - list it once`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (folding.folded.length > 0) {
|
|
161
|
+
return {
|
|
162
|
+
check: "envelope",
|
|
163
|
+
status: "warn",
|
|
164
|
+
detail: `${rcpt}: ${folding.folded.join(", ")} ${folding.folded.length === 1 ? "is" : "are"} listed more than once across to/cc/bcc; identical spellings fold into one envelope entry, so one copy is sent, not two`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
check: "envelope",
|
|
169
|
+
status: "pass",
|
|
170
|
+
detail: `${rcpt} across to, cc and bcc`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function checkDate(draft) {
|
|
174
|
+
const when = new Date(draft.date);
|
|
175
|
+
if (Number.isNaN(when.getTime())) {
|
|
176
|
+
return {
|
|
177
|
+
check: "date",
|
|
178
|
+
status: "fail",
|
|
179
|
+
detail: `"${draft.date}" is not an instant - use ISO-8601 with an offset, e.g. 2026-09-17T09:30:00Z`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
check: "date",
|
|
184
|
+
status: "pass",
|
|
185
|
+
detail: `the Date header will read ${when.toISOString()}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function checkHeaders(draft) {
|
|
189
|
+
const faults = [];
|
|
190
|
+
for (const name of Object.keys(draft.headers ?? {})) {
|
|
191
|
+
if (!isValidHeaderName(name))
|
|
192
|
+
faults.push(`"${name}" is not a valid header name`);
|
|
193
|
+
else if (isReservedHeaderName(name)) {
|
|
194
|
+
faults.push(`"${name}" is built by the composer and cannot be overridden here`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (draft.inReplyTo !== undefined && draft.inReplyTo !== "" && !isMessageId(draft.inReplyTo)) {
|
|
198
|
+
faults.push("inReplyTo is not one message id in angle brackets");
|
|
199
|
+
}
|
|
200
|
+
for (const reference of draft.references ?? []) {
|
|
201
|
+
if (!isMessageId(reference)) {
|
|
202
|
+
faults.push(`references entry "${reference.replace(/[\r\n]/g, "\\n")}" is not a message id in angle brackets`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (faults.length > 0) {
|
|
206
|
+
// Every fault at once, sorted: the composer stops at the first, and
|
|
207
|
+
// finding them one send at a time is the experience this tool exists to
|
|
208
|
+
// replace.
|
|
209
|
+
return { check: "headers", status: "fail", detail: faults.sort(byString).join("; ") };
|
|
210
|
+
}
|
|
211
|
+
const extra = Object.keys(draft.headers ?? {}).length;
|
|
212
|
+
return {
|
|
213
|
+
check: "headers",
|
|
214
|
+
status: "pass",
|
|
215
|
+
detail: extra === 0 ? "no extra headers" : `${extra} extra header${extra === 1 ? "" : "s"}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function checkSubject(draft) {
|
|
219
|
+
if (isBlank(draft.subject)) {
|
|
220
|
+
return {
|
|
221
|
+
check: "subject",
|
|
222
|
+
status: "warn",
|
|
223
|
+
detail: "the Subject header is empty; it is legal, and it is also one of the things a spam filter scores",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { check: "subject", status: "pass", detail: `${draft.subject.length} characters` };
|
|
227
|
+
}
|
|
228
|
+
function checkBody(draft) {
|
|
229
|
+
const textBlank = isBlank(draft.text);
|
|
230
|
+
const htmlBlank = draft.html === undefined || isBlank(draft.html);
|
|
231
|
+
if (textBlank && htmlBlank) {
|
|
232
|
+
return {
|
|
233
|
+
check: "body",
|
|
234
|
+
status: "fail",
|
|
235
|
+
detail: "the message has no body: text is empty and there is no html alternative. A send would succeed and deliver nothing",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (textBlank) {
|
|
239
|
+
return {
|
|
240
|
+
check: "body",
|
|
241
|
+
status: "warn",
|
|
242
|
+
detail: "text is empty and only the html part carries anything; a client that shows text/plain shows an empty message",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
check: "body",
|
|
247
|
+
status: "pass",
|
|
248
|
+
detail: htmlBlank
|
|
249
|
+
? `${draft.text.length} characters of text`
|
|
250
|
+
: `${draft.text.length} characters of text and ${draft.html.length} of html`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function checkPlaceholders(draft) {
|
|
254
|
+
const left = leftoverPlaceholders(draft);
|
|
255
|
+
if (left.length > 0) {
|
|
256
|
+
return {
|
|
257
|
+
check: "placeholders",
|
|
258
|
+
status: "warn",
|
|
259
|
+
// MessageTemplate treats a missing key as an error for this reason;
|
|
260
|
+
// a message that reads "deploy of failed" still gets acted on.
|
|
261
|
+
detail: `still unfilled: ${left.map((n) => `{{${n}}}`).join(", ")} - if these came from a template, the render did not happen`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return { check: "placeholders", status: "pass", detail: "no unfilled {{placeholders}}" };
|
|
265
|
+
}
|
|
266
|
+
/** Every rule that needs nothing but the draft itself, in a fixed order. */
|
|
267
|
+
export function draftChecks(draft) {
|
|
268
|
+
return [
|
|
269
|
+
checkSender(draft),
|
|
270
|
+
checkEnvelope(draft),
|
|
271
|
+
checkDate(draft),
|
|
272
|
+
checkSubject(draft),
|
|
273
|
+
checkBody(draft),
|
|
274
|
+
checkHeaders(draft),
|
|
275
|
+
checkPlaceholders(draft),
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The one-word answer, from the rows.
|
|
280
|
+
*
|
|
281
|
+
* `incomplete` exists so a check that could not run cannot be mistaken for
|
|
282
|
+
* one that passed: a message whose attachment could not be read is not ready
|
|
283
|
+
* to send, and it is not blocked either - somebody has to look.
|
|
284
|
+
*/
|
|
285
|
+
export function verdictFrom(checks) {
|
|
286
|
+
if (checks.some((c) => c.status === "fail"))
|
|
287
|
+
return "blocked";
|
|
288
|
+
if (checks.some((c) => c.status === "unknown"))
|
|
289
|
+
return "incomplete";
|
|
290
|
+
return "ready";
|
|
291
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet hours: may this notification go out now, and if not, when?
|
|
3
|
+
*
|
|
4
|
+
* The rule this file exists to enforce is that a harness does not wake
|
|
5
|
+
* somebody at 03:00 for something that will read identically at 09:00. The
|
|
6
|
+
* rule it exists to NOT break is determinism: nothing here reads the clock.
|
|
7
|
+
* `now` is an argument, always, so the same question asked twice gets the
|
|
8
|
+
* same answer and a test can ask about a Sunday in a timezone nobody is in.
|
|
9
|
+
*
|
|
10
|
+
* Timezones come from `Intl`, which every runtime this ships on carries with
|
|
11
|
+
* the full IANA database. That means real DST behaviour rather than a fixed
|
|
12
|
+
* offset: a window of 22:00–07:00 in `America/New_York` is nine hours in
|
|
13
|
+
* November and eight on the spring-forward night, which is what the person
|
|
14
|
+
* asleep experiences.
|
|
15
|
+
*/
|
|
16
|
+
export type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
|
17
|
+
export type QuietWindow = {
|
|
18
|
+
/** Days the window STARTS on. Omitted means every day. */
|
|
19
|
+
readonly days?: readonly Weekday[];
|
|
20
|
+
/** `HH:MM`, 24-hour, in the schedule's timezone. */
|
|
21
|
+
readonly start: string;
|
|
22
|
+
/** `HH:MM`. Earlier than `start` means the window wraps past midnight. */
|
|
23
|
+
readonly end: string;
|
|
24
|
+
};
|
|
25
|
+
export type QuietSchedule = {
|
|
26
|
+
/** IANA name, e.g. `Europe/Berlin`. */
|
|
27
|
+
readonly timezone: string;
|
|
28
|
+
readonly quietWindows: readonly QuietWindow[];
|
|
29
|
+
/** `YYYY-MM-DD` dates that are quiet all day, in the schedule's timezone. */
|
|
30
|
+
readonly blackoutDates?: readonly string[];
|
|
31
|
+
};
|
|
32
|
+
export type QuietDecision = {
|
|
33
|
+
readonly allowed: boolean;
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
/** The instant the next non-quiet moment begins, ISO-8601 UTC. */
|
|
36
|
+
readonly nextAllowed?: string;
|
|
37
|
+
/** Local wall-clock time of `now` in the schedule's timezone. */
|
|
38
|
+
readonly localTime: string;
|
|
39
|
+
readonly localDate: string;
|
|
40
|
+
readonly localWeekday: Weekday;
|
|
41
|
+
};
|
|
42
|
+
export type QuietError = {
|
|
43
|
+
readonly error: string;
|
|
44
|
+
};
|
|
45
|
+
/** Minutes past local midnight, or `null` when `text` is not `HH:MM`. */
|
|
46
|
+
export declare function parseClock(text: string): number | null;
|
|
47
|
+
/** Validate a schedule up front, so a bad window is one error and not silence. */
|
|
48
|
+
export declare function validateSchedule(schedule: QuietSchedule): string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Decide whether a notification may be sent at `nowMs`, and when it may next
|
|
51
|
+
* be.
|
|
52
|
+
*
|
|
53
|
+
* The search is exact rather than sampled: quiet can only end at a window's
|
|
54
|
+
* end, or at local midnight when a blackout date rolls over, so those are
|
|
55
|
+
* the only instants examined. Nine days of them is enough to escape any
|
|
56
|
+
* weekly schedule; a schedule that blacks out more than nine consecutive
|
|
57
|
+
* days is reported as such rather than answered with a guess.
|
|
58
|
+
*/
|
|
59
|
+
export declare function quietDecision(schedule: QuietSchedule, nowMs: number): QuietDecision | QuietError;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet hours: may this notification go out now, and if not, when?
|
|
3
|
+
*
|
|
4
|
+
* The rule this file exists to enforce is that a harness does not wake
|
|
5
|
+
* somebody at 03:00 for something that will read identically at 09:00. The
|
|
6
|
+
* rule it exists to NOT break is determinism: nothing here reads the clock.
|
|
7
|
+
* `now` is an argument, always, so the same question asked twice gets the
|
|
8
|
+
* same answer and a test can ask about a Sunday in a timezone nobody is in.
|
|
9
|
+
*
|
|
10
|
+
* Timezones come from `Intl`, which every runtime this ships on carries with
|
|
11
|
+
* the full IANA database. That means real DST behaviour rather than a fixed
|
|
12
|
+
* offset: a window of 22:00–07:00 in `America/New_York` is nine hours in
|
|
13
|
+
* November and eight on the spring-forward night, which is what the person
|
|
14
|
+
* asleep experiences.
|
|
15
|
+
*/
|
|
16
|
+
const WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
17
|
+
const CLOCK = /^([0-9]{1,2}):([0-9]{2})$/;
|
|
18
|
+
/** Minutes past local midnight, or `null` when `text` is not `HH:MM`. */
|
|
19
|
+
export function parseClock(text) {
|
|
20
|
+
const match = text.trim().match(CLOCK);
|
|
21
|
+
if (match === null)
|
|
22
|
+
return null;
|
|
23
|
+
const hours = Number.parseInt(match[1], 10);
|
|
24
|
+
const minutes = Number.parseInt(match[2], 10);
|
|
25
|
+
if (hours > 24 || minutes > 59)
|
|
26
|
+
return null;
|
|
27
|
+
if (hours === 24 && minutes !== 0)
|
|
28
|
+
return null;
|
|
29
|
+
return hours * 60 + minutes;
|
|
30
|
+
}
|
|
31
|
+
/** A cached formatter per timezone — building one per call is the slow path. */
|
|
32
|
+
const formatters = new Map();
|
|
33
|
+
function formatterFor(timeZone) {
|
|
34
|
+
let formatter = formatters.get(timeZone);
|
|
35
|
+
if (formatter === undefined) {
|
|
36
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
37
|
+
timeZone,
|
|
38
|
+
hourCycle: "h23",
|
|
39
|
+
year: "numeric",
|
|
40
|
+
month: "2-digit",
|
|
41
|
+
day: "2-digit",
|
|
42
|
+
hour: "2-digit",
|
|
43
|
+
minute: "2-digit",
|
|
44
|
+
second: "2-digit",
|
|
45
|
+
});
|
|
46
|
+
formatters.set(timeZone, formatter);
|
|
47
|
+
}
|
|
48
|
+
return formatter;
|
|
49
|
+
}
|
|
50
|
+
/** The wall-clock reading of an instant in a timezone. */
|
|
51
|
+
function zonedParts(instant, timeZone) {
|
|
52
|
+
const parts = formatterFor(timeZone).formatToParts(new Date(instant));
|
|
53
|
+
const get = (type) => {
|
|
54
|
+
const found = parts.find((p) => p.type === type);
|
|
55
|
+
return found === undefined ? 0 : Number.parseInt(found.value, 10);
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
year: get("year"),
|
|
59
|
+
month: get("month"),
|
|
60
|
+
day: get("day"),
|
|
61
|
+
hour: get("hour"),
|
|
62
|
+
minute: get("minute"),
|
|
63
|
+
second: get("second"),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** The zone's UTC offset, in milliseconds, at a given instant. */
|
|
67
|
+
function offsetAt(instant, timeZone) {
|
|
68
|
+
const z = zonedParts(instant, timeZone);
|
|
69
|
+
return Date.UTC(z.year, z.month - 1, z.day, z.hour, z.minute, z.second) - instant;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The instant at which a zone reads a given wall clock.
|
|
73
|
+
*
|
|
74
|
+
* Two passes, which is the standard way to invert a zone lookup: guess with
|
|
75
|
+
* the offset at the naive instant, then correct with the offset at the
|
|
76
|
+
* guess. A wall time that a DST jump skipped entirely has no instant; the
|
|
77
|
+
* second pass lands on the moment the clock jumped, which is the earliest
|
|
78
|
+
* real time at or after the requested one, and that is the right answer for
|
|
79
|
+
* "when may I send".
|
|
80
|
+
*/
|
|
81
|
+
function instantFor(timeZone, year, month, day, minutesOfDay) {
|
|
82
|
+
const naive = Date.UTC(year, month - 1, day, 0, 0, 0) + minutesOfDay * 60_000;
|
|
83
|
+
let instant = naive - offsetAt(naive, timeZone);
|
|
84
|
+
instant = naive - offsetAt(instant, timeZone);
|
|
85
|
+
return instant;
|
|
86
|
+
}
|
|
87
|
+
function weekdayOf(z) {
|
|
88
|
+
const index = new Date(Date.UTC(z.year, z.month - 1, z.day)).getUTCDay();
|
|
89
|
+
return WEEKDAYS[index];
|
|
90
|
+
}
|
|
91
|
+
function pad(value, width) {
|
|
92
|
+
return String(value).padStart(width, "0");
|
|
93
|
+
}
|
|
94
|
+
function isoDate(z) {
|
|
95
|
+
return `${pad(z.year, 4)}-${pad(z.month, 2)}-${pad(z.day, 2)}`;
|
|
96
|
+
}
|
|
97
|
+
/** Validate a schedule up front, so a bad window is one error and not silence. */
|
|
98
|
+
export function validateSchedule(schedule) {
|
|
99
|
+
try {
|
|
100
|
+
formatterFor(schedule.timezone);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return `"${schedule.timezone}" is not a timezone this runtime knows — use an IANA name such as Europe/Berlin`;
|
|
104
|
+
}
|
|
105
|
+
if (schedule.quietWindows.length === 0 && (schedule.blackoutDates ?? []).length === 0) {
|
|
106
|
+
return "the schedule has no quiet windows and no blackout dates, so it would never block anything — omit it instead";
|
|
107
|
+
}
|
|
108
|
+
for (const window of schedule.quietWindows) {
|
|
109
|
+
if (parseClock(window.start) === null) {
|
|
110
|
+
return `quiet window start "${window.start}" is not HH:MM`;
|
|
111
|
+
}
|
|
112
|
+
if (parseClock(window.end) === null) {
|
|
113
|
+
return `quiet window end "${window.end}" is not HH:MM`;
|
|
114
|
+
}
|
|
115
|
+
if (parseClock(window.start) === parseClock(window.end)) {
|
|
116
|
+
return `quiet window "${window.start}"–"${window.end}" is empty; use 00:00–24:00 for a whole day`;
|
|
117
|
+
}
|
|
118
|
+
for (const day of window.days ?? []) {
|
|
119
|
+
if (!WEEKDAYS.includes(day))
|
|
120
|
+
return `"${day}" is not a weekday name (mon…sun)`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const date of schedule.blackoutDates ?? []) {
|
|
124
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
125
|
+
return `blackout date "${date}" is not YYYY-MM-DD`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/** Is `instant` inside a quiet window or a blackout date? */
|
|
131
|
+
function isQuiet(schedule, instant) {
|
|
132
|
+
const z = zonedParts(instant, schedule.timezone);
|
|
133
|
+
const date = isoDate(z);
|
|
134
|
+
if ((schedule.blackoutDates ?? []).includes(date))
|
|
135
|
+
return true;
|
|
136
|
+
const minutes = z.hour * 60 + z.minute;
|
|
137
|
+
const today = weekdayOf(z);
|
|
138
|
+
const yesterday = WEEKDAYS[(WEEKDAYS.indexOf(today) + 6) % 7];
|
|
139
|
+
for (const window of schedule.quietWindows) {
|
|
140
|
+
const start = parseClock(window.start);
|
|
141
|
+
const end = parseClock(window.end);
|
|
142
|
+
if (start === null || end === null)
|
|
143
|
+
continue;
|
|
144
|
+
const days = window.days;
|
|
145
|
+
const startsToday = days === undefined || days.includes(today);
|
|
146
|
+
const startedYesterday = days === undefined || days.includes(yesterday);
|
|
147
|
+
if (start < end) {
|
|
148
|
+
if (startsToday && minutes >= start && minutes < end)
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// Wraps past midnight: the tail belongs to the day the window started.
|
|
153
|
+
if (startsToday && minutes >= start)
|
|
154
|
+
return true;
|
|
155
|
+
if (startedYesterday && minutes < end)
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
/** Days ahead searched for the next allowed moment before giving up. */
|
|
162
|
+
const SEARCH_DAYS = 9;
|
|
163
|
+
/**
|
|
164
|
+
* Decide whether a notification may be sent at `nowMs`, and when it may next
|
|
165
|
+
* be.
|
|
166
|
+
*
|
|
167
|
+
* The search is exact rather than sampled: quiet can only end at a window's
|
|
168
|
+
* end, or at local midnight when a blackout date rolls over, so those are
|
|
169
|
+
* the only instants examined. Nine days of them is enough to escape any
|
|
170
|
+
* weekly schedule; a schedule that blacks out more than nine consecutive
|
|
171
|
+
* days is reported as such rather than answered with a guess.
|
|
172
|
+
*/
|
|
173
|
+
export function quietDecision(schedule, nowMs) {
|
|
174
|
+
const invalid = validateSchedule(schedule);
|
|
175
|
+
if (invalid !== null)
|
|
176
|
+
return { error: invalid };
|
|
177
|
+
const z = zonedParts(nowMs, schedule.timezone);
|
|
178
|
+
const local = {
|
|
179
|
+
localTime: `${pad(z.hour, 2)}:${pad(z.minute, 2)}`,
|
|
180
|
+
localDate: isoDate(z),
|
|
181
|
+
localWeekday: weekdayOf(z),
|
|
182
|
+
};
|
|
183
|
+
if (!isQuiet(schedule, nowMs)) {
|
|
184
|
+
return { allowed: true, reason: "not inside any quiet window", ...local };
|
|
185
|
+
}
|
|
186
|
+
const candidates = new Set();
|
|
187
|
+
for (let offset = 0; offset <= SEARCH_DAYS; offset++) {
|
|
188
|
+
const dayStart = new Date(Date.UTC(z.year, z.month - 1, z.day + offset));
|
|
189
|
+
const year = dayStart.getUTCFullYear();
|
|
190
|
+
const month = dayStart.getUTCMonth() + 1;
|
|
191
|
+
const day = dayStart.getUTCDate();
|
|
192
|
+
candidates.add(instantFor(schedule.timezone, year, month, day, 0));
|
|
193
|
+
for (const window of schedule.quietWindows) {
|
|
194
|
+
const end = parseClock(window.end);
|
|
195
|
+
if (end === null)
|
|
196
|
+
continue;
|
|
197
|
+
candidates.add(instantFor(schedule.timezone, year, month, day, end));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const next = [...candidates]
|
|
201
|
+
.filter((instant) => instant > nowMs)
|
|
202
|
+
.sort((a, b) => a - b)
|
|
203
|
+
.find((instant) => !isQuiet(schedule, instant));
|
|
204
|
+
if (next === undefined) {
|
|
205
|
+
return {
|
|
206
|
+
allowed: false,
|
|
207
|
+
reason: `inside a quiet window, and no allowed moment was found within ${SEARCH_DAYS} days — check the schedule, which may block continuously`,
|
|
208
|
+
...local,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
allowed: false,
|
|
213
|
+
reason: "inside a quiet window",
|
|
214
|
+
nextAllowed: new Date(next).toISOString(),
|
|
215
|
+
...local,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Have I already told them about this?" — as a pure function over a state
|
|
3
|
+
* record the caller keeps.
|
|
4
|
+
*
|
|
5
|
+
* A harness that notices the same broken thing on every loop iteration will
|
|
6
|
+
* notify about it on every loop iteration unless something remembers. That
|
|
7
|
+
* memory has to live somewhere the harness controls (a KV entry, a file, a
|
|
8
|
+
* field on its own state) rather than inside a tool, because a tool's
|
|
9
|
+
* process-local memory dies with the process and the flood comes back after
|
|
10
|
+
* the first restart. So this takes the state in and hands the next state
|
|
11
|
+
* back, and the caller persists it wherever its state already lives.
|
|
12
|
+
*
|
|
13
|
+
* Fixed windows, not a sliding log. A sliding window needs every timestamp
|
|
14
|
+
* retained, which grows without bound in exactly the situation the gate
|
|
15
|
+
* exists for; a fixed window costs two numbers per key and is easy to
|
|
16
|
+
* reason about out loud ("at most 3 per hour per key").
|
|
17
|
+
*/
|
|
18
|
+
export type RateEntry = {
|
|
19
|
+
/** Start of the current window, in epoch milliseconds. */
|
|
20
|
+
readonly windowStart: number;
|
|
21
|
+
/** How many sends have been recorded in that window. */
|
|
22
|
+
readonly count: number;
|
|
23
|
+
/** When the most recent send was recorded. */
|
|
24
|
+
readonly last: number;
|
|
25
|
+
};
|
|
26
|
+
export type RateState = Readonly<Record<string, RateEntry>>;
|
|
27
|
+
export type RateDecision = {
|
|
28
|
+
readonly allowed: boolean;
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
/** Sends recorded for this key in the current window, after this call. */
|
|
31
|
+
readonly count: number;
|
|
32
|
+
readonly limit: number;
|
|
33
|
+
/** Milliseconds until the window resets. Present only when refused. */
|
|
34
|
+
readonly retryAfterMs?: number;
|
|
35
|
+
/** When the window resets, ISO-8601 UTC. Present only when refused. */
|
|
36
|
+
readonly retryAt?: string;
|
|
37
|
+
/** The record to persist. Unchanged from the input when refused. */
|
|
38
|
+
readonly nextState: RateState;
|
|
39
|
+
/** Keys dropped because their window had long expired. */
|
|
40
|
+
readonly pruned: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
export type RateOptions = {
|
|
43
|
+
readonly key: string;
|
|
44
|
+
readonly nowMs: number;
|
|
45
|
+
readonly windowMs: number;
|
|
46
|
+
/** How many sends the window allows. Default 1 — "tell me once". */
|
|
47
|
+
readonly limit?: number;
|
|
48
|
+
/**
|
|
49
|
+
* `"consume"` records the send when it is allowed; `"peek"` answers the
|
|
50
|
+
* question without changing anything, for a caller that wants to decide
|
|
51
|
+
* before it has built the message.
|
|
52
|
+
*/
|
|
53
|
+
readonly mode?: "consume" | "peek";
|
|
54
|
+
readonly state?: RateState;
|
|
55
|
+
};
|
|
56
|
+
/** Decide, and hand back the record to persist. */
|
|
57
|
+
export declare function rateLimitGate(options: RateOptions): RateDecision;
|