@getvitops/utils 8.1.0 → 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 +264 -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 +75 -7
- package/dist/ads/index.mjs.map +1 -1
- package/dist/cloudflare-BiK61Oq4.d.mts.map +1 -1
- 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 +29 -29
- package/dist/hosting/index.d.mts.map +1 -1
- package/dist/hosting/index.mjs +7 -4
- package/dist/hosting/index.mjs.map +1 -1
- 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 +1 -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/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 +14 -4
- package/dist/adc-BFrzpMHq.d.mts.map +0 -1
- package/dist/token-Bz_-igw-.d.mts.map +0 -1
- package/dist/types-CHHjzRrv.d.mts.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["EXPIRY_MARGIN_MS","cache"],"sources":["../../src/crm/map.ts","../../src/crm/zoho-token.ts","../../src/crm/bigin.ts","../../src/crm/hubspot.ts","../../src/crm/jobber-token.ts","../../src/crm/jobber.ts","../../src/crm/plan.ts","../../src/crm/types.ts","../../src/crm/index.ts"],"sourcesContent":["/**\n * Turning one submission into a provider-neutral `CrmRecord`.\n *\n * Pure, and separate from both executors for the reason `notify/render.ts` is\n * separate from `email.ts`: shaping the data is where the two subtle rules\n * that must not regress live, and pinning them here means a test can assert\n * them without a network.\n *\n * `fieldMap`/`trackingFields` are drop-if-unmapped, not guess-if-unmapped — an\n * unmapped field silently disappearing from the CRM record is the same shape\n * of decision `describeEvent()` makes for `OMIT_FIELDS`: an absent mapping is\n * a stated design choice (the operator hasn't decided this field matters to\n * the CRM yet), never an error.\n */\nimport { identifySubmitter } from '../tracking/identity.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { CrmConfig, CrmRecord } from './types.ts';\n\n/**\n * Bigin requires `Last_Name`; a form that only asked for a full name or an\n * email needs one derived rather than 400ing on insert.\n *\n * The fallback chain, in order: an explicit last name field → the last space-\n * separated token of a full name → the local part of the email address →\n * refuse, with a stated reason, rather than fabricate a name from nothing.\n * `map.ts` never emits an empty `lastName` for Bigin — `deliverToCrm` reads\n * the refusal and skips the write instead of letting the CRM reject it.\n */\nexport function deriveLastName(identity: {\n firstName?: string | undefined;\n lastName?: string | undefined;\n fullName: string;\n email?: string | undefined;\n}): { lastName: string } | { refusal: string } {\n if (identity.lastName) return { lastName: identity.lastName };\n\n const nameParts = identity.fullName.trim().split(/\\s+/);\n if (nameParts.length > 1) {\n return { lastName: nameParts[nameParts.length - 1]! };\n }\n\n if (identity.email) {\n const local = identity.email.split('@')[0];\n if (local) return { lastName: local };\n }\n\n return {\n refusal:\n 'Could not derive a last name from the submission (no last-name field, ' +\n 'no space in the full name, and no email to fall back to).',\n };\n}\n\nfunction mapExtra(\n formData: Record<string, string>,\n fieldMap: Record<string, string> | undefined,\n): Record<string, string> {\n const out: Record<string, string> = {};\n if (!fieldMap) return out;\n for (const [field, property] of Object.entries(fieldMap)) {\n const value = formData[field];\n if (value) out[property] = value;\n }\n return out;\n}\n\nfunction mapAttribution(\n event: ConversionEvent,\n trackingFields: Record<string, string> | undefined,\n): Record<string, string> {\n const out: Record<string, string> = {};\n // A visitor with no `_ac` cookie — organic arrival, or declined consent —\n // yields `{}`, never an error. Absence is not a mapping failure.\n if (!event.tracking || !trackingFields) return out;\n for (const [key, property] of Object.entries(trackingFields)) {\n const value = event.tracking[key];\n if (value !== undefined && value !== '') out[property] = String(value);\n }\n return out;\n}\n\nexport function mapSubmission(event: ConversionEvent, config: CrmConfig): CrmRecord {\n const formData = event.formData ?? {};\n const identity = identifySubmitter(formData);\n\n return {\n firstName: identity.firstName,\n lastName: identity.lastName,\n email: identity.email,\n phone: identity.phone ?? event.phone,\n company: identity.company,\n message: formData['message'] ?? formData['comments'] ?? formData['comment'],\n extra: mapExtra(formData, config.fieldMap),\n attribution: mapAttribution(event, config.trackingFields),\n };\n}\n","/**\n * Zoho OAuth2 refresh-token exchange, for Bigin.\n *\n * Not `google/token.ts`: that module imports `node:crypto` to sign the\n * service-account JWT assertion, which makes it unusable in a Worker. Zoho's\n * self-client grant is a plain `application/x-www-form-urlencoded` POST, so\n * this module has no such dependency and can run on every submission.\n *\n * Cached per isolate, keyed on the refresh token, with an expiry margin — a\n * Worker isolate can field many submissions before it's recycled, and minting\n * a fresh access token on every one would be a needless round trip Bigin's\n * own rate limit does not forgive kindly.\n */\n\nexport interface ZohoDataCenterHosts {\n accounts: string;\n}\n\nconst ZOHO_ACCOUNTS_HOSTS: Record<string, string> = {\n com: 'accounts.zoho.com',\n ca: 'accounts.zohocloud.ca',\n eu: 'accounts.zoho.eu',\n in: 'accounts.zoho.in',\n 'com.au': 'accounts.zoho.com.au',\n jp: 'accounts.zoho.jp',\n};\n\nexport function zohoAccountsHost(dataCenter: string): string {\n return ZOHO_ACCOUNTS_HOSTS[dataCenter] ?? ZOHO_ACCOUNTS_HOSTS.com!;\n}\n\n/** Refresh a few seconds before Zoho actually expires it, so a token that's\n * about to lapse mid-request is never handed out. */\nconst EXPIRY_MARGIN_MS = 60_000;\n\ninterface CachedToken {\n accessToken: string;\n expiresAt: number;\n}\n\nconst cache = new Map<string, CachedToken>();\n\nexport interface ZohoOAuth {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Exchange a refresh token for a short-lived Bigin access token, reusing a\n * cached one while it's still valid.\n *\n * `now` is a parameter so cache expiry is testable without a fake clock, the\n * same convention `google/token.ts`'s `googleAccessToken` uses.\n */\nexport async function zohoAccessToken(\n oauth: ZohoOAuth,\n dataCenter: string,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const cached = cache.get(oauth.refreshToken);\n if (cached && cached.expiresAt > now) return cached.accessToken;\n\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: oauth.clientId,\n client_secret: oauth.clientSecret,\n refresh_token: oauth.refreshToken,\n });\n\n const res = await fetchImpl(`https://${zohoAccountsHost(dataCenter)}/oauth/v2/token`, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n });\n\n if (!res.ok) {\n throw new Error(`Zoho token exchange failed (${res.status}): ${await res.text()}`);\n }\n\n const json = (await res.json()) as { access_token?: string; expires_in?: number; error?: string };\n if (!json.access_token) {\n throw new Error(\n `Zoho token exchange returned no access_token: ${json.error ?? 'unknown error'}`,\n );\n }\n\n const expiresAt = now + (json.expires_in ?? 3600) * 1000 - EXPIRY_MARGIN_MS;\n cache.set(oauth.refreshToken, { accessToken: json.access_token, expiresAt });\n return json.access_token;\n}\n\n/** Test-only: clear the module-level cache between cases. */\nexport function clearZohoTokenCache(): void {\n cache.clear();\n}\n","/**\n * Zoho Bigin executor — decides nothing except the one thing it must refuse\n * to send: a record with no derivable last name (see `deriveLastName` in\n * `map.ts`). Everything else is \"build the request, post it, classify the\n * response.\"\n *\n * `upsert` with `duplicate_check_fields: ['Email']` (configurable) rather than\n * plain `Contacts` create — a visitor resubmitting the same form (a second\n * enquiry, a retried failed submission) should update one contact, not create\n * a duplicate every time.\n */\nimport { deriveLastName } from './map.ts';\nimport type { BiginConfig, BiginCredentials, CrmRecord, CrmResult } from './types.ts';\nimport { zohoAccessToken } from './zoho-token.ts';\n\nconst ZOHO_API_HOSTS: Record<string, string> = {\n com: 'www.zohoapis.com',\n ca: 'www.zohoapis.ca',\n eu: 'www.zohoapis.eu',\n in: 'www.zohoapis.in',\n 'com.au': 'www.zohoapis.com.au',\n jp: 'www.zohoapis.jp',\n};\n\nexport function zohoApiHost(dataCenter: string): string {\n return ZOHO_API_HOSTS[dataCenter] ?? ZOHO_API_HOSTS.com!;\n}\n\nfunction biginFields(record: CrmRecord, lastName: string): Record<string, unknown> {\n return {\n Last_Name: lastName,\n ...(record.firstName ? { First_Name: record.firstName } : {}),\n ...(record.email ? { Email: record.email } : {}),\n ...(record.phone ? { Phone: record.phone } : {}),\n ...(record.company ? { Account_Name: record.company } : {}),\n ...(record.message ? { Description: record.message } : {}),\n ...record.extra,\n ...record.attribution,\n Lead_Source: 'Website',\n };\n}\n\n/**\n * Write one record to Bigin. Never throws — a token-exchange failure, a\n * network error and a rejected response all come back as `CrmResult`.\n */\nexport async function submitToBigin(\n record: CrmRecord,\n config: BiginConfig,\n credentials: BiginCredentials,\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const derived = deriveLastName({\n firstName: record.firstName,\n lastName: record.lastName,\n fullName: [record.firstName, record.lastName].filter(Boolean).join(' ') || record.email || '',\n email: record.email,\n });\n if ('refusal' in derived) {\n return { ok: false, provider: 'bigin', reason: derived.refusal };\n }\n\n let token: string;\n try {\n token = await zohoAccessToken(credentials, config.dataCenter, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Zoho token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const host = zohoApiHost(config.dataCenter);\n const url = `https://${host}/bigin/v2/Contacts/upsert`;\n const body = {\n data: [biginFields(record, derived.lastName)],\n duplicate_check_fields: [config.duplicateCheckField ?? 'Email'],\n trigger: ['workflow'],\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Zoho-oauthtoken ${token}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (err) {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Bigin request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const text = await res.text().catch(() => '');\n if (!res.ok) {\n return {\n ok: false,\n provider: 'bigin',\n code: String(res.status),\n reason: `Bigin responded ${res.status}: ${text}`,\n };\n }\n\n let json: { data?: { code?: string; status?: string; details?: { id?: string } }[] };\n try {\n json = JSON.parse(text);\n } catch {\n return {\n ok: false,\n provider: 'bigin',\n reason: `Bigin returned an unparsable response: ${text}`,\n };\n }\n\n const entry = json.data?.[0];\n if (!entry || entry.status !== 'success') {\n return {\n ok: false,\n provider: 'bigin',\n code: entry?.code,\n reason: `Bigin upsert did not succeed: ${text}`,\n };\n }\n\n const contactId = entry.details?.id;\n\n // The pipeline write is additive and best-effort: the contact already\n // exists, so a failure here is a lost pipeline card, not a lost lead. It\n // never downgrades the overall `ok: true` the contact write already earned.\n if (config.pipeline?.enabled && contactId) {\n await createPipelineRecord(record, config, contactId, host, token, fetchImpl, timeoutMs).catch(\n () => undefined,\n );\n }\n\n return { ok: true, provider: 'bigin', recordId: contactId };\n}\n\nasync function createPipelineRecord(\n record: CrmRecord,\n config: BiginConfig,\n contactId: string,\n host: string,\n token: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<void> {\n const pipeline = config.pipeline!;\n const body = {\n data: [\n {\n Pipeline_Name: record.company || record.email || 'Website enquiry',\n Contact_Name: contactId,\n ...(pipeline.subPipeline ? { Sub_Pipeline: pipeline.subPipeline } : {}),\n ...(pipeline.stage ? { Stage: pipeline.stage } : {}),\n },\n ],\n trigger: ['workflow'],\n };\n await fetchImpl(`https://${host}/bigin/v2/Pipelines`, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Zoho-oauthtoken ${token}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n}\n","/**\n * HubSpot executor — decides nothing, posts what it's told.\n *\n * Uses the **secure** submissions endpoint (`/submissions/v3/integration/\n * secure/submit/{portalId}/{formGuid}`, private-app Bearer token) rather than\n * the public forms-embed endpoint. That distinction is load-bearing: posting\n * to the secure endpoint registers a real *form submission* against the\n * portal's form, so HubSpot's own workflows, lifecycle-stage automation and\n * attribution reporting fire exactly as if the client's hosted form had been\n * used — the public endpoint has no such effect and is also the one that\n * rejects a field not already declared on the form (see `CrmRecord`'s\n * drop-if-unmapped rule, which exists partly because of that).\n */\nimport type { CrmRecord, CrmResult, HubSpotConfig, HubSpotCredentials } from './types.ts';\n\nconst HUBSPOT_HOSTS: Record<NonNullable<HubSpotConfig['dataCenter']>, string> = {\n na: 'api.hsforms.com',\n eu: 'api-eu1.hsforms.com',\n};\n\ninterface HubSpotField {\n objectTypeId: '0-1';\n name: string;\n value: string;\n}\n\nfunction fieldsFor(record: CrmRecord): HubSpotField[] {\n const fields: HubSpotField[] = [];\n const push = (name: string, value: string | undefined) => {\n if (value) fields.push({ objectTypeId: '0-1', name, value });\n };\n push('firstname', record.firstName);\n push('lastname', record.lastName);\n push('email', record.email);\n push('phone', record.phone);\n push('company', record.company);\n push('message', record.message);\n for (const [name, value] of Object.entries(record.extra)) push(name, value);\n for (const [name, value] of Object.entries(record.attribution)) push(name, value);\n return fields;\n}\n\nexport interface HubSpotSubmitContext {\n hutk?: string | undefined;\n pageUri?: string | undefined;\n pageName?: string | undefined;\n}\n\n/**\n * Submit one record to HubSpot's secure forms endpoint.\n *\n * Never throws — a network failure or an unparsable response both come back\n * as a `CrmResult` with `ok: false`, the same \"structured result, not an\n * exception\" rule `security/turnstile.ts` and `indexing/gsc.ts` follow. Status\n * classification: 401 is a rejected/expired token; 400 naming\n * `MISSING_REQUIRED_FIELDS` or an unrecognised field name is a mapping\n * problem (`fieldMap`/`trackingFields` naming a property that isn't on the\n * form); 429 is the per-portal rate limit.\n */\nexport async function submitToHubSpot(\n record: CrmRecord,\n config: HubSpotConfig,\n credentials: HubSpotCredentials,\n context: HubSpotSubmitContext = {},\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const host = HUBSPOT_HOSTS[config.dataCenter ?? 'na'];\n const url = `https://${host}/submissions/v3/integration/secure/submit/${config.portalId}/${config.formGuid}`;\n\n const body = {\n fields: fieldsFor(record),\n context: {\n ...(context.hutk ? { hutk: context.hutk } : {}),\n ...(context.pageUri ? { pageUri: context.pageUri } : {}),\n ...(context.pageName ? { pageName: context.pageName } : {}),\n },\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${credentials.accessToken}`,\n },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (err) {\n return {\n ok: false,\n provider: 'hubspot',\n reason: `HubSpot request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (res.ok) {\n return { ok: true, provider: 'hubspot' };\n }\n\n const text = await res.text().catch(() => '');\n\n if (res.status === 401) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '401',\n reason: `HubSpot rejected the access token: ${text}`,\n };\n }\n if (res.status === 429) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '429',\n reason: 'HubSpot rate limit exceeded for this portal.',\n };\n }\n if (\n res.status === 400 &&\n /MISSING_REQUIRED_FIELDS|not.*(a|an).*(valid|known).*field/i.test(text)\n ) {\n return {\n ok: false,\n provider: 'hubspot',\n code: '400',\n reason: `HubSpot rejected the mapped fields — check \\`fieldMap\\`/\\`trackingFields\\` against the form's own fields: ${text}`,\n };\n }\n\n return {\n ok: false,\n provider: 'hubspot',\n code: String(res.status),\n reason: `HubSpot responded ${res.status}: ${text}`,\n };\n}\n","/**\n * Jobber OAuth2 refresh-token exchange.\n *\n * A plain `application/x-www-form-urlencoded` POST, same shape as Zoho's —\n * see `zoho-token.ts` for why that keeps this Worker-safe (no `node:crypto`,\n * unlike `google/token.ts`'s service-account JWT).\n *\n * One thing Zoho's grant never does: Jobber's Refresh Token Rotation is ON by\n * default for a new app, and when it's on, every exchange returns a *new*\n * refresh token and invalidates the old one. A Worker reading a static env\n * var (`VITOPS_JOBBER_REFRESH_TOKEN`) cannot persist that new value, so this\n * integration requires rotation turned OFF in the app's Developer Center\n * settings — a legitimate setup requirement, since a per-client private app\n * is never published to Jobber's Marketplace (where rotation ON is\n * mandatory). `zohoAccessToken`'s Zoho equivalent has no such failure mode to\n * detect, because Zoho's grant is long-lived by default.\n *\n * Cached per isolate, keyed on the refresh token, with an expiry margin —\n * same rationale as `zoho-token.ts`.\n */\n\nconst JOBBER_OAUTH_TOKEN_URL = 'https://api.getjobber.com/api/oauth/token';\n\n/** Refresh a few seconds before Jobber actually expires it (access tokens\n * last 60 minutes), so a token that's about to lapse mid-request is never\n * handed out. */\nconst EXPIRY_MARGIN_MS = 60_000;\n\ninterface CachedToken {\n accessToken: string;\n expiresAt: number;\n}\n\nconst cache = new Map<string, CachedToken>();\n\nexport interface JobberOAuth {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Exchange a refresh token for a short-lived Jobber access token, reusing a\n * cached one while it's still valid.\n *\n * Throws a specific, actionable error when Jobber's response carries a\n * *different* refresh token than the one sent — the rotation case above.\n * Without this check, rotation surfaces an hour later as an opaque 401 on a\n * lead that is already lost; with it, the first submission after rotation\n * turns on names the fix.\n *\n * `now` is a parameter so cache expiry is testable without a fake clock, the\n * same convention `zoho-token.ts`'s `zohoAccessToken` uses.\n */\nexport async function jobberAccessToken(\n oauth: JobberOAuth,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const cached = cache.get(oauth.refreshToken);\n if (cached && cached.expiresAt > now) return cached.accessToken;\n\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: oauth.clientId,\n client_secret: oauth.clientSecret,\n refresh_token: oauth.refreshToken,\n });\n\n const res = await fetchImpl(JOBBER_OAUTH_TOKEN_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n });\n\n if (!res.ok) {\n throw new Error(`Jobber token exchange failed (${res.status}): ${await res.text()}`);\n }\n\n const json = (await res.json()) as {\n access_token?: string;\n refresh_token?: string;\n expires_in?: number;\n error?: string;\n };\n if (!json.access_token) {\n throw new Error(\n `Jobber token exchange returned no access_token: ${json.error ?? 'unknown error'}`,\n );\n }\n if (json.refresh_token && json.refresh_token !== oauth.refreshToken) {\n throw new Error(\n 'Jobber returned a rotated refresh token. Turn Refresh Token Rotation off for this app ' +\n \"in Jobber's Developer Center — a rotated token cannot be persisted from a stateless \" +\n 'Worker reading a static VITOPS_JOBBER_REFRESH_TOKEN.',\n );\n }\n\n const expiresAt = now + (json.expires_in ?? 3600) * 1000 - EXPIRY_MARGIN_MS;\n cache.set(oauth.refreshToken, { accessToken: json.access_token, expiresAt });\n return json.access_token;\n}\n\n/** Test-only: clear the module-level cache between cases. */\nexport function clearJobberTokenCache(): void {\n cache.clear();\n}\n","/**\n * Jobber executor — a GraphQL API, unlike both existing REST executors\n * (`hubspot.ts`, `bigin.ts`), and that difference is why this file classifies\n * differently from either of them.\n *\n * **GraphQL answers `200 OK` for failures.** A malformed query or a\n * permission/version problem returns a top-level `errors[]`; a rejected\n * mutation input returns `data.clientCreate.userErrors[]` — both with HTTP\n * 200. So classification here is four layers, in order: transport error →\n * HTTP status (401 credential, 429 rate limit) → top-level `errors[]` (a\n * query/permission/version problem) → `userErrors[]` (a mapping problem: an\n * unknown field, a missing required attribute). Only past all four is\n * `ok: true`.\n *\n * **Only `clientCreate` is verified against Jobber's own published docs.**\n * Two shapes here are NOT: `noteCreate` (Jobber's changelog records\n * `requestNoteCreate`/`requestUpsert` being *removed*, so the note surface is\n * genuinely in flux) and the `clients` query's dedupe filter argument. Until\n * both are checked against a live account's GraphiQL schema:\n * - the note write is wrapped in `.catch(() => undefined)`, exactly like\n * Bigin's `createPipelineRecord` — a failure here loses the note, never\n * the already-written client, and never downgrades `ok: true`;\n * - `dedupe` defaults to `false` rather than the eventually-intended `true`,\n * so a misshapen filter never silently blocks a real submission. Turning\n * it on is safe once the query is confirmed; document the confirmed\n * shape in this docblock when that happens.\n *\n * Attribution (`record.attribution`) and unmapped extras (`record.extra`)\n * are rendered into the note body by default — that works on every Jobber\n * plan. `config.jobber` carries no separate custom-field wiring in this\n * pass: Jobber's app-configured custom fields need the Connect plan or\n * higher and their `clientCreate` input shape is a further unverified\n * surface, so `fieldMap`/`trackingFields` values still land as note lines\n * rather than a guessed custom-field mutation input.\n */\nimport type { CrmRecord, CrmResult, JobberConfig, JobberCredentials } from './types.ts';\nimport { jobberAccessToken } from './jobber-token.ts';\n\nconst JOBBER_GRAPHQL_ENDPOINT = 'https://api.getjobber.com/api/graphql';\n\n/** Pinned to the version this integration was built and tested against.\n * Jobber's `X-JOBBER-GRAPHQL-VERSION` header is mandatory — an unversioned\n * request is rejected outright — so this is code, not something left to\n * config to supply by default. `config.jobber.apiVersion` is the escape\n * hatch for a client's app pinned to a different one. */\nconst JOBBER_API_VERSION = '2025-04-16';\n\ninterface JobberErrorItem {\n message: string;\n}\n\ninterface ClientCreateResponse {\n data?: {\n clientCreate?: {\n client?: { id: string } | null;\n userErrors?: JobberErrorItem[];\n } | null;\n };\n errors?: JobberErrorItem[];\n}\n\nfunction clientCreateInput(record: CrmRecord): Record<string, unknown> {\n return {\n ...(record.firstName ? { firstName: record.firstName } : {}),\n ...(record.lastName ? { lastName: record.lastName } : {}),\n ...(record.company ? { companyName: record.company } : {}),\n ...(record.email\n ? { emails: [{ description: 'MAIN', primary: true, address: record.email }] }\n : {}),\n ...(record.phone\n ? { phones: [{ description: 'MAIN', primary: true, number: record.phone }] }\n : {}),\n };\n}\n\n/** Note body: the submitted message plus every attribution/extra field that\n * has no dedicated Jobber field, so nothing is lost to the note-vs-field\n * split above. Empty sections are omitted rather than rendered blank. */\nfunction noteBody(record: CrmRecord, pageUri: string | undefined): string {\n const lines: string[] = [];\n if (record.message) lines.push(record.message);\n const extraEntries = Object.entries(record.extra);\n if (extraEntries.length) {\n lines.push('', ...extraEntries.map(([k, v]) => `${k}: ${v}`));\n }\n const attributionEntries = Object.entries(record.attribution);\n if (attributionEntries.length) {\n lines.push('', 'Attribution:', ...attributionEntries.map(([k, v]) => `${k}: ${v}`));\n }\n if (pageUri) lines.push('', `Submitted from ${pageUri}`);\n return lines.join('\\n').trim();\n}\n\nasync function graphql(\n query: string,\n variables: Record<string, unknown>,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<Response> {\n return fetchImpl(JOBBER_GRAPHQL_ENDPOINT, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${token}`,\n 'x-jobber-graphql-version': apiVersion,\n },\n body: JSON.stringify({ query, variables }),\n signal: AbortSignal.timeout(timeoutMs),\n });\n}\n\nconst CLIENT_CREATE_MUTATION = `\n mutation CrmClientCreate($input: ClientCreateInput!) {\n clientCreate(input: $input) {\n client { id }\n userErrors { message }\n }\n }\n`;\n\n/** Best-effort — see the module docblock's note on the unverified\n * `noteCreate` shape. Any failure (transport, GraphQL error, or the mutation\n * simply not existing under this name) is swallowed by the caller. */\nconst NOTE_CREATE_MUTATION = `\n mutation CrmNoteCreate($input: NoteCreateInput!) {\n noteCreate(input: $input) {\n note { id }\n userErrors { message }\n }\n }\n`;\n\nasync function createNote(\n clientId: string,\n message: string,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<void> {\n if (!message) return;\n const res = await graphql(\n NOTE_CREATE_MUTATION,\n { input: { clientId, message } },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n const json = (await res.json()) as {\n errors?: JobberErrorItem[];\n data?: { noteCreate?: { userErrors?: JobberErrorItem[] } };\n };\n if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join('; '));\n const userErrors = json.data?.noteCreate?.userErrors;\n if (userErrors?.length) throw new Error(userErrors.map((e) => e.message).join('; '));\n}\n\n/** Best-effort — see the module docblock's note on the unverified `clients`\n * filter shape. A failure (or the field simply not matching this account's\n * schema) means \"could not dedupe\", not \"stop the submission\" — falls\n * through to creating a new client. */\nconst FIND_CLIENT_BY_EMAIL_QUERY = `\n query CrmFindClientByEmail($searchTerm: String!) {\n clients(first: 1, searchTerm: $searchTerm) {\n nodes { id }\n }\n }\n`;\n\nasync function findExistingClientId(\n email: string,\n token: string,\n apiVersion: string,\n fetchImpl: typeof fetch,\n timeoutMs: number,\n): Promise<string | undefined> {\n const res = await graphql(\n FIND_CLIENT_BY_EMAIL_QUERY,\n { searchTerm: email },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n const json = (await res.json()) as {\n errors?: JobberErrorItem[];\n data?: { clients?: { nodes?: { id: string }[] } };\n };\n if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join('; '));\n return json.data?.clients?.nodes?.[0]?.id;\n}\n\n/**\n * Write one record to Jobber. Never throws — a token-exchange failure, a\n * network error, a top-level GraphQL `errors[]`, and a `userErrors[]` all\n * come back as a `CrmResult` with `ok: false`.\n */\nexport async function submitToJobber(\n record: CrmRecord,\n config: JobberConfig,\n credentials: JobberCredentials,\n pageUri: string | undefined,\n fetchImpl: typeof fetch = fetch,\n timeoutMs = 5_000,\n): Promise<CrmResult> {\n const apiVersion = config.apiVersion ?? JOBBER_API_VERSION;\n\n let token: string;\n try {\n token = await jobberAccessToken(credentials, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n // Dedupe defaults to false until the `clients` filter shape above is\n // verified against a live schema — see the module docblock.\n if (config.dedupe === true && record.email) {\n try {\n const existingId = await findExistingClientId(\n record.email,\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n if (existingId) {\n if (config.note?.enabled !== false) {\n await createNote(\n existingId,\n noteBody(record, pageUri),\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n ).catch(() => undefined);\n }\n return { ok: true, provider: 'jobber', recordId: existingId };\n }\n } catch {\n // Could not dedupe — proceed to create rather than fail the submission.\n }\n }\n\n let res: Response;\n try {\n res = await graphql(\n CLIENT_CREATE_MUTATION,\n { input: clientCreateInput(record) },\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n );\n } catch (err) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (res.status === 401) {\n return {\n ok: false,\n provider: 'jobber',\n code: '401',\n reason: `Jobber rejected the access token: ${await res.text().catch(() => '')}`,\n };\n }\n if (res.status === 429) {\n return {\n ok: false,\n provider: 'jobber',\n code: '429',\n reason: 'Jobber rate limit exceeded (2,500 requests per 5 minutes).',\n };\n }\n if (!res.ok) {\n return {\n ok: false,\n provider: 'jobber',\n code: String(res.status),\n reason: `Jobber responded ${res.status}: ${await res.text().catch(() => '')}`,\n };\n }\n\n let json: ClientCreateResponse;\n try {\n json = (await res.json()) as ClientCreateResponse;\n } catch {\n return { ok: false, provider: 'jobber', reason: 'Jobber returned an unparsable response.' };\n }\n\n if (json.errors?.length) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber rejected the request: ${json.errors.map((e) => e.message).join('; ')}`,\n };\n }\n\n const userErrors = json.data?.clientCreate?.userErrors;\n if (userErrors?.length) {\n return {\n ok: false,\n provider: 'jobber',\n reason: `Jobber rejected the mapped fields: ${userErrors.map((e) => e.message).join('; ')}`,\n };\n }\n\n const clientId = json.data?.clientCreate?.client?.id;\n if (!clientId) {\n return { ok: false, provider: 'jobber', reason: 'Jobber did not return a client id.' };\n }\n\n if (config.note?.enabled !== false) {\n await createNote(\n clientId,\n noteBody(record, pageUri),\n token,\n apiVersion,\n fetchImpl,\n timeoutMs,\n ).catch(() => undefined);\n }\n\n return { ok: true, provider: 'jobber', recordId: clientId };\n}\n","/**\n * Every decision about *whether* a submission reaches a CRM, as one pure\n * function.\n *\n * Pure in the load-bearing sense: no `fetch`, no clock, no credential lookup\n * beyond what the caller already resolved. Mirrors `notify/plan.ts`'s\n * `ChannelPlan` shape — `enabled`, or `false` with a stated `skip` — so `--dry`\n * is a complete account and a misconfigured site can be told exactly why\n * nothing will be written, rather than discovering it as a silently missing\n * contact.\n *\n * What this file does NOT decide: whether *this* submission's write succeeds.\n * That is `map.ts` (shaping the data) and the executors (`hubspot.ts`/\n * `bigin.ts`/`jobber.ts`, making the call) — this only decides whether the\n * channel is on at all, from config and credentials.\n */\nimport type { CrmConfig, CrmCredentials, CrmPlan, CrmProvider } from './types.ts';\n\n/**\n * What each provider requires to be considered configured, as one table\n * rather than a per-provider `if`. A provider missing here is a type error —\n * `Record<CrmProvider, …>` — rather than a silently-enabled channel with no\n * config block, which is what the equivalent hand-written checks would do for\n * a provider nobody added a branch for.\n */\nconst PROVIDER_REQUIREMENTS: Record<\n CrmProvider,\n { block: 'hubspot' | 'bigin' | 'jobber'; credentialHint: string }\n> = {\n hubspot: { block: 'hubspot', credentialHint: 'No HubSpot access token configured.' },\n bigin: { block: 'bigin', credentialHint: 'No Zoho OAuth credentials configured.' },\n jobber: { block: 'jobber', credentialHint: 'No Jobber OAuth credentials configured.' },\n};\n\nexport function planCrm(\n config: CrmConfig | undefined,\n credentials: CrmCredentials | undefined,\n): CrmPlan {\n if (!config) {\n return { enabled: false, skip: 'No `crm` configured.' };\n }\n\n if (config.enabled === false) {\n return { enabled: false, provider: config.provider, skip: '`crm.enabled` is false.' };\n }\n\n const requirement = PROVIDER_REQUIREMENTS[config.provider];\n if (!config[requirement.block]) {\n return {\n enabled: false,\n provider: config.provider,\n skip: `\\`crm.provider\\` is \"${config.provider}\" but \\`crm.${requirement.block}\\` is missing.`,\n };\n }\n\n if (!credentials) {\n return {\n enabled: false,\n provider: config.provider,\n skip: requirement.credentialHint,\n };\n }\n\n if (credentials.provider !== config.provider) {\n return {\n enabled: false,\n provider: config.provider,\n skip: `\\`crm.provider\\` is \"${config.provider}\" but the supplied credentials are for \"${credentials.provider}\".`,\n };\n }\n\n return { enabled: true, provider: config.provider };\n}\n","/**\n * The CRM module's own option surface.\n *\n * Deliberately structural rather than `Config['site']['crm']` imported from\n * `@getvitops/generator`: the generator already depends on this package, so\n * importing back would be a cycle. The vocabulary mirrors that block field for\n * field — the same arrangement, and for the same reason, as `IndexingConfig`\n * and `NotifyContext` beside it.\n *\n * Optional fields are written `?: T | undefined` throughout: `exactOptionalPropertyTypes`\n * is on, and these describe a *parsed JSON config* where an absent key and an\n * explicit `undefined` are the same thing.\n */\n\nexport type CrmProvider = 'hubspot' | 'bigin' | 'jobber';\n\n/** Human-readable label per provider, for the notification email's Delivery\n * line (`describeCrmResult`) and anywhere else a provider must be named\n * without a hand-written ternary. Exhaustive by type — a provider missing\n * here is a compile error, not a mislabelled email. */\nexport const PROVIDER_LABELS: Record<CrmProvider, string> = {\n hubspot: 'HubSpot',\n bigin: 'Bigin',\n jobber: 'Jobber',\n};\n\n/** Bigin's data centre — the region a self-client's tokens and API host belong to. */\nexport type BiginDataCenter = 'com' | 'ca' | 'eu' | 'in' | 'com.au' | 'jp';\n\nexport interface HubSpotConfig {\n portalId: string;\n formGuid: string;\n /** Default `na`. Only affects the submissions API host. */\n dataCenter?: 'na' | 'eu' | undefined;\n}\n\nexport interface BiginPipelineConfig {\n enabled: boolean;\n subPipeline?: string | undefined;\n stage?: string | undefined;\n}\n\nexport interface BiginConfig {\n dataCenter: BiginDataCenter;\n /** Default `Email` — Bigin's own unique field, used for `upsert`'s dedupe. */\n duplicateCheckField?: string | undefined;\n pipeline?: BiginPipelineConfig | undefined;\n}\n\nexport interface JobberConfig {\n /**\n * The pinned `X-JOBBER-GRAPHQL-VERSION` date this integration was built and\n * tested against. Overriding it is an escape hatch for a client's app\n * pinned to a different version — not something to reach for casually,\n * since Jobber's schema can change field names between versions.\n */\n apiVersion?: string | undefined;\n /**\n * Look up an existing client by email before creating one (default\n * **false**). Jobber's `clientCreate` has no upsert, so a resubmitting\n * visitor creates a second client unless this runs — but the lookup query's\n * filter shape is not yet verified against a live Jobber schema (see\n * `jobber.ts`'s docblock), so it defaults off rather than risk a wrong\n * query silently blocking a real submission. Set `true` once verified for\n * your account.\n */\n dedupe?: boolean | undefined;\n note?:\n | {\n /** Attach a best-effort note carrying the message + unmapped\n * attribution (default true). A failure here never downgrades the\n * client write's own success — see `jobber.ts`. */\n enabled?: boolean | undefined;\n }\n | undefined;\n}\n\nexport interface CrmConfig {\n provider: CrmProvider;\n enabled?: boolean | undefined;\n hubspot?: HubSpotConfig | undefined;\n bigin?: BiginConfig | undefined;\n jobber?: JobberConfig | undefined;\n /** Submitted field name → CRM property name. Unmapped fields are dropped, not guessed. */\n fieldMap?: Record<string, string> | undefined;\n /** `_ac` tracking key → CRM property name. Same drop-if-unmapped rule. */\n trackingFields?: Record<string, string> | undefined;\n}\n\n/** Credentials, never config — always injected by the caller from env vars. */\nexport interface HubSpotCredentials {\n accessToken: string;\n}\n\nexport interface BiginCredentials {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Jobber's self-client OAuth credential. The refresh token is expected to be\n * static — Refresh Token Rotation must be OFF in the app's Developer Center\n * settings, since a Worker reading a static env var cannot persist a rotated\n * one. See `jobber-token.ts` for the detection when a client's app has it on\n * anyway.\n */\nexport interface JobberCredentials {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\nexport type CrmCredentials =\n | { provider: 'hubspot'; hubspot: HubSpotCredentials }\n | { provider: 'bigin'; bigin: BiginCredentials }\n | { provider: 'jobber'; jobber: JobberCredentials };\n\n/** A channel's decision: on, or off with a stated reason. Mirrors `ChannelPlan`. */\nexport interface CrmPlan {\n enabled: boolean;\n provider?: CrmProvider | undefined;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string | undefined;\n}\n\n/** A provider-neutral record — what a submission maps to, before either executor\n * renders it into that CRM's own request shape. */\nexport interface CrmRecord {\n firstName?: string | undefined;\n lastName?: string | undefined;\n email?: string | undefined;\n phone?: string | undefined;\n company?: string | undefined;\n message?: string | undefined;\n /** Mapped fields beyond the identity ones above, keyed by the CRM property name. */\n extra: Record<string, string>;\n /** `trackingFields`-mapped attribution, keyed by the CRM property name. Empty\n * when the visitor arrived with no `_ac` cookie — never an error. */\n attribution: Record<string, string>;\n}\n\n/**\n * The outcome of one delivery attempt. Never thrown — a CRM write is\n * best-effort and its failure is a fact for the caller to log or notify with,\n * not an exception to propagate into the request that produced the submission.\n */\nexport interface CrmResult {\n ok: boolean;\n provider: CrmProvider;\n /** The created/updated record's id, when the CRM returned one. */\n recordId?: string | undefined;\n /** Why it didn't write, or what the classification of the failure was. */\n reason?: string | undefined;\n /** The provider's own error/status code, when it gave one. */\n code?: string | undefined;\n}\n\n/** What `mapSubmission` needs, gathered by the caller once per site. */\nexport interface CrmMapContext {\n config: CrmConfig;\n}\n","/**\n * CRM form-submission delivery — plan, map, dispatch.\n *\n * The seam between \"a form was submitted\" and \"there's a contact in the\n * client's CRM for it.\" A `ConversionEvent` from `@getvitops/utils/tracking`\n * goes in — the same event `notify` already turns into an email — and where\n * it goes and how it's shaped are config, not code at the call site.\n *\n * Pure-planner / executor split, matching `notify/`, `indexing/`, `ads/`:\n * - `plan.ts` decides whether the channel is on at all, purely.\n * - `map.ts` decides how one submission becomes a `CrmRecord`, purely.\n * - `hubspot.ts` / `bigin.ts` / `jobber.ts` execute, and decide nothing\n * beyond the one thing Bigin must refuse (see `deriveLastName`) and the\n * four-layer classification `jobber.ts`'s GraphQL transport needs.\n *\n * `deliverToCrm` never throws: a CRM outage must not lose a lead, so the\n * caller (`createConversionRoute`) always gets a `CrmResult` back to log or\n * fold into the notification email, never an exception to catch.\n *\n * TODO — no generic connector framework. Three CRMs with three genuinely\n * different transports (a form-submission POST, a REST upsert, a GraphQL\n * mutation) is evidence against a shared abstraction, not for one — matching\n * `notify`'s own restraint about its unimplemented channels.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { submitToBigin } from './bigin.ts';\nimport { submitToHubSpot, type HubSpotSubmitContext } from './hubspot.ts';\nimport { submitToJobber } from './jobber.ts';\nimport { mapSubmission } from './map.ts';\nimport { planCrm } from './plan.ts';\nimport type { CrmConfig, CrmCredentials, CrmResult } from './types.ts';\nimport { PROVIDER_LABELS } from './types.ts';\n\nexport interface CrmDeps {\n credentials?: CrmCredentials | undefined;\n fetch?: typeof fetch | undefined;\n timeoutMs?: number | undefined;\n /**\n * The visitor's page context, provider-neutral. HubSpot reads `hutk` (its\n * own session cookie) to stitch the submission to its tracking; Jobber\n * reads `pageUri` for the note body. Not every provider uses every field —\n * an unused one is simply ignored, which is why this is one shape rather\n * than a per-provider option the caller has to pick correctly.\n */\n pageContext?: HubSpotSubmitContext | undefined;\n}\n\nexport async function deliverToCrm(\n event: ConversionEvent,\n config: CrmConfig | undefined,\n deps: CrmDeps = {},\n): Promise<CrmResult | null> {\n const plan = planCrm(config, deps.credentials);\n if (!plan.enabled || !config || !deps.credentials) {\n // `null` distinguishes \"no CRM configured for this site\" from a failed\n // write — a caller building a notification's delivery notes can choose to\n // say nothing at all rather than \"Not written to (no provider)\".\n return config ? { ok: false, provider: config.provider, reason: plan.skip } : null;\n }\n\n const record = mapSubmission(event, config);\n const fetchImpl = deps.fetch ?? fetch;\n const timeoutMs = deps.timeoutMs ?? 5_000;\n const credentials = deps.credentials;\n\n switch (config.provider) {\n case 'hubspot':\n if (credentials.provider !== 'hubspot') break;\n return submitToHubSpot(\n record,\n config.hubspot!,\n credentials.hubspot,\n deps.pageContext,\n fetchImpl,\n timeoutMs,\n );\n case 'bigin':\n if (credentials.provider !== 'bigin') break;\n return submitToBigin(record, config.bigin!, credentials.bigin, fetchImpl, timeoutMs);\n case 'jobber':\n if (credentials.provider !== 'jobber') break;\n return submitToJobber(\n record,\n config.jobber ?? {},\n credentials.jobber,\n deps.pageContext?.pageUri,\n fetchImpl,\n timeoutMs,\n );\n default: {\n // Exhaustiveness: a provider added to `CrmProvider` with no case above\n // fails the build here, rather than silently falling through to the\n // generic mismatch result below.\n const _exhaustive: never = config.provider;\n return _exhaustive;\n }\n }\n\n return {\n ok: false,\n provider: config.provider,\n reason: plan.skip ?? 'Credentials/provider mismatch.',\n };\n}\n\n/** A one-line, human-readable delivery note — what `conversion.ts` folds into\n * the notification email's \"Delivery\" section. */\nexport function describeCrmResult(result: CrmResult | null): string | null {\n if (!result) return null;\n const name = PROVIDER_LABELS[result.provider];\n if (result.ok) {\n return result.recordId\n ? `Written to ${name} (contact ${result.recordId})`\n : `Written to ${name}`;\n }\n return `⚠ Not written to ${name}: ${result.reason ?? 'unknown reason'}`;\n}\n\nexport { deriveLastName, mapSubmission } from './map.ts';\nexport { planCrm } from './plan.ts';\nexport { submitToBigin, zohoApiHost } from './bigin.ts';\nexport { submitToHubSpot } from './hubspot.ts';\nexport { submitToJobber } from './jobber.ts';\nexport type { HubSpotSubmitContext } from './hubspot.ts';\nexport { clearZohoTokenCache, zohoAccessToken, zohoAccountsHost } from './zoho-token.ts';\nexport { clearJobberTokenCache, jobberAccessToken } from './jobber-token.ts';\nexport type { JobberOAuth } from './jobber-token.ts';\nexport { PROVIDER_LABELS } from './types.ts';\nexport type {\n BiginConfig,\n BiginCredentials,\n BiginDataCenter,\n BiginPipelineConfig,\n CrmConfig,\n CrmCredentials,\n CrmPlan,\n CrmProvider,\n CrmRecord,\n CrmResult,\n HubSpotConfig,\n HubSpotCredentials,\n JobberConfig,\n JobberCredentials,\n} from './types.ts';\n"],"mappings":"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"}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { o as backoffSchedule } from "../plan-Hq8Pb0Uw.mjs";
|
|
2
|
+
//#region src/email/types.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The email-onboarding module's own option surface.
|
|
5
|
+
*
|
|
6
|
+
* Structural rather than imported from `@getvitops/generator` for the usual
|
|
7
|
+
* reason — the generator already depends on this package — and rather than
|
|
8
|
+
* imported from `../notify/types.ts`, which describes what a *notification*
|
|
9
|
+
* channel sends, not what a *sending domain* needs onboarded. The two share a
|
|
10
|
+
* concept (a `from` address) but not a config shape.
|
|
11
|
+
*
|
|
12
|
+
* `EmailProvider` is open to more than `'cloudflare'` on purpose, unlike
|
|
13
|
+
* `EmailChannelConfig['provider']` in `notify/types.ts` (a closed `z.literal`
|
|
14
|
+
* there, since nothing else is implemented for *sending mail*). Checking
|
|
15
|
+
* whether a domain is *onboarded* is a narrower problem a second provider can
|
|
16
|
+
* answer without the notify channel itself changing shape.
|
|
17
|
+
*/
|
|
18
|
+
/** Providers this module knows how to check onboarding for. Only `cloudflare` is implemented. */
|
|
19
|
+
type EmailProvider = 'cloudflare' | 'ses' | 'sendgrid';
|
|
20
|
+
/** A `from` address worth checking, and where it came from. */
|
|
21
|
+
interface FromCandidate {
|
|
22
|
+
address: string;
|
|
23
|
+
domain: string;
|
|
24
|
+
/**
|
|
25
|
+
* `config` — `site.notifications.email.from` stated explicitly.
|
|
26
|
+
* `derived` — `resolveSender`'s `noreply@<canonical>` default.
|
|
27
|
+
* `scan` — found in source by the static scanner (see `scan.ts`).
|
|
28
|
+
*/
|
|
29
|
+
source: 'config' | 'derived' | 'scan';
|
|
30
|
+
}
|
|
31
|
+
/** One sending domain/subdomain's live state, as Cloudflare reports it. */
|
|
32
|
+
interface SendingDomainState {
|
|
33
|
+
name: string;
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
tag: string;
|
|
36
|
+
dkimSelector?: string | undefined;
|
|
37
|
+
returnPathDomain?: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** One DNS record Cloudflare expects for a sending domain, from `sendingDns`. */
|
|
40
|
+
interface ExpectedRecord {
|
|
41
|
+
type: 'MX' | 'TXT';
|
|
42
|
+
name: string;
|
|
43
|
+
content: string;
|
|
44
|
+
priority?: number | undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Everything `planSendingDomains` needs, once the CLI has read the config + scan. */
|
|
47
|
+
interface EmailConfig {
|
|
48
|
+
candidates: FromCandidate[];
|
|
49
|
+
provider: EmailProvider;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Live state for one zone, gathered by the executor before planning.
|
|
53
|
+
*
|
|
54
|
+
* `subdomains` is every sending name Cloudflare reports **in the domain's own
|
|
55
|
+
* zone** (there is no cross-zone lookup) — the planner needs the whole list,
|
|
56
|
+
* not just an exact-name match, so it can name a sibling that IS onboarded
|
|
57
|
+
* when the exact domain is not (the `send.<zone>` case).
|
|
58
|
+
*/
|
|
59
|
+
interface ZoneSendingState {
|
|
60
|
+
zoneId?: string | undefined;
|
|
61
|
+
subdomains: SendingDomainState[];
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/email/plan.d.ts
|
|
65
|
+
type StepAction = 'create' | 'skip' | 'blocked';
|
|
66
|
+
interface StepPlan {
|
|
67
|
+
action: StepAction;
|
|
68
|
+
/** Why it's a skip, what the mutation will do, or what a human should do instead. */
|
|
69
|
+
detail: string;
|
|
70
|
+
}
|
|
71
|
+
interface SendingDomainPlan {
|
|
72
|
+
domain: string;
|
|
73
|
+
/** Where this domain came from — config, derived, or the source scan. */
|
|
74
|
+
sources: FromCandidate['source'][];
|
|
75
|
+
onboard: StepPlan;
|
|
76
|
+
reminders: string[];
|
|
77
|
+
}
|
|
78
|
+
interface EmailPlan {
|
|
79
|
+
domains: SendingDomainPlan[];
|
|
80
|
+
notes: string[];
|
|
81
|
+
}
|
|
82
|
+
/** Decide one domain's step from its zone's observed sending names. */
|
|
83
|
+
export declare function planDomain(domain: string, provider: EmailProvider, state: ZoneSendingState | undefined): StepPlan;
|
|
84
|
+
/** Plan every candidate domain. One entry per distinct domain, sources merged. */
|
|
85
|
+
export declare function planSendingDomains(config: EmailConfig, states: Map<string, ZoneSendingState>): EmailPlan;
|
|
86
|
+
/** Any step that isn't a skip — i.e. drift, worth reporting. */
|
|
87
|
+
export declare function hasDrift(p: EmailPlan): boolean;
|
|
88
|
+
/** Every domain still blocked, for a summary that names what's outstanding. */
|
|
89
|
+
export declare function blockedDomains(p: EmailPlan): SendingDomainPlan[];
|
|
90
|
+
/** Render a plan as the block `--dry`/`--check`/`email check` print. */
|
|
91
|
+
export declare function formatPlan(p: EmailPlan): string;
|
|
92
|
+
/** The outcome of one domain's run, for the summary table. */
|
|
93
|
+
interface DomainResult {
|
|
94
|
+
domain: string;
|
|
95
|
+
onboard: 'created' | 'present' | 'blocked' | 'failed';
|
|
96
|
+
reminders: string[];
|
|
97
|
+
}
|
|
98
|
+
export declare function formatSummary(results: DomainResult[]): string;
|
|
99
|
+
/** One expected record vs. what DoH actually resolved for it. Pure — testable with no resolver. */
|
|
100
|
+
export declare function dnsStatus(expected: {
|
|
101
|
+
type: 'MX' | 'TXT';
|
|
102
|
+
name: string;
|
|
103
|
+
content: string;
|
|
104
|
+
}[], resolved: Map<string, string[]>): {
|
|
105
|
+
record: (typeof expected)[number];
|
|
106
|
+
present: boolean;
|
|
107
|
+
}[];
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/email/cloudflare.d.ts
|
|
110
|
+
/**
|
|
111
|
+
* Find the zone a host belongs to, walking labels from most to least specific —
|
|
112
|
+
* the same walk `wrangler`'s own `resolveDomain` does, because a sending name is
|
|
113
|
+
* usually a subdomain (`send.example.com`) and Cloudflare zones are registered at
|
|
114
|
+
* the apex. Tries `send.example.com`, then `example.com`, stopping at the first
|
|
115
|
+
* match. No public-suffix list: Cloudflare simply won't have a zone at a bare
|
|
116
|
+
* suffix like `co.uk`, so the walk is safe without one.
|
|
117
|
+
*/
|
|
118
|
+
export declare function findZoneForHost(token: string, host: string, fetchImpl?: typeof fetch): Promise<{
|
|
119
|
+
ok: boolean;
|
|
120
|
+
status: number;
|
|
121
|
+
zoneId?: string;
|
|
122
|
+
zoneName?: string;
|
|
123
|
+
message?: string;
|
|
124
|
+
}>;
|
|
125
|
+
/** Every sending name onboarded in one zone (`GET /zones/{id}/email/sending/subdomains`). */
|
|
126
|
+
export declare function listSendingSubdomains(token: string, zoneId: string, fetchImpl?: typeof fetch): Promise<{
|
|
127
|
+
ok: boolean;
|
|
128
|
+
status: number;
|
|
129
|
+
subdomains: {
|
|
130
|
+
name: string;
|
|
131
|
+
enabled: boolean;
|
|
132
|
+
tag: string;
|
|
133
|
+
dkimSelector?: string;
|
|
134
|
+
returnPathDomain?: string;
|
|
135
|
+
}[];
|
|
136
|
+
message?: string;
|
|
137
|
+
}>;
|
|
138
|
+
/**
|
|
139
|
+
* Enable Email Sending for a name (`POST … /subdomains`). Create only — there is
|
|
140
|
+
* no update/delete verb here, matching every other onboarding executor's
|
|
141
|
+
* never-remove contract; disabling a live sending domain is left to the operator.
|
|
142
|
+
*/
|
|
143
|
+
export declare function enableSending(token: string, zoneId: string, name: string, fetchImpl?: typeof fetch): Promise<{
|
|
144
|
+
ok: boolean;
|
|
145
|
+
status: number;
|
|
146
|
+
tag?: string;
|
|
147
|
+
message?: string;
|
|
148
|
+
}>;
|
|
149
|
+
/** The DNS records Cloudflare expects for one sending name (`… /subdomains/{id}/dns`). */
|
|
150
|
+
export declare function sendingDns(token: string, zoneId: string, subdomainId: string, fetchImpl?: typeof fetch): Promise<{
|
|
151
|
+
ok: boolean;
|
|
152
|
+
status: number;
|
|
153
|
+
records: {
|
|
154
|
+
type: 'MX' | 'TXT';
|
|
155
|
+
name: string;
|
|
156
|
+
content: string;
|
|
157
|
+
priority?: number;
|
|
158
|
+
}[];
|
|
159
|
+
message?: string;
|
|
160
|
+
}>;
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/email/dns.d.ts
|
|
163
|
+
/** Resolve TXT records for a name over DoH. `NXDOMAIN`/no-answer both return `[]`. */
|
|
164
|
+
export declare function resolveTxt(name: string, fetchImpl?: typeof fetch): Promise<{
|
|
165
|
+
ok: boolean;
|
|
166
|
+
contents: string[];
|
|
167
|
+
message?: string;
|
|
168
|
+
}>;
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/email/scan.d.ts
|
|
171
|
+
/**
|
|
172
|
+
* Best-effort static scan for `from` addresses in consumer source.
|
|
173
|
+
*
|
|
174
|
+
* Config already has the primary source of truth (`site.notifications.email.from`,
|
|
175
|
+
* plus `resolveSender`'s derived default) — this exists only to catch a hand-rolled
|
|
176
|
+
* `EMAIL.send()` call outside `createConversionRoute()`, which the config cannot see.
|
|
177
|
+
*
|
|
178
|
+
* Same judgement call as `icons-scan.ts`'s `extractIconRefs`, for the same reason:
|
|
179
|
+
* a computed value (`from: getSender()`, `from: { email: cfg.from }`) is REPORTED
|
|
180
|
+
* as dynamic, never guessed at. Guessing would produce a candidate list that is
|
|
181
|
+
* confidently wrong, which is worse than one that is honestly incomplete — and
|
|
182
|
+
* it is exactly the false-positive case `--skip`/`verifySender: false` exists for.
|
|
183
|
+
*/
|
|
184
|
+
/** A statically resolvable `from` address. */
|
|
185
|
+
interface FromRef {
|
|
186
|
+
address: string;
|
|
187
|
+
/** `bare` — `from: 'x@y.com'`. `object` — `from: { email: '…' }` / `{ address: '…' }`. */
|
|
188
|
+
kind: 'bare' | 'object';
|
|
189
|
+
file: string;
|
|
190
|
+
line: number;
|
|
191
|
+
}
|
|
192
|
+
/** A `from` value that cannot be read statically. */
|
|
193
|
+
interface DynamicFromRef {
|
|
194
|
+
expr: string;
|
|
195
|
+
file: string;
|
|
196
|
+
line: number;
|
|
197
|
+
}
|
|
198
|
+
interface FromScanResult {
|
|
199
|
+
refs: FromRef[];
|
|
200
|
+
dynamic: DynamicFromRef[];
|
|
201
|
+
/** Unique addresses, in first-seen order. */
|
|
202
|
+
addresses: string[];
|
|
203
|
+
}
|
|
204
|
+
/** Extract every `from` reference from one file's text. */
|
|
205
|
+
export declare function extractFromRefs(src: string, file?: string): {
|
|
206
|
+
refs: FromRef[];
|
|
207
|
+
dynamic: DynamicFromRef[];
|
|
208
|
+
};
|
|
209
|
+
/** Run `extractFromRefs` across a file set and collect unique addresses. */
|
|
210
|
+
export declare function collectFromAddresses(files: readonly {
|
|
211
|
+
path: string;
|
|
212
|
+
text: string;
|
|
213
|
+
}[]): FromScanResult;
|
|
214
|
+
//#endregion
|
|
215
|
+
export { type DomainResult, type DynamicFromRef, type EmailConfig, type EmailPlan, type EmailProvider, type ExpectedRecord, type FromCandidate, type FromRef, type FromScanResult, type SendingDomainPlan, type SendingDomainState, type StepAction, type StepPlan, type ZoneSendingState, backoffSchedule };
|
|
216
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/email/types.ts","../../src/email/plan.ts","../../src/email/cloudflare.ts","../../src/email/dns.ts","../../src/email/scan.ts"],"mappings":";;;;;;;;;;;;;;;;;;KAiBY;;UAGK;EACf;EACA;;;;;;EAMA;;;UAIe;EACf;EACA;EACA;EACA;EACA;;;UAIe;EACf;EACA;EACA;EACA;;;UAIe;EACf,YAAY;EACZ,UAAU;;;;;;;;;;UAWK;EACf;EACA,YAAY;;;;KCzCF;UAEK;EACf,QAAQ;;EAER;;UAGe;EACf;;EAEA,SAAS;EACT,SAAS;EACT;;UAGe;EACf,SAAS;EACT;;;wBAIc,WACd,gBACA,UAAU,eACV,OAAO,+BACN;;wBAuCa,mBACd,QAAQ,aACR,QAAQ,YAAY,oBACnB;;wBAqBa,SAAS,GAAG;;wBAKZ,eAAe,GAAG,YAAY;;wBAK9B,WAAW,GAAG;;UAab;EACf;EACA;EACA;;wBAGc,cAAc,SAAS;;wBAWvB,UACd;EAAY;EAAoB;EAAc;KAC9C,UAAU;EACP,gBAAgB;EAAmB;;;;;;;;;;;;wBCzGlB,gBACpB,eACA,cACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB;EAAiB;EAAmB;;;wBAuBxD,sBACpB,eACA,gBACA,mBAAkB,QACjB;EACD;EACA;EACA;IACE;IACA;IACA;IACA;IACA;;EAEF;;;;;;;wBAgBoB,cACpB,eACA,gBACA,cACA,mBAAkB,QACjB;EAAU;EAAa;EAAgB;EAAc;;;wBAsBlC,WACpB,eACA,gBACA,qBACA,mBAAkB,QACjB;EACD;EACA;EACA;IAAW;IAAoB;IAAc;IAAiB;;EAC9D;;;;;wBClHoB,WACpB,cACA,mBAAkB,QACjB;EAAU;EAAa;EAAoB;;;;;;;;;;;;;;;;;;UCjB7B;EACf;;EAEA;EACA;EACA;;;UAIe;EACf;EACA;EACA;;UAGe;EACf,MAAM;EACN,SAAS;;EAET;;;wBAqBc,gBACd,aACA;EACG,MAAM;EAAW,SAAS;;;wBAsCf,qBACd;EAAkB;EAAc;MAC/B"}
|