@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.
@@ -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;