@getvitops/utils 8.0.1 → 8.2.0-alpha.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/CHANGELOG.md +289 -8
- package/dist/{adc-BFrzpMHq.d.mts → adc-BoEoogyo.d.mts} +2 -3
- package/dist/adc-BoEoogyo.d.mts.map +1 -0
- package/dist/ads/index.d.mts +50 -17
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +77 -8
- package/dist/ads/index.mjs.map +1 -1
- package/dist/cloudflare-BiK61Oq4.d.mts.map +1 -1
- package/dist/{cloudflare-CgAsXiqe.mjs → cloudflare-CD8vwrgj.mjs} +2 -24
- package/dist/cloudflare-CD8vwrgj.mjs.map +1 -0
- package/dist/color/index.d.mts +34 -35
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +3 -1
- package/dist/color/index.mjs.map +1 -1
- package/dist/crm/index.d.mts +288 -0
- package/dist/crm/index.d.mts.map +1 -0
- package/dist/crm/index.mjs +731 -0
- package/dist/crm/index.mjs.map +1 -0
- package/dist/email/index.d.mts +216 -0
- package/dist/email/index.d.mts.map +1 -0
- package/dist/email/index.mjs +331 -0
- package/dist/email/index.mjs.map +1 -0
- package/dist/favicon.d.mts +8 -9
- package/dist/favicon.d.mts.map +1 -1
- package/dist/git/index.d.mts +65 -0
- package/dist/git/index.d.mts.map +1 -0
- package/dist/git/index.mjs +107 -0
- package/dist/git/index.mjs.map +1 -0
- package/dist/hosting/index.d.mts +184 -0
- package/dist/hosting/index.d.mts.map +1 -0
- package/dist/hosting/index.mjs +553 -0
- package/dist/hosting/index.mjs.map +1 -0
- package/dist/identity-BoRddTx3.mjs +35 -0
- package/dist/identity-BoRddTx3.mjs.map +1 -0
- package/dist/index.d.mts +24 -25
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +23 -24
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/media/index.d.mts +24 -24
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +3 -3
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +26 -15
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +50 -20
- package/dist/notify/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +10 -141
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +2 -144
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/plan-BLKikoOG.mjs +150 -0
- package/dist/plan-BLKikoOG.mjs.map +1 -0
- package/dist/plan-Hq8Pb0Uw.d.mts +133 -0
- package/dist/plan-Hq8Pb0Uw.d.mts.map +1 -0
- package/dist/plan-format-A18SvJ7C.mjs +24 -0
- package/dist/plan-format-A18SvJ7C.mjs.map +1 -0
- package/dist/reviews/index.d.mts +12 -13
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/schema/index.d.mts +7 -7
- package/dist/schema/index.d.mts.map +1 -1
- package/dist/security/index.d.mts +2 -2
- package/dist/security/index.d.mts.map +1 -1
- package/dist/{token-Bz_-igw-.d.mts → token-DmmoQ7um.d.mts} +6 -3
- package/dist/token-DmmoQ7um.d.mts.map +1 -0
- package/dist/tracking/index.d.mts +43 -17
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +2 -1
- package/dist/tracking/index.mjs.map +1 -1
- package/dist/{types-CHHjzRrv.d.mts → types-7ELci8RQ.d.mts} +4 -2
- package/dist/types-7ELci8RQ.d.mts.map +1 -0
- package/package.json +18 -4
- package/dist/adc-BFrzpMHq.d.mts.map +0 -1
- package/dist/cloudflare-CgAsXiqe.mjs.map +0 -1
- package/dist/token-Bz_-igw-.d.mts.map +0 -1
- package/dist/types-CHHjzRrv.d.mts.map +0 -1
package/dist/notify/index.d.mts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { t as ConversionEvent } from "../types-
|
|
2
|
-
|
|
1
|
+
import { t as ConversionEvent } from "../types-7ELci8RQ.mjs";
|
|
3
2
|
//#region src/notify/types.d.ts
|
|
4
3
|
/**
|
|
5
4
|
* The notification module's option surface.
|
|
@@ -44,6 +43,13 @@ interface EmailChannelConfig {
|
|
|
44
43
|
interface NotificationsConfig {
|
|
45
44
|
/** A bare address is shorthand for `{ provider: 'cloudflare', to }`. */
|
|
46
45
|
email?: string | EmailChannelConfig | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Optional action-link template added to the conversion email as an
|
|
48
|
+
* "Actions" section — `{clickId}` is replaced with the ad click ID captured
|
|
49
|
+
* on `_ac` (gclid, fbclid, …). Omitted from the email when the event carries
|
|
50
|
+
* no click ID, rather than rendering a broken link — see `renderEmail`.
|
|
51
|
+
*/
|
|
52
|
+
actionUrl?: string | undefined;
|
|
47
53
|
}
|
|
48
54
|
/** Everything `planNotifications` needs. Assembled by the caller from the config. */
|
|
49
55
|
interface NotifyContext {
|
|
@@ -109,9 +115,9 @@ interface EmailBinding {
|
|
|
109
115
|
//#endregion
|
|
110
116
|
//#region src/notify/plan.d.ts
|
|
111
117
|
/** Default Workers binding name for Cloudflare Email Sending. */
|
|
112
|
-
declare const DEFAULT_EMAIL_BINDING = "EMAIL";
|
|
118
|
+
export declare const DEFAULT_EMAIL_BINDING = "EMAIL";
|
|
113
119
|
/** Normalise the `email` shorthand: a bare address means a Cloudflare send to it. */
|
|
114
|
-
declare function normalizeEmailChannel(input: string | EmailChannelConfig | undefined): EmailChannelConfig | undefined;
|
|
120
|
+
export declare function normalizeEmailChannel(input: string | EmailChannelConfig | undefined): EmailChannelConfig | undefined;
|
|
115
121
|
/**
|
|
116
122
|
* Who the notification reaches.
|
|
117
123
|
*
|
|
@@ -119,7 +125,7 @@ declare function normalizeEmailChannel(input: string | EmailChannelConfig | unde
|
|
|
119
125
|
* addresses, primary first. This cascade is why a site that has already said
|
|
120
126
|
* where it receives mail doesn't have to say it twice.
|
|
121
127
|
*/
|
|
122
|
-
declare function resolveRecipient(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined;
|
|
128
|
+
export declare function resolveRecipient(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined;
|
|
123
129
|
/**
|
|
124
130
|
* The From address.
|
|
125
131
|
*
|
|
@@ -127,14 +133,14 @@ declare function resolveRecipient(channel: EmailChannelConfig, ctx: NotifyContex
|
|
|
127
133
|
* site controls — Cloudflare rejects anything else with `E_SENDER_NOT_VERIFIED`,
|
|
128
134
|
* and guessing from the recipient's domain would send that failure every time.
|
|
129
135
|
*/
|
|
130
|
-
declare function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined;
|
|
136
|
+
export declare function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined;
|
|
131
137
|
/**
|
|
132
138
|
* Decide what will be sent, and say why anything won't be.
|
|
133
139
|
*
|
|
134
140
|
* Every "off" carries a reason rather than being an absence, so a caller can
|
|
135
141
|
* report the misconfiguration instead of reporting success over a no-op.
|
|
136
142
|
*/
|
|
137
|
-
declare function planNotifications(ctx: NotifyContext): NotificationPlan;
|
|
143
|
+
export declare function planNotifications(ctx: NotifyContext): NotificationPlan;
|
|
138
144
|
//#endregion
|
|
139
145
|
//#region src/notify/render.d.ts
|
|
140
146
|
/** One section: a heading and its lines. Empty sections are dropped by the caller. */
|
|
@@ -148,12 +154,15 @@ interface Section {
|
|
|
148
154
|
* Extracted so the text and HTML versions cannot disagree about content. They
|
|
149
155
|
* differ only in markup; a field added here appears in both.
|
|
150
156
|
*/
|
|
151
|
-
declare function describeEvent(event: ConversionEvent): {
|
|
157
|
+
export declare function describeEvent(event: ConversionEvent): {
|
|
152
158
|
subject: string;
|
|
153
159
|
sections: Section[];
|
|
154
160
|
};
|
|
155
161
|
/** Render for e-mail. Both halves, always — see `RenderedMessage`. */
|
|
156
|
-
declare function renderEmail(event: ConversionEvent
|
|
162
|
+
export declare function renderEmail(event: ConversionEvent, extra?: {
|
|
163
|
+
notes?: string[] | undefined;
|
|
164
|
+
actionUrl?: string | undefined;
|
|
165
|
+
}): RenderedMessage;
|
|
157
166
|
//#endregion
|
|
158
167
|
//#region src/notify/email.d.ts
|
|
159
168
|
interface SendEmailOptions {
|
|
@@ -183,7 +192,7 @@ interface SendEmailOptions {
|
|
|
183
192
|
* response because our mail didn't go out would turn a lost notification into a
|
|
184
193
|
* lost conversion. The failure is returned instead, for the caller to log.
|
|
185
194
|
*/
|
|
186
|
-
declare function sendEmail(plan: EmailPlan, message: RenderedMessage, binding: EmailBinding, options?: SendEmailOptions): Promise<ChannelResult>;
|
|
195
|
+
export declare function sendEmail(plan: EmailPlan, message: RenderedMessage, binding: EmailBinding, options?: SendEmailOptions): Promise<ChannelResult>;
|
|
187
196
|
/**
|
|
188
197
|
* Print the message instead of sending it.
|
|
189
198
|
*
|
|
@@ -191,17 +200,17 @@ declare function sendEmail(plan: EmailPlan, message: RenderedMessage, binding: E
|
|
|
191
200
|
* no-op, because "I never saw the notification" is the symptom of both a broken
|
|
192
201
|
* integration and a working one nobody could observe.
|
|
193
202
|
*/
|
|
194
|
-
declare function logEmail(plan: EmailPlan, message: RenderedMessage, log?: (line: string) => void): ChannelResult;
|
|
203
|
+
export declare function logEmail(plan: EmailPlan, message: RenderedMessage, log?: (line: string) => void): ChannelResult;
|
|
195
204
|
//#endregion
|
|
196
205
|
//#region src/notify/index.d.ts
|
|
197
|
-
interface NotifyDeps {
|
|
206
|
+
export interface NotifyDeps {
|
|
198
207
|
/**
|
|
199
208
|
* The Workers `send_email` binding (`env.EMAIL`). Omit it and the notification
|
|
200
209
|
* is printed instead — the dev path.
|
|
201
210
|
*/
|
|
202
211
|
email?: EmailBinding | undefined;
|
|
203
212
|
}
|
|
204
|
-
interface NotifyResult {
|
|
213
|
+
export interface NotifyResult {
|
|
205
214
|
results: ChannelResult[];
|
|
206
215
|
/** True when at least one channel delivered. */
|
|
207
216
|
delivered: boolean;
|
|
@@ -213,7 +222,9 @@ interface NotifyResult {
|
|
|
213
222
|
* them may throw into the request that produced the event. Every outcome —
|
|
214
223
|
* delivered, skipped, failed — comes back as data for the caller to log.
|
|
215
224
|
*/
|
|
216
|
-
declare function notify(event: ConversionEvent, ctx: NotifyContext, deps?: NotifyDeps
|
|
225
|
+
export declare function notify(event: ConversionEvent, ctx: NotifyContext, deps?: NotifyDeps, extra?: {
|
|
226
|
+
notes?: string[] | undefined;
|
|
227
|
+
}): Promise<NotifyResult>;
|
|
217
228
|
//#endregion
|
|
218
|
-
export {
|
|
229
|
+
export type { ChannelPlan, ChannelResult, EmailBinding, EmailChannelConfig, EmailPlan, NotificationPlan, NotificationsConfig, NotifyContext, RenderedMessage };
|
|
219
230
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/notify/types.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/email.ts","../../src/notify/index.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/notify/types.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/email.ts","../../src/notify/index.ts"],"mappings":";;;;;;;;;;;;;;;;UAeiB;EACf;EACA;EACA;;;;;;;;UASe;EACf;;EAEA;;EAEA;;EAEA;EACA;;;;;EAKA;;UAGe;;EAEf,iBAAiB;;;;;;;EAOjB;;;UAOe;EACf,gBAAgB;;EAEhB;;EAEA;;;;;EAKA;;;UAIe;EACf;;EAEA;;UAGe,kBAAkB;EACjC;EACA;EACA;EACA;EACA;EACA;;UAGe;EACf,OAAO;;EAEP;;UAGe;EACf;EACA;;EAEA;;EAEA;;;;;;;;;;UAWe;EACf,KAAK;IACH;IACA;MAAiB;MAAe;;IAChC;IACA;IACA;IACA;MACE;;;;;qBCrGO;;wBAGG,sBACd,gBAAgB,iCACf;;;;;;;;wBAYa,iBACd,SAAS,oBACT,KAAK;;;;;;;;wBAaS,cAAc,SAAS,oBAAoB,KAAK;;;;;;;wBAmBhD,kBAAkB,KAAK,gBAAgB;;;;UC7B7C;EACR;EACA;;;;;;;;wBAyDc,cAAc,OAAO;EAAoB;EAAiB,UAAU;;;wBA4DpE,YACd,OAAO,iBACP;EAAS;EAA8B;IACtC;;;UCxHc;;EAEf;;;;;;;;;;;;;EAaA;;EAEA,SAAS,eAAe;;;;;;;;;;wBA0CJ,UACpB,MAAM,WACN,SAAS,iBACT,SAAS,cACT,UAAS,mBACR,QAAQ;;;;;;;;wBAkDK,SACd,MAAM,WACN,SAAS,iBACT,OAAM,wBACL;;;iBC/Hc;;;;;EAKf,QAAQ;;iBAGO;EACf,SAAS;;EAET;;;;;;;;;wBAUoB,OACpB,OAAO,iBACP,KAAK,eACL,OAAM,YACN;EAAS;IACR,QAAQ"}
|
package/dist/notify/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t as identifySubmitter } from "../identity-BoRddTx3.mjs";
|
|
1
2
|
import { n as identifyPlatform, t as getPrimaryClickId } from "../platform-0nlOIy8h.mjs";
|
|
2
3
|
|
|
3
4
|
//#region src/notify/email.ts
|
|
@@ -8,7 +9,7 @@ import { n as identifyPlatform, t as getPrimaryClickId } from "../platform-0nlOI
|
|
|
8
9
|
* payload, a suppressed recipient — where a retry is pure latency and the send
|
|
9
10
|
* will fail identically. Retrying those also burns the daily quota.
|
|
10
11
|
*/
|
|
11
|
-
const RETRYABLE = new Set([
|
|
12
|
+
const RETRYABLE = /* @__PURE__ */ new Set([
|
|
12
13
|
"E_RATE_LIMIT_EXCEEDED",
|
|
13
14
|
"E_DELIVERY_FAILED",
|
|
14
15
|
"E_INTERNAL_SERVER_ERROR"
|
|
@@ -230,7 +231,7 @@ function planEmail(ctx) {
|
|
|
230
231
|
* they submitted is untrue. The same rule `legal/derive.ts` applies when deriving
|
|
231
232
|
* `piiCollected`, and for the same reason. Callers may strip more.
|
|
232
233
|
*/
|
|
233
|
-
const OMIT_FIELDS = new Set([
|
|
234
|
+
const OMIT_FIELDS = /* @__PURE__ */ new Set([
|
|
234
235
|
"website",
|
|
235
236
|
"_honeypot",
|
|
236
237
|
"honeypot",
|
|
@@ -242,6 +243,26 @@ function label(key) {
|
|
|
242
243
|
function escapeHtml(value) {
|
|
243
244
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
244
245
|
}
|
|
246
|
+
function factLine([label, value]) {
|
|
247
|
+
return {
|
|
248
|
+
label,
|
|
249
|
+
text: value,
|
|
250
|
+
html: escapeHtml(value)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function linkLine(label, href) {
|
|
254
|
+
return {
|
|
255
|
+
label,
|
|
256
|
+
text: href,
|
|
257
|
+
html: `<a href="${escapeHtml(href)}">${escapeHtml(href)}</a>`
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function toRenderSection(section) {
|
|
261
|
+
return {
|
|
262
|
+
heading: section.heading,
|
|
263
|
+
lines: section.lines.map(factLine)
|
|
264
|
+
};
|
|
265
|
+
}
|
|
245
266
|
/**
|
|
246
267
|
* Who submitted the form, for the subject line.
|
|
247
268
|
*
|
|
@@ -253,18 +274,7 @@ function escapeHtml(value) {
|
|
|
253
274
|
* even when no name field exists.
|
|
254
275
|
*/
|
|
255
276
|
function submitterName(formData) {
|
|
256
|
-
|
|
257
|
-
for (const k of keys) {
|
|
258
|
-
const v = formData[k]?.trim();
|
|
259
|
-
if (v) return v;
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
const full = get("name", "full_name", "fullName", "fullname", "your-name");
|
|
263
|
-
if (full) return full;
|
|
264
|
-
const first = get("first_name", "firstName", "firstname", "given_name");
|
|
265
|
-
const last = get("last_name", "lastName", "lastname", "family_name", "surname");
|
|
266
|
-
if (first || last) return [first, last].filter(Boolean).join(" ");
|
|
267
|
-
return get("email", "email_address", "emailAddress") ?? "Unknown";
|
|
277
|
+
return identifySubmitter(formData).fullName;
|
|
268
278
|
}
|
|
269
279
|
/**
|
|
270
280
|
* The event as sections — the shared structure behind both renderings.
|
|
@@ -281,6 +291,10 @@ function describeEvent(event) {
|
|
|
281
291
|
if (event.type === "form" && event.formData) {
|
|
282
292
|
subject = `[Contact Form] New submission from ${submitterName(event.formData)}`;
|
|
283
293
|
for (const [key, value] of Object.entries(event.formData)) if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);
|
|
294
|
+
} else if (event.type === "email") {
|
|
295
|
+
const email = event.email ?? "unknown";
|
|
296
|
+
subject = `[Email Click] Visitor emailing ${email}`;
|
|
297
|
+
contact.push(["Email", email]);
|
|
284
298
|
} else {
|
|
285
299
|
const phone = event.phone ?? "unknown";
|
|
286
300
|
subject = `[Phone Call] Visitor calling ${phone}`;
|
|
@@ -329,12 +343,25 @@ function describeEvent(event) {
|
|
|
329
343
|
};
|
|
330
344
|
}
|
|
331
345
|
/** Render for e-mail. Both halves, always — see `RenderedMessage`. */
|
|
332
|
-
function renderEmail(event) {
|
|
333
|
-
const { subject, sections } = describeEvent(event);
|
|
346
|
+
function renderEmail(event, extra = {}) {
|
|
347
|
+
const { subject, sections: eventSections } = describeEvent(event);
|
|
348
|
+
const clickId = event.tracking ? getPrimaryClickId(event.tracking) : null;
|
|
349
|
+
const actionSection = extra.actionUrl && clickId ? {
|
|
350
|
+
heading: "Actions",
|
|
351
|
+
lines: [linkLine("View in ad platform", extra.actionUrl.replace(/\{clickId\}/g, encodeURIComponent(clickId.value)))]
|
|
352
|
+
} : null;
|
|
353
|
+
const sections = [
|
|
354
|
+
...eventSections.map(toRenderSection),
|
|
355
|
+
...actionSection ? [actionSection] : [],
|
|
356
|
+
...extra.notes && extra.notes.length ? [{
|
|
357
|
+
heading: "Delivery",
|
|
358
|
+
lines: extra.notes.map((n) => factLine(["CRM", n]))
|
|
359
|
+
}] : []
|
|
360
|
+
];
|
|
334
361
|
return {
|
|
335
362
|
subject,
|
|
336
|
-
text: sections.map((s) => [`--- ${s.heading} ---`, ...s.lines.map((
|
|
337
|
-
html: sections.map((s) => `<h2 style="font:600 14px system-ui;margin:24px 0 8px">${escapeHtml(s.heading)}</h2><table style="font:14px system-ui;border-collapse:collapse">${s.lines.map((
|
|
363
|
+
text: sections.map((s) => [`--- ${s.heading} ---`, ...s.lines.map((l) => `${l.label}: ${l.text}`)].join("\n")).join("\n\n"),
|
|
364
|
+
html: sections.map((s) => `<h2 style="font:600 14px system-ui;margin:24px 0 8px">${escapeHtml(s.heading)}</h2><table style="font:14px system-ui;border-collapse:collapse">${s.lines.map((l) => `<tr><td style="padding:2px 12px 2px 0;color:#666;vertical-align:top">${escapeHtml(l.label)}</td><td style="padding:2px 0">${l.html}</td></tr>`).join("")}</table>`).join("")
|
|
338
365
|
};
|
|
339
366
|
}
|
|
340
367
|
|
|
@@ -347,11 +374,14 @@ function renderEmail(event) {
|
|
|
347
374
|
* them may throw into the request that produced the event. Every outcome —
|
|
348
375
|
* delivered, skipped, failed — comes back as data for the caller to log.
|
|
349
376
|
*/
|
|
350
|
-
async function notify(event, ctx, deps = {}) {
|
|
377
|
+
async function notify(event, ctx, deps = {}, extra = {}) {
|
|
351
378
|
const plan = planNotifications(ctx);
|
|
352
379
|
const results = [];
|
|
353
380
|
if (plan.email.enabled) {
|
|
354
|
-
const message = renderEmail(event
|
|
381
|
+
const message = renderEmail(event, {
|
|
382
|
+
...extra,
|
|
383
|
+
actionUrl: ctx.notifications?.actionUrl
|
|
384
|
+
});
|
|
355
385
|
results.push(deps.email ? await sendEmail(plan.email, message, deps.email) : logEmail(plan.email, message));
|
|
356
386
|
} else results.push({
|
|
357
387
|
channel: "email",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/notify/email.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/index.ts"],"sourcesContent":["/**\n * The e-mail channel — Cloudflare Email Sending.\n *\n * I/O only: it executes an `EmailPlan` and decides nothing. Whether to send, to\n * whom, and from where were all settled in `plan.ts`.\n *\n * **The binding is passed in, never imported.** `@getvitops/utils` does not depend\n * on `cloudflare:workers`, so this module runs in Node and in a test with a stub,\n * and the Astro package gains no Cloudflare coupling. The consumer's route hands\n * over `env.EMAIL`.\n *\n * Uses the current Email Sending binding — a structured `send({ to, from,\n * subject, html, text })` — not the legacy `EmailMessage` + hand-built MIME path.\n * The domain in `from` must have been onboarded (`wrangler email sending enable\n * <domain>`); nothing here can check that, which is why `E_SENDER_NOT_VERIFIED`\n * is surfaced verbatim rather than collapsed into \"send failed\".\n */\nimport type { RenderedMessage } from './types.ts';\nimport type { ChannelResult, EmailBinding, EmailPlan } from './types.ts';\n\n/**\n * Provider errors worth retrying.\n *\n * Everything else is a configuration fault — an unverified sender, a malformed\n * payload, a suppressed recipient — where a retry is pure latency and the send\n * will fail identically. Retrying those also burns the daily quota.\n */\nconst RETRYABLE = new Set([\n 'E_RATE_LIMIT_EXCEEDED',\n 'E_DELIVERY_FAILED',\n 'E_INTERNAL_SERVER_ERROR',\n]);\n\nfunction codeOf(error: unknown): string | undefined {\n const code = (error as { code?: unknown } | null)?.code;\n return typeof code === 'string' ? code : undefined;\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nexport interface SendEmailOptions {\n /** Attempts for a retryable failure (default 3). */\n attempts?: number;\n /**\n * Per-attempt ceiling in milliseconds (default 10000; `0` disables).\n *\n * `binding.send()` is a network call with no deadline of its own, so without\n * this one hung attempt hangs the request that produced it — forever, in the\n * one module otherwise built on always saying why. A conversion endpoint\n * cannot be the thing that makes a form submission time out, so the bound\n * belongs here rather than in every consumer's wrapper.\n *\n * A timeout is treated as retryable: it is a transient condition by\n * definition, unlike `E_SENDER_NOT_VERIFIED` and friends.\n */\n timeoutMs?: number;\n /** Injected for tests; real callers let it default to a real delay. */\n sleep?: (ms: number) => Promise<void>;\n}\n\nconst wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));\n\n/** Marker for a send that exceeded its deadline, so the retry rule can see it. */\nconst TIMEOUT_CODE = 'E_SEND_TIMEOUT';\n\n/**\n * Race a send against its deadline.\n *\n * The timer is always cleared — a pending `setTimeout` keeps a Worker's event\n * loop alive past the response, which turns a bounded send into an unbounded\n * invocation and bills for the difference.\n */\nasync function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {\n if (ms <= 0) return work;\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n work,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n const err = new Error(`Email send exceeded ${ms}ms.`) as Error & { code: string };\n err.code = TIMEOUT_CODE;\n reject(err);\n }, ms);\n }),\n ]);\n } finally {\n if (timer !== undefined) clearTimeout(timer);\n }\n}\n\n/**\n * Send one rendered message according to a plan.\n *\n * Never throws: a failed notification must not take down the request that\n * produced it. The visitor has already submitted their form, and failing their\n * response because our mail didn't go out would turn a lost notification into a\n * lost conversion. The failure is returned instead, for the caller to log.\n */\nexport async function sendEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n binding: EmailBinding,\n options: SendEmailOptions = {},\n): Promise<ChannelResult> {\n if (!plan.enabled || !plan.to || !plan.from) {\n return { channel: 'email', sent: false, reason: plan.skip ?? 'Channel not enabled.' };\n }\n\n const attempts = Math.max(1, options.attempts ?? 3);\n const sleep = options.sleep ?? wait;\n const timeoutMs = options.timeoutMs ?? 10_000;\n\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n await withTimeout(\n binding.send({\n to: plan.to,\n from: plan.fromName ? { email: plan.from, name: plan.fromName } : { email: plan.from },\n subject: message.subject,\n text: message.text,\n html: message.html,\n ...(plan.replyTo ? { replyTo: plan.replyTo } : {}),\n }),\n timeoutMs,\n );\n return { channel: 'email', sent: true };\n } catch (error) {\n const code = codeOf(error);\n if (attempt === attempts || !code || !(RETRYABLE.has(code) || code === TIMEOUT_CODE)) {\n return {\n channel: 'email',\n sent: false,\n reason: messageOf(error),\n ...(code ? { code } : {}),\n };\n }\n // Exponential backoff. Cloudflare's rate limit is per-account, so a burst\n // of conversions retrying in lockstep would keep colliding.\n await sleep(2 ** (attempt - 1) * 250);\n }\n }\n\n /* c8 ignore next -- the loop always returns; this satisfies the type checker. */\n return { channel: 'email', sent: false, reason: 'Exhausted attempts.' };\n}\n\n/**\n * Print the message instead of sending it.\n *\n * The dev path, for a machine with no binding. Explicit rather than a silent\n * no-op, because \"I never saw the notification\" is the symptom of both a broken\n * integration and a working one nobody could observe.\n */\nexport function logEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n log: (line: string) => void = console.log,\n): ChannelResult {\n log(\n [\n '\\n========== NOTIFICATION EMAIL (not sent — no binding) ==========',\n `To: ${plan.to ?? '(unresolved)'}`,\n `From: ${plan.from ?? '(unresolved)'}`,\n `Subject: ${message.subject}`,\n '---',\n message.text,\n '================================================================\\n',\n ].join('\\n'),\n );\n return { channel: 'email', sent: false, reason: 'No binding — logged to the console.' };\n}\n","/**\n * Every decision a notification makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no binding, no clock. Whether a\n * channel fires, who it reaches, and *why* it was skipped are all decided here and\n * asserted in `plan.test.ts`; the senders beside this file do what they are told\n * and decide nothing. That is the same split `indexing/plan.ts` makes against\n * `gsc.ts`, and `@getvitops/core`'s consent store makes against its DOM wiring,\n * for the same reason: the consequential logic has to be testable without the\n * environment it runs in.\n *\n * The practical payoff is that a misconfigured site can be told exactly what will\n * and won't happen without sending anything — a silently unsent conversion\n * notification is indistinguishable from no conversion.\n */\nimport type { EmailChannelConfig, EmailPlan, NotificationPlan, NotifyContext } from './types.ts';\n\n/** Default Workers binding name for Cloudflare Email Sending. */\nexport const DEFAULT_EMAIL_BINDING = 'EMAIL';\n\n/** Normalise the `email` shorthand: a bare address means a Cloudflare send to it. */\nexport function normalizeEmailChannel(\n input: string | EmailChannelConfig | undefined,\n): EmailChannelConfig | undefined {\n if (input === undefined) return undefined;\n return typeof input === 'string' ? { provider: 'cloudflare', to: input } : input;\n}\n\n/**\n * Who the notification reaches.\n *\n * An explicit recipient wins; otherwise it falls to the site's own contact\n * addresses, primary first. This cascade is why a site that has already said\n * where it receives mail doesn't have to say it twice.\n */\nexport function resolveRecipient(\n channel: EmailChannelConfig,\n ctx: NotifyContext,\n): string | undefined {\n if (channel.to) return channel.to;\n return ctx.locationEmails?.find((e) => !!e);\n}\n\n/**\n * The From address.\n *\n * `noreply@<canonical>` by default, because the sending domain has to be one the\n * site controls — Cloudflare rejects anything else with `E_SENDER_NOT_VERIFIED`,\n * and guessing from the recipient's domain would send that failure every time.\n */\nexport function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined {\n if (channel.from) return channel.from;\n if (!ctx.canonical) return undefined;\n let host: string;\n try {\n host = new URL(ctx.canonical).hostname;\n } catch {\n // A bare hostname is a legitimate way to write `domains.canonical`.\n host = ctx.canonical.replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');\n }\n return host ? `noreply@${host}` : undefined;\n}\n\n/**\n * Decide what will be sent, and say why anything won't be.\n *\n * Every \"off\" carries a reason rather than being an absence, so a caller can\n * report the misconfiguration instead of reporting success over a no-op.\n */\nexport function planNotifications(ctx: NotifyContext): NotificationPlan {\n const email = planEmail(ctx);\n return { email, empty: !email.enabled };\n}\n\nfunction planEmail(ctx: NotifyContext): EmailPlan {\n const channel = normalizeEmailChannel(ctx.notifications?.email);\n const binding = channel?.binding ?? DEFAULT_EMAIL_BINDING;\n\n if (!channel) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip: 'No `notifications.email` configured.',\n };\n }\n\n const to = resolveRecipient(channel, ctx);\n if (!to) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip:\n 'No recipient: set `notifications.email.to`, or give a location an `email` ' +\n 'so the notification has somewhere to go.',\n };\n }\n\n const from = resolveSender(channel, ctx);\n if (!from) {\n return {\n provider: 'cloudflare',\n binding,\n to,\n enabled: false,\n skip:\n 'No sender: set `notifications.email.from`, or `domains.canonical` so ' +\n '`noreply@<host>` can be derived. Cloudflare only sends from a domain you have onboarded.',\n };\n }\n\n return {\n provider: 'cloudflare',\n binding,\n to,\n from,\n fromName: channel.fromName ?? ctx.organizationName,\n replyTo: channel.replyTo,\n enabled: true,\n };\n}\n","/**\n * Turning a `ConversionEvent` into something a person reads.\n *\n * Pure, and separate from both the plan and the sender, because rendering is the\n * part that differs per channel: an SMS channel will render the same event in 160\n * characters, a portal log as a row. The event stays the fact; only this file has\n * an opinion about prose.\n *\n * The layout is three sections — who got in touch, which ad sent them, and what\n * the request looked like — because that is the order the reader needs them in.\n * The contact details are the point; attribution is why it matters; the visitor\n * context is for when something looks wrong.\n */\nimport { getPrimaryClickId, identifyPlatform } from '../tracking/platform.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { RenderedMessage } from './types.ts';\n\n/**\n * Fields never worth putting in a notification.\n *\n * A honeypot is not visitor-supplied — it is bait, and reporting it as something\n * they submitted is untrue. The same rule `legal/derive.ts` applies when deriving\n * `piiCollected`, and for the same reason. Callers may strip more.\n */\nconst OMIT_FIELDS = new Set(['website', '_honeypot', 'honeypot', 'cf-turnstile-response']);\n\nfunction label(key: string): string {\n return key.charAt(0).toUpperCase() + key.slice(1).replace(/[_-]/g, ' ');\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\n/** One section: a heading and its lines. Empty sections are dropped by the caller. */\ninterface Section {\n heading: string;\n lines: [string, string][];\n}\n\n/**\n * Who submitted the form, for the subject line.\n *\n * `formData['name']` alone is the shape a form has when someone writes it that\n * way, and most don't: a downstream site had `first_name`/`last_name` on five of\n * six forms, so every notification read \"from Unknown\" and the renderer could\n * not be adopted at all. The cascade is ordered most-specific first, and falls\n * back to the email rather than to nothing — an address identifies the person\n * even when no name field exists.\n */\nfunction submitterName(formData: Record<string, string>): string {\n const get = (...keys: string[]): string | undefined => {\n for (const k of keys) {\n const v = formData[k]?.trim();\n if (v) return v;\n }\n return undefined;\n };\n const full = get('name', 'full_name', 'fullName', 'fullname', 'your-name');\n if (full) return full;\n const first = get('first_name', 'firstName', 'firstname', 'given_name');\n const last = get('last_name', 'lastName', 'lastname', 'family_name', 'surname');\n if (first || last) return [first, last].filter(Boolean).join(' ');\n return get('email', 'email_address', 'emailAddress') ?? 'Unknown';\n}\n\n/**\n * The event as sections — the shared structure behind both renderings.\n *\n * Extracted so the text and HTML versions cannot disagree about content. They\n * differ only in markup; a field added here appears in both.\n */\nexport function describeEvent(event: ConversionEvent): { subject: string; sections: Section[] } {\n const t = event.tracking;\n const platform = t ? identifyPlatform(t) : null;\n const clickId = t ? getPrimaryClickId(t) : null;\n\n const contact: [string, string][] = [];\n let subject: string;\n\n if (event.type === 'form' && event.formData) {\n const name = submitterName(event.formData);\n subject = `[Contact Form] New submission from ${name}`;\n for (const [key, value] of Object.entries(event.formData)) {\n if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);\n }\n } else {\n const phone = event.phone ?? 'unknown';\n subject = `[Phone Call] Visitor calling ${phone}`;\n contact.push(['Phone', phone]);\n }\n\n const attribution: [string, string][] = [];\n if (t) {\n if (platform) attribution.push(['Platform', platform]);\n if (clickId) attribution.push([`Click ID (${clickId.param})`, clickId.value]);\n for (const key of ['utm_campaign', 'utm_source', 'utm_medium', 'utm_content', 'utm_term']) {\n const value = t[key];\n if (value) attribution.push([label(key.replace('utm_', '')), String(value)]);\n }\n if (t.ab_variant) attribution.push(['A/B variant', t.ab_variant]);\n }\n\n const context: [string, string][] = [];\n if (t?.landingPage) context.push(['Landing page', t.landingPage]);\n if (t?.referrer) context.push(['Referrer', t.referrer]);\n if (t?.ts) context.push(['First visit', new Date(t.ts).toISOString()]);\n context.push(['Received', new Date(event.at).toISOString()]);\n if (event.userAgent) context.push(['User agent', event.userAgent]);\n if (event.ip) context.push(['IP', event.ip]);\n\n const sections: Section[] = [\n { heading: 'Contact details', lines: contact },\n // Stated rather than omitted: \"we don't know where this came from\" is a\n // useful answer, and a silently missing section reads as a bug in the tool.\n {\n heading: 'Ad attribution',\n lines: attribution.length\n ? attribution\n : [['Source', 'None — visitor arrived without ad tracking']],\n },\n { heading: 'Visitor context', lines: context },\n ];\n\n return { subject, sections: sections.filter((s) => s.lines.length) };\n}\n\n/** Render for e-mail. Both halves, always — see `RenderedMessage`. */\nexport function renderEmail(event: ConversionEvent): RenderedMessage {\n const { subject, sections } = describeEvent(event);\n\n const text = sections\n .map((s) => [`--- ${s.heading} ---`, ...s.lines.map(([k, v]) => `${k}: ${v}`)].join('\\n'))\n .join('\\n\\n');\n\n const html = sections\n .map(\n (s) =>\n `<h2 style=\"font:600 14px system-ui;margin:24px 0 8px\">${escapeHtml(s.heading)}</h2>` +\n `<table style=\"font:14px system-ui;border-collapse:collapse\">${s.lines\n .map(\n ([k, v]) =>\n `<tr><td style=\"padding:2px 12px 2px 0;color:#666;vertical-align:top\">${escapeHtml(k)}</td>` +\n `<td style=\"padding:2px 0\">${escapeHtml(v)}</td></tr>`,\n )\n .join('')}</table>`,\n )\n .join('');\n\n return { subject, text, html };\n}\n","/**\n * Conversion notifications — plan, render, dispatch.\n *\n * The seam between \"a conversion happened\" and \"someone found out about it\".\n * A `ConversionEvent` from `@getvitops/utils/tracking` goes in; where it goes and\n * how it reads are config, not code at the call site.\n *\n * Three files, three jobs, matching `indexing/`:\n * - `plan.ts` decides everything, purely — which channels, which recipients,\n * and why anything is off.\n * - `render.ts` turns the event into prose, purely.\n * - `email.ts` executes, and decides nothing.\n *\n * ---\n *\n * **TODO — further channels.** Only `email` is implemented. The seam is\n * `NotificationsConfig` + a sender with `sendEmail`'s signature `(plan, message,\n * transport) => ChannelResult`; adding one should mean a new sender file, a new\n * branch in `planNotifications`, and a schema variant — touching neither\n * `ConversionEvent` nor the recipient cascade. Planned:\n * - `sms` — the same event rendered short. `render.ts` already separates\n * `describeEvent` from `renderEmail` for exactly this.\n * - `persist` — append to a store (D1, a portal log) the client reviews.\n * The generic `email`/`text`/`persist` helpers taking content + provider config\n * belong at that point, not before: one implemented channel is not enough to know\n * what the abstraction should be.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { logEmail, sendEmail } from './email.ts';\nimport { planNotifications } from './plan.ts';\nimport { renderEmail } from './render.ts';\nimport type { ChannelResult, EmailBinding, NotifyContext } from './types.ts';\n\nexport interface NotifyDeps {\n /**\n * The Workers `send_email` binding (`env.EMAIL`). Omit it and the notification\n * is printed instead — the dev path.\n */\n email?: EmailBinding | undefined;\n}\n\nexport interface NotifyResult {\n results: ChannelResult[];\n /** True when at least one channel delivered. */\n delivered: boolean;\n}\n\n/**\n * Notify every configured channel about a conversion.\n *\n * Channels are independent: one failing must not stop the others, and none of\n * them may throw into the request that produced the event. Every outcome —\n * delivered, skipped, failed — comes back as data for the caller to log.\n */\nexport async function notify(\n event: ConversionEvent,\n ctx: NotifyContext,\n deps: NotifyDeps = {},\n): Promise<NotifyResult> {\n const plan = planNotifications(ctx);\n const results: ChannelResult[] = [];\n\n if (plan.email.enabled) {\n const message = renderEmail(event);\n results.push(\n deps.email ? await sendEmail(plan.email, message, deps.email) : logEmail(plan.email, message),\n );\n } else {\n results.push({ channel: 'email', sent: false, reason: plan.email.skip ?? 'Not configured.' });\n }\n\n return { results, delivered: results.some((r) => r.sent) };\n}\n\nexport {\n DEFAULT_EMAIL_BINDING,\n normalizeEmailChannel,\n planNotifications,\n resolveRecipient,\n resolveSender,\n} from './plan.ts';\nexport { describeEvent, renderEmail } from './render.ts';\nexport { logEmail, sendEmail } from './email.ts';\nexport type {\n ChannelPlan,\n ChannelResult,\n EmailBinding,\n EmailChannelConfig,\n EmailPlan,\n NotificationPlan,\n NotificationsConfig,\n NotifyContext,\n RenderedMessage,\n} from './types.ts';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/notify/email.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/index.ts"],"sourcesContent":["/**\n * The e-mail channel — Cloudflare Email Sending.\n *\n * I/O only: it executes an `EmailPlan` and decides nothing. Whether to send, to\n * whom, and from where were all settled in `plan.ts`.\n *\n * **The binding is passed in, never imported.** `@getvitops/utils` does not depend\n * on `cloudflare:workers`, so this module runs in Node and in a test with a stub,\n * and the Astro package gains no Cloudflare coupling. The consumer's route hands\n * over `env.EMAIL`.\n *\n * Uses the current Email Sending binding — a structured `send({ to, from,\n * subject, html, text })` — not the legacy `EmailMessage` + hand-built MIME path.\n * The domain in `from` must have been onboarded (`wrangler email sending enable\n * <domain>`); nothing here can check that, which is why `E_SENDER_NOT_VERIFIED`\n * is surfaced verbatim rather than collapsed into \"send failed\".\n */\nimport type { RenderedMessage } from './types.ts';\nimport type { ChannelResult, EmailBinding, EmailPlan } from './types.ts';\n\n/**\n * Provider errors worth retrying.\n *\n * Everything else is a configuration fault — an unverified sender, a malformed\n * payload, a suppressed recipient — where a retry is pure latency and the send\n * will fail identically. Retrying those also burns the daily quota.\n */\nconst RETRYABLE = new Set([\n 'E_RATE_LIMIT_EXCEEDED',\n 'E_DELIVERY_FAILED',\n 'E_INTERNAL_SERVER_ERROR',\n]);\n\nfunction codeOf(error: unknown): string | undefined {\n const code = (error as { code?: unknown } | null)?.code;\n return typeof code === 'string' ? code : undefined;\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nexport interface SendEmailOptions {\n /** Attempts for a retryable failure (default 3). */\n attempts?: number;\n /**\n * Per-attempt ceiling in milliseconds (default 10000; `0` disables).\n *\n * `binding.send()` is a network call with no deadline of its own, so without\n * this one hung attempt hangs the request that produced it — forever, in the\n * one module otherwise built on always saying why. A conversion endpoint\n * cannot be the thing that makes a form submission time out, so the bound\n * belongs here rather than in every consumer's wrapper.\n *\n * A timeout is treated as retryable: it is a transient condition by\n * definition, unlike `E_SENDER_NOT_VERIFIED` and friends.\n */\n timeoutMs?: number;\n /** Injected for tests; real callers let it default to a real delay. */\n sleep?: (ms: number) => Promise<void>;\n}\n\nconst wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));\n\n/** Marker for a send that exceeded its deadline, so the retry rule can see it. */\nconst TIMEOUT_CODE = 'E_SEND_TIMEOUT';\n\n/**\n * Race a send against its deadline.\n *\n * The timer is always cleared — a pending `setTimeout` keeps a Worker's event\n * loop alive past the response, which turns a bounded send into an unbounded\n * invocation and bills for the difference.\n */\nasync function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {\n if (ms <= 0) return work;\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n work,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n const err = new Error(`Email send exceeded ${ms}ms.`) as Error & { code: string };\n err.code = TIMEOUT_CODE;\n reject(err);\n }, ms);\n }),\n ]);\n } finally {\n if (timer !== undefined) clearTimeout(timer);\n }\n}\n\n/**\n * Send one rendered message according to a plan.\n *\n * Never throws: a failed notification must not take down the request that\n * produced it. The visitor has already submitted their form, and failing their\n * response because our mail didn't go out would turn a lost notification into a\n * lost conversion. The failure is returned instead, for the caller to log.\n */\nexport async function sendEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n binding: EmailBinding,\n options: SendEmailOptions = {},\n): Promise<ChannelResult> {\n if (!plan.enabled || !plan.to || !plan.from) {\n return { channel: 'email', sent: false, reason: plan.skip ?? 'Channel not enabled.' };\n }\n\n const attempts = Math.max(1, options.attempts ?? 3);\n const sleep = options.sleep ?? wait;\n const timeoutMs = options.timeoutMs ?? 10_000;\n\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n await withTimeout(\n binding.send({\n to: plan.to,\n from: plan.fromName ? { email: plan.from, name: plan.fromName } : { email: plan.from },\n subject: message.subject,\n text: message.text,\n html: message.html,\n ...(plan.replyTo ? { replyTo: plan.replyTo } : {}),\n }),\n timeoutMs,\n );\n return { channel: 'email', sent: true };\n } catch (error) {\n const code = codeOf(error);\n if (attempt === attempts || !code || !(RETRYABLE.has(code) || code === TIMEOUT_CODE)) {\n return {\n channel: 'email',\n sent: false,\n reason: messageOf(error),\n ...(code ? { code } : {}),\n };\n }\n // Exponential backoff. Cloudflare's rate limit is per-account, so a burst\n // of conversions retrying in lockstep would keep colliding.\n await sleep(2 ** (attempt - 1) * 250);\n }\n }\n\n /* c8 ignore next -- the loop always returns; this satisfies the type checker. */\n return { channel: 'email', sent: false, reason: 'Exhausted attempts.' };\n}\n\n/**\n * Print the message instead of sending it.\n *\n * The dev path, for a machine with no binding. Explicit rather than a silent\n * no-op, because \"I never saw the notification\" is the symptom of both a broken\n * integration and a working one nobody could observe.\n */\nexport function logEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n log: (line: string) => void = console.log,\n): ChannelResult {\n log(\n [\n '\\n========== NOTIFICATION EMAIL (not sent — no binding) ==========',\n `To: ${plan.to ?? '(unresolved)'}`,\n `From: ${plan.from ?? '(unresolved)'}`,\n `Subject: ${message.subject}`,\n '---',\n message.text,\n '================================================================\\n',\n ].join('\\n'),\n );\n return { channel: 'email', sent: false, reason: 'No binding — logged to the console.' };\n}\n","/**\n * Every decision a notification makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no binding, no clock. Whether a\n * channel fires, who it reaches, and *why* it was skipped are all decided here and\n * asserted in `plan.test.ts`; the senders beside this file do what they are told\n * and decide nothing. That is the same split `indexing/plan.ts` makes against\n * `gsc.ts`, and `@getvitops/core`'s consent store makes against its DOM wiring,\n * for the same reason: the consequential logic has to be testable without the\n * environment it runs in.\n *\n * The practical payoff is that a misconfigured site can be told exactly what will\n * and won't happen without sending anything — a silently unsent conversion\n * notification is indistinguishable from no conversion.\n */\nimport type { EmailChannelConfig, EmailPlan, NotificationPlan, NotifyContext } from './types.ts';\n\n/** Default Workers binding name for Cloudflare Email Sending. */\nexport const DEFAULT_EMAIL_BINDING = 'EMAIL';\n\n/** Normalise the `email` shorthand: a bare address means a Cloudflare send to it. */\nexport function normalizeEmailChannel(\n input: string | EmailChannelConfig | undefined,\n): EmailChannelConfig | undefined {\n if (input === undefined) return undefined;\n return typeof input === 'string' ? { provider: 'cloudflare', to: input } : input;\n}\n\n/**\n * Who the notification reaches.\n *\n * An explicit recipient wins; otherwise it falls to the site's own contact\n * addresses, primary first. This cascade is why a site that has already said\n * where it receives mail doesn't have to say it twice.\n */\nexport function resolveRecipient(\n channel: EmailChannelConfig,\n ctx: NotifyContext,\n): string | undefined {\n if (channel.to) return channel.to;\n return ctx.locationEmails?.find((e) => !!e);\n}\n\n/**\n * The From address.\n *\n * `noreply@<canonical>` by default, because the sending domain has to be one the\n * site controls — Cloudflare rejects anything else with `E_SENDER_NOT_VERIFIED`,\n * and guessing from the recipient's domain would send that failure every time.\n */\nexport function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined {\n if (channel.from) return channel.from;\n if (!ctx.canonical) return undefined;\n let host: string;\n try {\n host = new URL(ctx.canonical).hostname;\n } catch {\n // A bare hostname is a legitimate way to write `domains.canonical`.\n host = ctx.canonical.replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');\n }\n return host ? `noreply@${host}` : undefined;\n}\n\n/**\n * Decide what will be sent, and say why anything won't be.\n *\n * Every \"off\" carries a reason rather than being an absence, so a caller can\n * report the misconfiguration instead of reporting success over a no-op.\n */\nexport function planNotifications(ctx: NotifyContext): NotificationPlan {\n const email = planEmail(ctx);\n return { email, empty: !email.enabled };\n}\n\nfunction planEmail(ctx: NotifyContext): EmailPlan {\n const channel = normalizeEmailChannel(ctx.notifications?.email);\n const binding = channel?.binding ?? DEFAULT_EMAIL_BINDING;\n\n if (!channel) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip: 'No `notifications.email` configured.',\n };\n }\n\n const to = resolveRecipient(channel, ctx);\n if (!to) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip:\n 'No recipient: set `notifications.email.to`, or give a location an `email` ' +\n 'so the notification has somewhere to go.',\n };\n }\n\n const from = resolveSender(channel, ctx);\n if (!from) {\n return {\n provider: 'cloudflare',\n binding,\n to,\n enabled: false,\n skip:\n 'No sender: set `notifications.email.from`, or `domains.canonical` so ' +\n '`noreply@<host>` can be derived. Cloudflare only sends from a domain you have onboarded.',\n };\n }\n\n return {\n provider: 'cloudflare',\n binding,\n to,\n from,\n fromName: channel.fromName ?? ctx.organizationName,\n replyTo: channel.replyTo,\n enabled: true,\n };\n}\n","/**\n * Turning a `ConversionEvent` into something a person reads.\n *\n * Pure, and separate from both the plan and the sender, because rendering is the\n * part that differs per channel: an SMS channel will render the same event in 160\n * characters, a portal log as a row. The event stays the fact; only this file has\n * an opinion about prose.\n *\n * The layout is three sections — who got in touch, which ad sent them, and what\n * the request looked like — because that is the order the reader needs them in.\n * The contact details are the point; attribution is why it matters; the visitor\n * context is for when something looks wrong.\n */\nimport { identifySubmitter } from '../tracking/identity.ts';\nimport { getPrimaryClickId, identifyPlatform } from '../tracking/platform.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { RenderedMessage } from './types.ts';\n\n/**\n * Fields never worth putting in a notification.\n *\n * A honeypot is not visitor-supplied — it is bait, and reporting it as something\n * they submitted is untrue. The same rule `legal/derive.ts` applies when deriving\n * `piiCollected`, and for the same reason. Callers may strip more.\n */\nconst OMIT_FIELDS = new Set(['website', '_honeypot', 'honeypot', 'cf-turnstile-response']);\n\nfunction label(key: string): string {\n return key.charAt(0).toUpperCase() + key.slice(1).replace(/[_-]/g, ' ');\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\n/** One section: a heading and its lines. Empty sections are dropped by the caller. */\ninterface Section {\n heading: string;\n lines: [string, string][];\n}\n\n/**\n * One line, already rendered both ways — a plain fact (`html` is just the\n * escaped `text`) or a link (`html` an anchor, `text` the bare href). Built at\n * the point each line is known rather than carried as a union and branched on\n * later: `describeEvent`'s lines are always plain `[label, value]` pairs (see\n * `Section` above, which `render.test.ts` destructures as such), and the one\n * line shape `renderEmail` adds itself — the \"Actions\" link — is turned into\n * this same rendered shape immediately, so the two renderers below share one\n * line format instead of branching on what kind of line each one is.\n */\ninterface RenderedLine {\n label: string;\n text: string;\n html: string;\n}\n\n/** A section as `renderEmail` assembles it — `describeEvent`'s output plus its own additions. */\ninterface RenderSection {\n heading: string;\n lines: RenderedLine[];\n}\n\nfunction factLine([label, value]: readonly [string, string]): RenderedLine {\n return { label, text: value, html: escapeHtml(value) };\n}\n\nfunction linkLine(label: string, href: string): RenderedLine {\n return { label, text: href, html: `<a href=\"${escapeHtml(href)}\">${escapeHtml(href)}</a>` };\n}\n\nfunction toRenderSection(section: Section): RenderSection {\n return { heading: section.heading, lines: section.lines.map(factLine) };\n}\n\n/**\n * Who submitted the form, for the subject line.\n *\n * `formData['name']` alone is the shape a form has when someone writes it that\n * way, and most don't: a downstream site had `first_name`/`last_name` on five of\n * six forms, so every notification read \"from Unknown\" and the renderer could\n * not be adopted at all. The cascade is ordered most-specific first, and falls\n * back to the email rather than to nothing — an address identifies the person\n * even when no name field exists.\n */\nfunction submitterName(formData: Record<string, string>): string {\n return identifySubmitter(formData).fullName;\n}\n\n/**\n * The event as sections — the shared structure behind both renderings.\n *\n * Extracted so the text and HTML versions cannot disagree about content. They\n * differ only in markup; a field added here appears in both.\n */\nexport function describeEvent(event: ConversionEvent): { subject: string; sections: Section[] } {\n const t = event.tracking;\n const platform = t ? identifyPlatform(t) : null;\n const clickId = t ? getPrimaryClickId(t) : null;\n\n const contact: [string, string][] = [];\n let subject: string;\n\n if (event.type === 'form' && event.formData) {\n const name = submitterName(event.formData);\n subject = `[Contact Form] New submission from ${name}`;\n for (const [key, value] of Object.entries(event.formData)) {\n if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);\n }\n } else if (event.type === 'email') {\n const email = event.email ?? 'unknown';\n subject = `[Email Click] Visitor emailing ${email}`;\n contact.push(['Email', email]);\n } else {\n const phone = event.phone ?? 'unknown';\n subject = `[Phone Call] Visitor calling ${phone}`;\n contact.push(['Phone', phone]);\n }\n\n const attribution: [string, string][] = [];\n if (t) {\n if (platform) attribution.push(['Platform', platform]);\n if (clickId) attribution.push([`Click ID (${clickId.param})`, clickId.value]);\n for (const key of ['utm_campaign', 'utm_source', 'utm_medium', 'utm_content', 'utm_term']) {\n const value = t[key];\n if (value) attribution.push([label(key.replace('utm_', '')), String(value)]);\n }\n if (t.ab_variant) attribution.push(['A/B variant', t.ab_variant]);\n }\n\n const context: [string, string][] = [];\n if (t?.landingPage) context.push(['Landing page', t.landingPage]);\n if (t?.referrer) context.push(['Referrer', t.referrer]);\n if (t?.ts) context.push(['First visit', new Date(t.ts).toISOString()]);\n context.push(['Received', new Date(event.at).toISOString()]);\n if (event.userAgent) context.push(['User agent', event.userAgent]);\n if (event.ip) context.push(['IP', event.ip]);\n\n const sections: Section[] = [\n { heading: 'Contact details', lines: contact },\n // Stated rather than omitted: \"we don't know where this came from\" is a\n // useful answer, and a silently missing section reads as a bug in the tool.\n {\n heading: 'Ad attribution',\n lines: attribution.length\n ? attribution\n : [['Source', 'None — visitor arrived without ad tracking']],\n },\n { heading: 'Visitor context', lines: context },\n ];\n\n return { subject, sections: sections.filter((s) => s.lines.length) };\n}\n\n/** Render for e-mail. Both halves, always — see `RenderedMessage`. */\nexport function renderEmail(\n event: ConversionEvent,\n extra: { notes?: string[] | undefined; actionUrl?: string | undefined } = {},\n): RenderedMessage {\n const { subject, sections: eventSections } = describeEvent(event);\n\n // `{clickId}` is the only placeholder — an event has no durable id nothing\n // in this toolchain could look back up (there's no store), so interpolating\n // one would be decorative. Omitted entirely when there's no click ID to\n // interpolate, rather than emailing a link with a literal `{clickId}` in it.\n const clickId = event.tracking ? getPrimaryClickId(event.tracking) : null;\n const actionSection: RenderSection | null =\n extra.actionUrl && clickId\n ? {\n heading: 'Actions',\n lines: [\n linkLine(\n 'View in ad platform',\n extra.actionUrl.replace(/\\{clickId\\}/g, encodeURIComponent(clickId.value)),\n ),\n ],\n }\n : null;\n\n // Deliberately a channel-neutral `string[]` rather than a typed CRM result —\n // this module gains no dependency on `crm`, and the seam serves any future\n // sink the same way.\n const sections: RenderSection[] = [\n ...eventSections.map(toRenderSection),\n ...(actionSection ? [actionSection] : []),\n ...(extra.notes && extra.notes.length\n ? [{ heading: 'Delivery', lines: extra.notes.map((n) => factLine(['CRM', n])) }]\n : []),\n ];\n\n const text = sections\n .map((s) => [`--- ${s.heading} ---`, ...s.lines.map((l) => `${l.label}: ${l.text}`)].join('\\n'))\n .join('\\n\\n');\n\n const html = sections\n .map(\n (s) =>\n `<h2 style=\"font:600 14px system-ui;margin:24px 0 8px\">${escapeHtml(s.heading)}</h2>` +\n `<table style=\"font:14px system-ui;border-collapse:collapse\">${s.lines\n .map(\n (l) =>\n `<tr><td style=\"padding:2px 12px 2px 0;color:#666;vertical-align:top\">${escapeHtml(l.label)}</td>` +\n `<td style=\"padding:2px 0\">${l.html}</td></tr>`,\n )\n .join('')}</table>`,\n )\n .join('');\n\n return { subject, text, html };\n}\n","/**\n * Conversion notifications — plan, render, dispatch.\n *\n * The seam between \"a conversion happened\" and \"someone found out about it\".\n * A `ConversionEvent` from `@getvitops/utils/tracking` goes in; where it goes and\n * how it reads are config, not code at the call site.\n *\n * Three files, three jobs, matching `indexing/`:\n * - `plan.ts` decides everything, purely — which channels, which recipients,\n * and why anything is off.\n * - `render.ts` turns the event into prose, purely.\n * - `email.ts` executes, and decides nothing.\n *\n * ---\n *\n * **TODO — further channels.** Only `email` is implemented. The seam is\n * `NotificationsConfig` + a sender with `sendEmail`'s signature `(plan, message,\n * transport) => ChannelResult`; adding one should mean a new sender file, a new\n * branch in `planNotifications`, and a schema variant — touching neither\n * `ConversionEvent` nor the recipient cascade. Planned:\n * - `sms` — the same event rendered short. `render.ts` already separates\n * `describeEvent` from `renderEmail` for exactly this.\n * - `persist` — append to a store (D1, a portal log) the client reviews.\n * The generic `email`/`text`/`persist` helpers taking content + provider config\n * belong at that point, not before: one implemented channel is not enough to know\n * what the abstraction should be.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { logEmail, sendEmail } from './email.ts';\nimport { planNotifications } from './plan.ts';\nimport { renderEmail } from './render.ts';\nimport type { ChannelResult, EmailBinding, NotifyContext } from './types.ts';\n\nexport interface NotifyDeps {\n /**\n * The Workers `send_email` binding (`env.EMAIL`). Omit it and the notification\n * is printed instead — the dev path.\n */\n email?: EmailBinding | undefined;\n}\n\nexport interface NotifyResult {\n results: ChannelResult[];\n /** True when at least one channel delivered. */\n delivered: boolean;\n}\n\n/**\n * Notify every configured channel about a conversion.\n *\n * Channels are independent: one failing must not stop the others, and none of\n * them may throw into the request that produced the event. Every outcome —\n * delivered, skipped, failed — comes back as data for the caller to log.\n */\nexport async function notify(\n event: ConversionEvent,\n ctx: NotifyContext,\n deps: NotifyDeps = {},\n extra: { notes?: string[] | undefined } = {},\n): Promise<NotifyResult> {\n const plan = planNotifications(ctx);\n const results: ChannelResult[] = [];\n\n if (plan.email.enabled) {\n const message = renderEmail(event, { ...extra, actionUrl: ctx.notifications?.actionUrl });\n results.push(\n deps.email ? await sendEmail(plan.email, message, deps.email) : logEmail(plan.email, message),\n );\n } else {\n results.push({ channel: 'email', sent: false, reason: plan.email.skip ?? 'Not configured.' });\n }\n\n return { results, delivered: results.some((r) => r.sent) };\n}\n\nexport {\n DEFAULT_EMAIL_BINDING,\n normalizeEmailChannel,\n planNotifications,\n resolveRecipient,\n resolveSender,\n} from './plan.ts';\nexport { describeEvent, renderEmail } from './render.ts';\nexport { logEmail, sendEmail } from './email.ts';\nexport type {\n ChannelPlan,\n ChannelResult,\n EmailBinding,\n EmailChannelConfig,\n EmailPlan,\n NotificationPlan,\n NotificationsConfig,\n NotifyContext,\n RenderedMessage,\n} from './types.ts';\n"],"mappings":"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"}
|
|
@@ -1,140 +1,9 @@
|
|
|
1
1
|
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-BiK61Oq4.mjs";
|
|
2
|
-
import { i as GoogleOAuth, n as GoogleAuthLike } from "../token-
|
|
3
|
-
|
|
4
|
-
//#region src/onboarding/types.d.ts
|
|
5
|
-
/**
|
|
6
|
-
* The onboarding module's own option surface.
|
|
7
|
-
*
|
|
8
|
-
* Deliberately structural rather than `Config['site']['searchConsole']` imported
|
|
9
|
-
* from `@getvitops/generator`: the generator already depends on this package, so
|
|
10
|
-
* importing back would be a cycle. The vocabulary mirrors that block field for
|
|
11
|
-
* field, so the CLI's adapter is a flat map — the same arrangement, and for the
|
|
12
|
-
* same reason, as `IndexingConfig` beside it.
|
|
13
|
-
*
|
|
14
|
-
* Optional fields are written `?: T | undefined` throughout: `exactOptionalPropertyTypes`
|
|
15
|
-
* is on, and these types describe a *parsed JSON config* where an absent key and an
|
|
16
|
-
* explicit `undefined` are the same thing.
|
|
17
|
-
*/
|
|
18
|
-
/** One domain to onboard, as the config declares it. */
|
|
19
|
-
interface DomainSetup {
|
|
20
|
-
/** The domain, bare (`acme.ca`). Becomes the `sc-domain:` property and the DNS zone name. */
|
|
21
|
-
domain: string;
|
|
22
|
-
/**
|
|
23
|
-
* Emails added as verification owners on the Site Verification web resource.
|
|
24
|
-
* Additive: existing owners are never removed.
|
|
25
|
-
*/
|
|
26
|
-
delegatedOwners?: string[] | undefined;
|
|
27
|
-
/**
|
|
28
|
-
* A Google Group to grant Full-User access in Search Console. Surfaced as a
|
|
29
|
-
* reminder only — Search Console has no user/permission API.
|
|
30
|
-
*/
|
|
31
|
-
fullUserGroup?: string | undefined;
|
|
32
|
-
}
|
|
33
|
-
/** Everything `plan()` needs, once the CLI has read the config and creds. */
|
|
34
|
-
interface OnboardingConfig {
|
|
35
|
-
/** The domains to onboard. Already filtered to `--domain` if that was passed. */
|
|
36
|
-
domains: DomainSetup[];
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* One domain's observed live state, gathered by the executors before planning.
|
|
40
|
-
*
|
|
41
|
-
* `plan()` compares this against the desired `DomainSetup` to decide each step —
|
|
42
|
-
* the same shape the indexing planner takes its sitemap snapshot in, for the same
|
|
43
|
-
* reason: the decision is pure, the observation is not.
|
|
44
|
-
*/
|
|
45
|
-
interface DomainState {
|
|
46
|
-
/** The Cloudflare zone id for the domain, if one was found. */
|
|
47
|
-
zoneId?: string | undefined;
|
|
48
|
-
/** An apex TXT record already carrying the verification token exists. */
|
|
49
|
-
txtPresent: boolean;
|
|
50
|
-
/** The Site Verification web resource reports this domain as verified. */
|
|
51
|
-
verified: boolean;
|
|
52
|
-
/** Owners currently on the web resource (empty when not yet verified). */
|
|
53
|
-
currentOwners: string[];
|
|
54
|
-
/** A `sc-domain:` property already exists in Search Console. */
|
|
55
|
-
propertyExists: boolean;
|
|
56
|
-
}
|
|
57
|
-
//#endregion
|
|
58
|
-
//#region src/onboarding/plan.d.ts
|
|
59
|
-
/** A step's decision: do it, or skip it because it already holds. */
|
|
60
|
-
type StepAction = 'create' | 'update' | 'skip';
|
|
61
|
-
interface StepPlan {
|
|
62
|
-
action: StepAction;
|
|
63
|
-
/** Why it's a skip, or what the mutation will do. */
|
|
64
|
-
detail: string;
|
|
65
|
-
}
|
|
66
|
-
/** The four onboarding steps for one domain, decided against its live state. */
|
|
67
|
-
interface DomainPlan {
|
|
68
|
-
domain: string;
|
|
69
|
-
/** `sc-domain:` property identifier this domain maps to. */
|
|
70
|
-
siteUrl: string;
|
|
71
|
-
/** Ensure the apex TXT verification record. */
|
|
72
|
-
txt: StepPlan;
|
|
73
|
-
/** Verify ownership via the Site Verification API (DNS_TXT). */
|
|
74
|
-
verify: StepPlan;
|
|
75
|
-
/** Add the Search Console domain property. */
|
|
76
|
-
property: StepPlan;
|
|
77
|
-
/** Add delegated owners to the verified web resource. */
|
|
78
|
-
owners: StepPlan;
|
|
79
|
-
/** Owners not yet present that the run should add. */
|
|
80
|
-
ownersToAdd: string[];
|
|
81
|
-
/** Manual follow-ups (e.g. a Full-User group). */
|
|
82
|
-
reminders: string[];
|
|
83
|
-
}
|
|
84
|
-
interface OnboardingPlan {
|
|
85
|
-
domains: DomainPlan[];
|
|
86
|
-
/** Operator-facing warnings that span the whole run. */
|
|
87
|
-
notes: string[];
|
|
88
|
-
}
|
|
89
|
-
/** The `sc-domain:` property identifier for a bare domain. */
|
|
90
|
-
declare const siteUrlFor: (domain: string) => string;
|
|
91
|
-
/**
|
|
92
|
-
* Backoff ladder for the verification retry, in milliseconds.
|
|
93
|
-
*
|
|
94
|
-
* DNS_TXT verification fails until the record has propagated, which is a matter of
|
|
95
|
-
* seconds-to-minutes, not a real error — so the schedule is a *policy*, decided
|
|
96
|
-
* here and asserted in the tests, while the actual sleeping is the CLI's job.
|
|
97
|
-
* Capped at `maxAttempts`; a domain still unverified after the last attempt is
|
|
98
|
-
* reported PENDING, never failed.
|
|
99
|
-
*/
|
|
100
|
-
declare function backoffSchedule(maxAttempts?: number): number[];
|
|
101
|
-
/** Decide the four steps for one domain from its desired config and live state. */
|
|
102
|
-
declare function planDomain(setup: DomainSetup, state: DomainState): DomainPlan;
|
|
103
|
-
/**
|
|
104
|
-
* The owner set to PUT: everyone already there, plus everyone being added.
|
|
105
|
-
*
|
|
106
|
-
* `updateOwners` REPLACES the owner list, so this is the one computation standing
|
|
107
|
-
* between a delegation and locking yourself out — pass a bare `ownersToAdd` and
|
|
108
|
-
* the account that just verified the domain is removed from it. It belongs here
|
|
109
|
-
* rather than at the call site for exactly that reason: it is a decision, it is
|
|
110
|
-
* the dangerous one, and the executor is not allowed to make it.
|
|
111
|
-
*
|
|
112
|
-
* `current` comes FIRST so the API's own casing survives dedupe; matching is
|
|
113
|
-
* case-insensitive, mirroring `planDomain`'s `present` check, because Google
|
|
114
|
-
* echoes back addresses in whatever case the account uses and a case-sensitive
|
|
115
|
-
* `Set` would re-add an owner who is already there.
|
|
116
|
-
*/
|
|
117
|
-
declare function ownerUnion(current: string[], toAdd: string[]): string[];
|
|
118
|
-
/** Plan every domain. */
|
|
119
|
-
declare function plan(config: OnboardingConfig, states: Map<string, DomainState>): OnboardingPlan;
|
|
120
|
-
/** Any step on any domain is a mutation — i.e. the live state has drifted. */
|
|
121
|
-
declare function hasDrift(p: OnboardingPlan): boolean;
|
|
122
|
-
/** Render a plan as the block `--dry` and `--check` print. */
|
|
123
|
-
declare function formatPlan(p: OnboardingPlan): string;
|
|
124
|
-
/** The outcome of one domain's run, for the summary table. */
|
|
125
|
-
interface DomainResult {
|
|
126
|
-
domain: string;
|
|
127
|
-
txt: 'created' | 'present' | 'failed' | '—';
|
|
128
|
-
verified: 'yes' | 'pending' | 'failed' | '—';
|
|
129
|
-
property: 'added' | 'present' | 'failed' | '—';
|
|
130
|
-
reminders: string[];
|
|
131
|
-
}
|
|
132
|
-
/** Render the end-of-run summary: one row per domain, plus any reminders. */
|
|
133
|
-
declare function formatSummary(results: DomainResult[]): string;
|
|
134
|
-
//#endregion
|
|
2
|
+
import { i as GoogleOAuth, n as GoogleAuthLike } from "../token-DmmoQ7um.mjs";
|
|
3
|
+
import { a as StepPlan, c as formatSummary, d as plan, f as planDomain, g as OnboardingConfig, h as DomainState, i as StepAction, l as hasDrift, m as DomainSetup, n as DomainResult, o as backoffSchedule, p as siteUrlFor, r as OnboardingPlan, s as formatPlan, t as DomainPlan, u as ownerUnion } from "../plan-Hq8Pb0Uw.mjs";
|
|
135
4
|
//#region src/onboarding/google.d.ts
|
|
136
5
|
/** Exchange a user OAuth refresh token for a short-lived access token. */
|
|
137
|
-
declare function refreshTokenGrant(oauth: GoogleOAuth, fetchImpl?: typeof fetch): Promise<string>;
|
|
6
|
+
export declare function refreshTokenGrant(oauth: GoogleOAuth, fetchImpl?: typeof fetch): Promise<string>;
|
|
138
7
|
/**
|
|
139
8
|
* Ask the Site Verification API for the DNS_TXT token for a domain.
|
|
140
9
|
*
|
|
@@ -142,7 +11,7 @@ declare function refreshTokenGrant(oauth: GoogleOAuth, fetchImpl?: typeof fetch)
|
|
|
142
11
|
* record. It is deterministic per (user, domain), so re-fetching it and comparing
|
|
143
12
|
* against DNS is what makes the TXT step idempotent.
|
|
144
13
|
*/
|
|
145
|
-
declare function getVerificationToken(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
14
|
+
export declare function getVerificationToken(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
146
15
|
ok: boolean;
|
|
147
16
|
status: number;
|
|
148
17
|
token?: string;
|
|
@@ -156,7 +25,7 @@ declare function getVerificationToken(auth: GoogleAuthLike, domain: string, fetc
|
|
|
156
25
|
* contains resources the user already owns — so a hit *is* the verified state, and
|
|
157
26
|
* carries the id and owners the later steps need.
|
|
158
27
|
*/
|
|
159
|
-
declare function getWebResource(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
28
|
+
export declare function getWebResource(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
160
29
|
ok: boolean;
|
|
161
30
|
status: number;
|
|
162
31
|
exists: boolean;
|
|
@@ -165,7 +34,7 @@ declare function getWebResource(auth: GoogleAuthLike, domain: string, fetchImpl?
|
|
|
165
34
|
message?: string;
|
|
166
35
|
}>;
|
|
167
36
|
/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */
|
|
168
|
-
declare function verifyWebResource(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
37
|
+
export declare function verifyWebResource(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
169
38
|
ok: boolean;
|
|
170
39
|
status: number;
|
|
171
40
|
id?: string;
|
|
@@ -179,13 +48,13 @@ declare function verifyWebResource(auth: GoogleAuthLike, domain: string, fetchIm
|
|
|
179
48
|
* current owners and the ones to add — never a bare list of new owners, which
|
|
180
49
|
* would drop everyone already there.
|
|
181
50
|
*/
|
|
182
|
-
declare function updateOwners(auth: GoogleAuthLike, id: string, domain: string, owners: string[], fetchImpl?: typeof fetch): Promise<{
|
|
51
|
+
export declare function updateOwners(auth: GoogleAuthLike, id: string, domain: string, owners: string[], fetchImpl?: typeof fetch): Promise<{
|
|
183
52
|
ok: boolean;
|
|
184
53
|
status: number;
|
|
185
54
|
message?: string;
|
|
186
55
|
}>;
|
|
187
56
|
/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */
|
|
188
|
-
declare function getSite(auth: GoogleAuthLike, siteUrl: string, fetchImpl?: typeof fetch): Promise<{
|
|
57
|
+
export declare function getSite(auth: GoogleAuthLike, siteUrl: string, fetchImpl?: typeof fetch): Promise<{
|
|
189
58
|
ok: boolean;
|
|
190
59
|
status: number;
|
|
191
60
|
exists: boolean;
|
|
@@ -197,11 +66,11 @@ declare function getSite(auth: GoogleAuthLike, siteUrl: string, fetchImpl?: type
|
|
|
197
66
|
* A `403` here almost always means the domain is not yet verified for this user
|
|
198
67
|
* rather than a transient error — property creation is gated on ownership.
|
|
199
68
|
*/
|
|
200
|
-
declare function addSite(auth: GoogleAuthLike, siteUrl: string, fetchImpl?: typeof fetch): Promise<{
|
|
69
|
+
export declare function addSite(auth: GoogleAuthLike, siteUrl: string, fetchImpl?: typeof fetch): Promise<{
|
|
201
70
|
ok: boolean;
|
|
202
71
|
status: number;
|
|
203
72
|
message?: string;
|
|
204
73
|
}>;
|
|
205
74
|
//#endregion
|
|
206
|
-
export { type DomainPlan, type DomainResult, type DomainSetup, type DomainState, type GoogleOAuth, type OnboardingConfig, type OnboardingPlan, type StepAction, type StepPlan,
|
|
75
|
+
export { type DomainPlan, type DomainResult, type DomainSetup, type DomainState, type GoogleOAuth, type OnboardingConfig, type OnboardingPlan, type StepAction, type StepPlan, backoffSchedule, createApexTxt, findZoneId, formatPlan, formatSummary, hasDrift, listApexTxt, ownerUnion, plan, planDomain, siteUrlFor };
|
|
207
76
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/onboarding/
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/onboarding/google.ts"],"mappings":";;;;;wBA+BgB,kBACd,OAAO,aACP,mBAAkB,QACjB;;;;;;;;wBAamB,qBACpB,MAAM,gBACN,gBACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB;EAAgB;;;;;;;;;;wBAoBpC,eACpB,MAAM,gBACN,gBACA,mBAAkB,QACjB;EACD;EACA;EACA;EACA;EACA;EACA;;;wBAwBoB,kBACpB,MAAM,gBACN,gBACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB;EAAa;EAAkB;;;;;;;;;wBAuBnD,aACpB,MAAM,gBACN,YACA,gBACA,kBACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB;;;wBAWpB,QACpB,MAAM,gBACN,iBACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB;EAAiB;;;;;;;;wBAerC,QACpB,MAAM,gBACN,iBACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB"}
|