@aglyn/shared-util-email 1.0.0-beta.220 → 1.0.0-beta.222

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.
@@ -34,7 +34,8 @@
34
34
  *
35
35
  * Nothing installed is UNGATED, the governor's posture: a deployment that
36
36
  * never installs the store must still send.
37
- *=========================================*/ import { screenOutboundEmail, signalsThatHold } from "./outbound-phishing-screen.js";
37
+ *=========================================*/ import { linkReputationSignals } from "./link-reputation.js";
38
+ import { linkUrlsIn, screenOutboundEmail, signalsThatHold } from "./outbound-phishing-screen.js";
38
39
  let installedGate = null;
39
40
  /** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */ export function setOutboundScreenGate(gate) {
40
41
  installedGate = gate;
@@ -46,9 +47,10 @@ let installedGate = null;
46
47
  installedGate = null;
47
48
  }
48
49
  /**
49
- * Screen one tenant message: the pure screen, its tiers, then — only when a
50
- * signal holds — the installed gate, which files or reads the review row.
51
- * `null` when it may go.
50
+ * Screen one tenant message: the pure screen, the reputation of every
51
+ * foreign host it links to (AGL-3451), the tiers, then — only when a signal
52
+ * holds — the installed gate, which files or reads the review row. `null`
53
+ * when it may go.
52
54
  *
53
55
  * The ONE door for every transport. `sendEmail` asks it for every message a
54
56
  * site sends through the platform's provider, and a sender on another
@@ -71,7 +73,20 @@ let installedGate = null;
71
73
  ownNames: workspace.ownNames,
72
74
  ownDomains: workspace.ownDomains
73
75
  });
74
- const holding = signalsThatHold(verdict.signals, {
76
+ // Every foreign link the message carries, against the reputation list
77
+ // (AGL-3451): its host, and in 'url' mode its address (AGL-3459). A
78
+ // listing is a strong signal; a lookup that fails is none. Nothing
79
+ // installed looks nothing up.
80
+ const reputation = await linkReputationSignals(linkUrlsIn([
81
+ input.subject,
82
+ ...input.bodies
83
+ ].filter(Boolean).join('\n')), {
84
+ ownDomains: workspace.ownDomains
85
+ });
86
+ const holding = signalsThatHold([
87
+ ...verdict.signals,
88
+ ...reputation
89
+ ], {
75
90
  ageDays: workspace.ageDays,
76
91
  owed: input.owed === true
77
92
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/outbound-screen-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE OUTBOUND SCREEN ON THE SEND SEAM (AGL-3362).\n *\n * Every tenant message leaves through `sendEmail`, and every tenant sender\n * but the campaign core resolves its identity through `hostSendingIdentity`,\n * which stamps the verdict with the {@link SendingWorkspace} it read. So the\n * screen is asked HERE, once, for the CRM's one-off mail, inbox replies,\n * newsletters, member posts, cart and restock sweeps, flow steps, receipts —\n * and any marketplace plugin that sends for a site — with no copy of it in\n * any of them.\n *\n * Split the way the governor, the marketing gate and the deliverability\n * preflight are: the DECISION is pure and lives in this library\n * (`screenOutboundEmail` finds signals, `signalsThatHold` applies the tiers);\n * what a hold WRITES — the abuse-queue row, the staff alert, the release\n * lookup — is a store, so it is injected by `@aglyn/tenant-data-admin`\n * through {@link setOutboundScreenGate}. The gate is only called when a\n * signal holds, so a clean message costs no read.\n *\n * Nothing installed is UNGATED, the governor's posture: a deployment that\n * never installs the store must still send.\n *=========================================*/\n\nimport {\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from './outbound-phishing-screen'\n\n/**\n * The workspace a tenant message belongs to, as the screen needs it. Stamped\n * on the sending identity by `hostSendingIdentity` from the host and org\n * documents it already reads, so the screen costs no read of its own.\n */\nexport interface SendingWorkspace {\n hostId: string\n orgId: string | null\n /** Days since the org was created; `null` when unreadable (an existing customer). */\n ageDays: number | null\n /** The org's and site's names and the subdomain — a brand in one is its own. */\n ownNames: string[]\n /** The site's hosts — a link to one is never \"elsewhere\". */\n ownDomains: string[]\n}\n\nexport interface OutboundScreenGateRequest {\n workspace: SendingWorkspace\n /** The signals that HOLD, after the tiers. Never empty. */\n signals: PhishingScreenSignal[]\n subject: string\n fromName: string | null\n /** The sender's `context`, so the reviewer can tell which feature sent it. */\n context: string | null\n /**\n * A review row a caller's own screen held and staff RELEASED for this\n * content (a campaign, an automation step). The gate honors it only when\n * that row really is a release for this site — a caller cannot mint one.\n */\n releasedReviewId: string | null\n}\n\nexport interface OutboundScreenGateVerdict {\n outcome: 'send' | 'held' | 'rejected'\n /** The `HS-…` reference staff and the merchant quote. */\n reference?: string | null\n}\n\nexport type OutboundScreenGate = (\n request: OutboundScreenGateRequest,\n) => Promise<OutboundScreenGateVerdict>\n\nlet installedGate: OutboundScreenGate | null = null\n\n/** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setOutboundScreenGate(gate: OutboundScreenGate | null): void {\n installedGate = gate\n}\n\n/** The installed gate, or null when nothing has been installed. */\nexport function getOutboundScreenGate(): OutboundScreenGate | null {\n return installedGate\n}\n\n/** Test seam: forget any installed gate. */\nexport function resetOutboundScreenGateForTests(): void {\n installedGate = null\n}\n\nexport interface TenantMessageScreenInput {\n workspace: SendingWorkspace\n subject: string\n fromName?: string | null\n /** The address it leaves from — a lookalike `From:` domain holds for everyone. */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n /** Everything the recipient reads or clicks: the text part, the HTML. */\n bodies: readonly (string | null | undefined)[]\n /** The recipient's own act made this owed (see `SendEmailOptions.owedFor`). */\n owed?: boolean\n context?: string | null\n releasedReviewId?: string | null\n}\n\n/** Why a tenant message may not leave. */\nexport interface TenantMessageScreenRefusal {\n outcome: 'held' | 'rejected'\n reference: string | null\n /** The sentence a merchant reads. */\n detail: string\n}\n\n/**\n * Screen one tenant message: the pure screen, its tiers, then — only when a\n * signal holds — the installed gate, which files or reads the review row.\n * `null` when it may go.\n *\n * The ONE door for every transport. `sendEmail` asks it for every message a\n * site sends through the platform's provider, and a sender on another\n * transport (Outreach's connected mailboxes) asks it the same way, so a\n * second transport is not a second screen.\n *\n * Fails OPEN on a gate that is missing, throws or answers nothing, the\n * posture of every other control on the send path: an outage on the review\n * queue must not become an outage on every site's mail.\n */\nexport async function screenTenantMessage(\n input: TenantMessageScreenInput,\n label = 'email',\n): Promise<TenantMessageScreenRefusal | null> {\n const { workspace } = input\n if (!workspace?.hostId) return null\n const verdict = screenOutboundEmail({\n subject: input.subject,\n fromName: input.fromName ?? null,\n fromAddress: input.fromAddress ?? null,\n replyTo: input.replyTo ?? null,\n bodies: input.bodies,\n ownNames: workspace.ownNames,\n ownDomains: workspace.ownDomains,\n })\n const holding = signalsThatHold(verdict.signals, {\n ageDays: workspace.ageDays,\n owed: input.owed === true,\n })\n if (!holding.length) return null\n const gate = getOutboundScreenGate()\n if (!gate) {\n console.warn(`${label} matched the phishing screen, and no review store is installed — allowing`)\n return null\n }\n let answer: OutboundScreenGateVerdict | null\n try {\n answer = await gate({\n workspace,\n signals: holding,\n subject: String(input.subject ?? ''),\n fromName: input.fromName ?? null,\n context: input.context ?? null,\n releasedReviewId: input.releasedReviewId ?? null,\n })\n } catch (error) {\n console.error(`${label} phishing review failed — allowing`, error)\n answer = null\n }\n if (!answer || answer.outcome === 'send') return null\n const reference = answer.reference ?? null\n const cited = reference ? ` (${reference})` : ''\n return {\n outcome: answer.outcome,\n reference,\n detail:\n answer.outcome === 'rejected'\n ? `Staff review rejected email like this from this site${cited}, so it was not sent.`\n : 'This email is held for staff review because it may impersonate another ' +\n `business${cited}. Nothing was sent.`,\n }\n}\n"],"names":["screenOutboundEmail","signalsThatHold","installedGate","setOutboundScreenGate","gate","getOutboundScreenGate","resetOutboundScreenGateForTests","screenTenantMessage","input","label","answer","workspace","hostId","verdict","subject","fromName","fromAddress","replyTo","bodies","ownNames","ownDomains","holding","signals","ageDays","owed","length","console","warn","String","context","releasedReviewId","error","outcome","reference","cited","detail"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;2CAqB2C,GAE3C,SAEEA,mBAAmB,EACnBC,eAAe,QACV,gCAA4B;AA4CnC,IAAIC,gBAA2C;AAE/C,6EAA6E,GAC7E,OAAO,SAASC,sBAAsBC,IAA+B;IACnEF,gBAAgBE;AAClB;AAEA,iEAAiE,GACjE,OAAO,SAASC;IACd,OAAOH;AACT;AAEA,0CAA0C,GAC1C,OAAO,SAASI;IACdJ,gBAAgB;AAClB;AAyBA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeK,oBACpBC,KAA+B,EAC/BC,QAAQ,OAAO;QAMHD,iBACGA,oBACJA,gBA8BOE;IApClB,MAAM,EAAEC,SAAS,EAAE,GAAGH;IACtB,IAAI,EAACG,6BAAAA,UAAWC,MAAM,GAAE,OAAO;IAC/B,MAAMC,UAAUb,oBAAoB;QAClCc,SAASN,MAAMM,OAAO;QACtBC,QAAQ,GAAEP,kBAAAA,MAAMO,QAAQ,YAAdP,kBAAkB;QAC5BQ,WAAW,GAAER,qBAAAA,MAAMQ,WAAW,YAAjBR,qBAAqB;QAClCS,OAAO,GAAET,iBAAAA,MAAMS,OAAO,YAAbT,iBAAiB;QAC1BU,QAAQV,MAAMU,MAAM;QACpBC,UAAUR,UAAUQ,QAAQ;QAC5BC,YAAYT,UAAUS,UAAU;IAClC;IACA,MAAMC,UAAUpB,gBAAgBY,QAAQS,OAAO,EAAE;QAC/CC,SAASZ,UAAUY,OAAO;QAC1BC,MAAMhB,MAAMgB,IAAI,KAAK;IACvB;IACA,IAAI,CAACH,QAAQI,MAAM,EAAE,OAAO;IAC5B,MAAMrB,OAAOC;IACb,IAAI,CAACD,MAAM;QACTsB,QAAQC,IAAI,CAAC,GAAGlB,MAAM,yEAAyE,CAAC;QAChG,OAAO;IACT;IACA,IAAIC;IACJ,IAAI;YAIgBF,gBACNA,kBACDA,gBACSA;QANpBE,SAAS,MAAMN,KAAK;YAClBO;YACAW,SAASD;YACTP,SAASc,QAAOpB,iBAAAA,MAAMM,OAAO,YAAbN,iBAAiB;YACjCO,QAAQ,GAAEP,mBAAAA,MAAMO,QAAQ,YAAdP,mBAAkB;YAC5BqB,OAAO,GAAErB,iBAAAA,MAAMqB,OAAO,YAAbrB,iBAAiB;YAC1BsB,gBAAgB,GAAEtB,0BAAAA,MAAMsB,gBAAgB,YAAtBtB,0BAA0B;QAC9C;IACF,EAAE,OAAOuB,OAAO;QACdL,QAAQK,KAAK,CAAC,GAAGtB,MAAM,kCAAkC,CAAC,EAAEsB;QAC5DrB,SAAS;IACX;IACA,IAAI,CAACA,UAAUA,OAAOsB,OAAO,KAAK,QAAQ,OAAO;IACjD,MAAMC,aAAYvB,oBAAAA,OAAOuB,SAAS,YAAhBvB,oBAAoB;IACtC,MAAMwB,QAAQD,YAAY,CAAC,EAAE,EAAEA,UAAU,CAAC,CAAC,GAAG;IAC9C,OAAO;QACLD,SAAStB,OAAOsB,OAAO;QACvBC;QACAE,QACEzB,OAAOsB,OAAO,KAAK,aACf,CAAC,oDAAoD,EAAEE,MAAM,qBAAqB,CAAC,GACnF,4EACA,CAAC,QAAQ,EAAEA,MAAM,mBAAmB,CAAC;IAC7C;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/outbound-screen-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE OUTBOUND SCREEN ON THE SEND SEAM (AGL-3362).\n *\n * Every tenant message leaves through `sendEmail`, and every tenant sender\n * but the campaign core resolves its identity through `hostSendingIdentity`,\n * which stamps the verdict with the {@link SendingWorkspace} it read. So the\n * screen is asked HERE, once, for the CRM's one-off mail, inbox replies,\n * newsletters, member posts, cart and restock sweeps, flow steps, receipts —\n * and any marketplace plugin that sends for a site — with no copy of it in\n * any of them.\n *\n * Split the way the governor, the marketing gate and the deliverability\n * preflight are: the DECISION is pure and lives in this library\n * (`screenOutboundEmail` finds signals, `signalsThatHold` applies the tiers);\n * what a hold WRITES — the abuse-queue row, the staff alert, the release\n * lookup — is a store, so it is injected by `@aglyn/tenant-data-admin`\n * through {@link setOutboundScreenGate}. The gate is only called when a\n * signal holds, so a clean message costs no read.\n *\n * Nothing installed is UNGATED, the governor's posture: a deployment that\n * never installs the store must still send.\n *=========================================*/\n\nimport { linkReputationSignals } from './link-reputation'\nimport {\n linkUrlsIn,\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from './outbound-phishing-screen'\n\n/**\n * The workspace a tenant message belongs to, as the screen needs it. Stamped\n * on the sending identity by `hostSendingIdentity` from the host and org\n * documents it already reads, so the screen costs no read of its own.\n */\nexport interface SendingWorkspace {\n hostId: string\n orgId: string | null\n /** Days since the org was created; `null` when unreadable (an existing customer). */\n ageDays: number | null\n /** The org's and site's names and the subdomain — a brand in one is its own. */\n ownNames: string[]\n /** The site's hosts — a link to one is never \"elsewhere\". */\n ownDomains: string[]\n}\n\nexport interface OutboundScreenGateRequest {\n workspace: SendingWorkspace\n /** The signals that HOLD, after the tiers. Never empty. */\n signals: PhishingScreenSignal[]\n subject: string\n fromName: string | null\n /** The sender's `context`, so the reviewer can tell which feature sent it. */\n context: string | null\n /**\n * A review row a caller's own screen held and staff RELEASED for this\n * content (a campaign, an automation step). The gate honors it only when\n * that row really is a release for this site — a caller cannot mint one.\n */\n releasedReviewId: string | null\n}\n\nexport interface OutboundScreenGateVerdict {\n outcome: 'send' | 'held' | 'rejected'\n /** The `HS-…` reference staff and the merchant quote. */\n reference?: string | null\n}\n\nexport type OutboundScreenGate = (\n request: OutboundScreenGateRequest,\n) => Promise<OutboundScreenGateVerdict>\n\nlet installedGate: OutboundScreenGate | null = null\n\n/** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setOutboundScreenGate(gate: OutboundScreenGate | null): void {\n installedGate = gate\n}\n\n/** The installed gate, or null when nothing has been installed. */\nexport function getOutboundScreenGate(): OutboundScreenGate | null {\n return installedGate\n}\n\n/** Test seam: forget any installed gate. */\nexport function resetOutboundScreenGateForTests(): void {\n installedGate = null\n}\n\nexport interface TenantMessageScreenInput {\n workspace: SendingWorkspace\n subject: string\n fromName?: string | null\n /** The address it leaves from — a lookalike `From:` domain holds for everyone. */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n /** Everything the recipient reads or clicks: the text part, the HTML. */\n bodies: readonly (string | null | undefined)[]\n /** The recipient's own act made this owed (see `SendEmailOptions.owedFor`). */\n owed?: boolean\n context?: string | null\n releasedReviewId?: string | null\n}\n\n/** Why a tenant message may not leave. */\nexport interface TenantMessageScreenRefusal {\n outcome: 'held' | 'rejected'\n reference: string | null\n /** The sentence a merchant reads. */\n detail: string\n}\n\n/**\n * Screen one tenant message: the pure screen, the reputation of every\n * foreign host it links to (AGL-3451), the tiers, then — only when a signal\n * holds — the installed gate, which files or reads the review row. `null`\n * when it may go.\n *\n * The ONE door for every transport. `sendEmail` asks it for every message a\n * site sends through the platform's provider, and a sender on another\n * transport (Outreach's connected mailboxes) asks it the same way, so a\n * second transport is not a second screen.\n *\n * Fails OPEN on a gate that is missing, throws or answers nothing, the\n * posture of every other control on the send path: an outage on the review\n * queue must not become an outage on every site's mail.\n */\nexport async function screenTenantMessage(\n input: TenantMessageScreenInput,\n label = 'email',\n): Promise<TenantMessageScreenRefusal | null> {\n const { workspace } = input\n if (!workspace?.hostId) return null\n const verdict = screenOutboundEmail({\n subject: input.subject,\n fromName: input.fromName ?? null,\n fromAddress: input.fromAddress ?? null,\n replyTo: input.replyTo ?? null,\n bodies: input.bodies,\n ownNames: workspace.ownNames,\n ownDomains: workspace.ownDomains,\n })\n // Every foreign link the message carries, against the reputation list\n // (AGL-3451): its host, and in 'url' mode its address (AGL-3459). A\n // listing is a strong signal; a lookup that fails is none. Nothing\n // installed looks nothing up.\n const reputation = await linkReputationSignals(\n linkUrlsIn([input.subject, ...input.bodies].filter(Boolean).join('\\n')),\n { ownDomains: workspace.ownDomains },\n )\n const holding = signalsThatHold([...verdict.signals, ...reputation], {\n ageDays: workspace.ageDays,\n owed: input.owed === true,\n })\n if (!holding.length) return null\n const gate = getOutboundScreenGate()\n if (!gate) {\n console.warn(`${label} matched the phishing screen, and no review store is installed — allowing`)\n return null\n }\n let answer: OutboundScreenGateVerdict | null\n try {\n answer = await gate({\n workspace,\n signals: holding,\n subject: String(input.subject ?? ''),\n fromName: input.fromName ?? null,\n context: input.context ?? null,\n releasedReviewId: input.releasedReviewId ?? null,\n })\n } catch (error) {\n console.error(`${label} phishing review failed — allowing`, error)\n answer = null\n }\n if (!answer || answer.outcome === 'send') return null\n const reference = answer.reference ?? null\n const cited = reference ? ` (${reference})` : ''\n return {\n outcome: answer.outcome,\n reference,\n detail:\n answer.outcome === 'rejected'\n ? `Staff review rejected email like this from this site${cited}, so it was not sent.`\n : 'This email is held for staff review because it may impersonate another ' +\n `business${cited}. Nothing was sent.`,\n }\n}\n"],"names":["linkReputationSignals","linkUrlsIn","screenOutboundEmail","signalsThatHold","installedGate","setOutboundScreenGate","gate","getOutboundScreenGate","resetOutboundScreenGateForTests","screenTenantMessage","input","label","answer","workspace","hostId","verdict","subject","fromName","fromAddress","replyTo","bodies","ownNames","ownDomains","reputation","filter","Boolean","join","holding","signals","ageDays","owed","length","console","warn","String","context","releasedReviewId","error","outcome","reference","cited","detail"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;2CAqB2C,GAE3C,SAASA,qBAAqB,QAAQ,uBAAmB;AACzD,SACEC,UAAU,EAEVC,mBAAmB,EACnBC,eAAe,QACV,gCAA4B;AA4CnC,IAAIC,gBAA2C;AAE/C,6EAA6E,GAC7E,OAAO,SAASC,sBAAsBC,IAA+B;IACnEF,gBAAgBE;AAClB;AAEA,iEAAiE,GACjE,OAAO,SAASC;IACd,OAAOH;AACT;AAEA,0CAA0C,GAC1C,OAAO,SAASI;IACdJ,gBAAgB;AAClB;AAyBA;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeK,oBACpBC,KAA+B,EAC/BC,QAAQ,OAAO;QAMHD,iBACGA,oBACJA,gBAsCOE;IA5ClB,MAAM,EAAEC,SAAS,EAAE,GAAGH;IACtB,IAAI,EAACG,6BAAAA,UAAWC,MAAM,GAAE,OAAO;IAC/B,MAAMC,UAAUb,oBAAoB;QAClCc,SAASN,MAAMM,OAAO;QACtBC,QAAQ,GAAEP,kBAAAA,MAAMO,QAAQ,YAAdP,kBAAkB;QAC5BQ,WAAW,GAAER,qBAAAA,MAAMQ,WAAW,YAAjBR,qBAAqB;QAClCS,OAAO,GAAET,iBAAAA,MAAMS,OAAO,YAAbT,iBAAiB;QAC1BU,QAAQV,MAAMU,MAAM;QACpBC,UAAUR,UAAUQ,QAAQ;QAC5BC,YAAYT,UAAUS,UAAU;IAClC;IACA,sEAAsE;IACtE,oEAAoE;IACpE,mEAAmE;IACnE,8BAA8B;IAC9B,MAAMC,aAAa,MAAMvB,sBACvBC,WAAW;QAACS,MAAMM,OAAO;WAAKN,MAAMU,MAAM;KAAC,CAACI,MAAM,CAACC,SAASC,IAAI,CAAC,QACjE;QAAEJ,YAAYT,UAAUS,UAAU;IAAC;IAErC,MAAMK,UAAUxB,gBAAgB;WAAIY,QAAQa,OAAO;WAAKL;KAAW,EAAE;QACnEM,SAAShB,UAAUgB,OAAO;QAC1BC,MAAMpB,MAAMoB,IAAI,KAAK;IACvB;IACA,IAAI,CAACH,QAAQI,MAAM,EAAE,OAAO;IAC5B,MAAMzB,OAAOC;IACb,IAAI,CAACD,MAAM;QACT0B,QAAQC,IAAI,CAAC,GAAGtB,MAAM,yEAAyE,CAAC;QAChG,OAAO;IACT;IACA,IAAIC;IACJ,IAAI;YAIgBF,gBACNA,kBACDA,gBACSA;QANpBE,SAAS,MAAMN,KAAK;YAClBO;YACAe,SAASD;YACTX,SAASkB,QAAOxB,iBAAAA,MAAMM,OAAO,YAAbN,iBAAiB;YACjCO,QAAQ,GAAEP,mBAAAA,MAAMO,QAAQ,YAAdP,mBAAkB;YAC5ByB,OAAO,GAAEzB,iBAAAA,MAAMyB,OAAO,YAAbzB,iBAAiB;YAC1B0B,gBAAgB,GAAE1B,0BAAAA,MAAM0B,gBAAgB,YAAtB1B,0BAA0B;QAC9C;IACF,EAAE,OAAO2B,OAAO;QACdL,QAAQK,KAAK,CAAC,GAAG1B,MAAM,kCAAkC,CAAC,EAAE0B;QAC5DzB,SAAS;IACX;IACA,IAAI,CAACA,UAAUA,OAAO0B,OAAO,KAAK,QAAQ,OAAO;IACjD,MAAMC,aAAY3B,oBAAAA,OAAO2B,SAAS,YAAhB3B,oBAAoB;IACtC,MAAM4B,QAAQD,YAAY,CAAC,EAAE,EAAEA,UAAU,CAAC,CAAC,GAAG;IAC9C,OAAO;QACLD,SAAS1B,OAAO0B,OAAO;QACvBC;QACAE,QACE7B,OAAO0B,OAAO,KAAK,aACf,CAAC,oDAAoD,EAAEE,MAAM,qBAAqB,CAAC,GACnF,4EACA,CAAC,QAAQ,EAAEA,MAAM,mBAAmB,CAAC;IAC7C;AACF"}
@@ -149,6 +149,109 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
149
149
  footerReason: 'You’re receiving this because someone invited you to join ' + '{{org.name}} on {{brand.productName}}.',
150
150
  source: 'apps/console/app/api/orgs/invites/route.ts'
151
151
  },
152
+ {
153
+ key: 'org-owner-handoff',
154
+ name: 'Workspace handoff',
155
+ description: 'Sent when the owner of a workspace, or the platform team, invites ' + 'someone to take the workspace over as its new owner.',
156
+ deliveredBy: 'resend',
157
+ defaultSubject: '{{org.name}} is being handed to you on {{brand.productName}}',
158
+ mergeTokens: [
159
+ {
160
+ name: 'org.name',
161
+ description: 'Name of the workspace being handed over',
162
+ sample: 'Test Org'
163
+ },
164
+ {
165
+ name: 'handoff.previousOwner',
166
+ description: 'What happens to the current owner once the handoff is accepted',
167
+ sample: 'The current owner stays on as an admin.'
168
+ },
169
+ {
170
+ name: 'signInUrl',
171
+ description: 'Console URL to sign in and accept',
172
+ sample: SAMPLE_CONSOLE_ORIGIN
173
+ }
174
+ ],
175
+ // Mirrors the fallback text in invites/route.ts.
176
+ defaultBody: [
177
+ {
178
+ block: 'text',
179
+ text: '{{org.name}} is being handed to you',
180
+ variant: 'heading'
181
+ },
182
+ {
183
+ block: 'text',
184
+ text: "You've been invited to take over {{org.name}} as its owner. " + 'Accepting makes you the owner of the workspace and every site ' + 'in it. {{handoff.previousOwner}}',
185
+ variant: 'body'
186
+ },
187
+ {
188
+ block: 'text',
189
+ text: 'Sign in with this email address and accept the invitation ' + 'from your dashboard. Nothing changes until you accept.',
190
+ variant: 'body'
191
+ },
192
+ {
193
+ block: 'button',
194
+ label: 'Sign in',
195
+ href: '{{signInUrl}}'
196
+ }
197
+ ],
198
+ footerReason: 'You’re receiving this because someone invited you to take over ' + '{{org.name}} on {{brand.productName}}.',
199
+ source: 'apps/console/app/api/orgs/invites/route.ts'
200
+ },
201
+ {
202
+ key: 'org-upgrade-proposal',
203
+ name: 'Upgrade proposal',
204
+ description: "Sent to a workspace's owner when the platform team proposes a paid " + 'plan for it, after the workspace has been evaluated.',
205
+ deliveredBy: 'resend',
206
+ defaultSubject: 'Your {{brand.productName}} team proposed the {{plan.name}} plan for {{org.name}}',
207
+ mergeTokens: [
208
+ {
209
+ name: 'org.name',
210
+ description: 'Name of the workspace the plan is proposed for',
211
+ sample: 'Test Org'
212
+ },
213
+ {
214
+ name: 'plan.name',
215
+ description: 'The proposed plan',
216
+ sample: 'Starter'
217
+ },
218
+ {
219
+ name: 'proposal.note',
220
+ description: 'A line from the team, or nothing when none was written',
221
+ sample: 'As we discussed on Tuesday.'
222
+ },
223
+ {
224
+ name: 'billingUrl',
225
+ description: 'Billing page with the proposed plan preselected',
226
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/test-org/billing?plan=starter`
227
+ }
228
+ ],
229
+ // Mirrors the fallback text in /api/admin/org-upgrade-proposal.
230
+ defaultBody: [
231
+ {
232
+ block: 'text',
233
+ text: 'The {{plan.name}} plan, proposed for {{org.name}}',
234
+ variant: 'heading'
235
+ },
236
+ {
237
+ block: 'text',
238
+ text: 'Your {{brand.productName}} team proposed moving {{org.name}} to ' + 'the {{plan.name}} plan. When you are ready, start the ' + 'subscription from Billing, where the plan is already selected.',
239
+ variant: 'body'
240
+ },
241
+ {
242
+ block: 'text',
243
+ text: '{{proposal.note}}',
244
+ variant: 'body'
245
+ },
246
+ {
247
+ block: 'button',
248
+ label: 'Review the plan',
249
+ href: '{{billingUrl}}'
250
+ }
251
+ ],
252
+ footerReason: 'You’re receiving this because you own {{org.name}} on ' + '{{brand.productName}} and its team proposed a plan for it.',
253
+ source: 'apps/console/app/api/admin/org-upgrade-proposal/route.ts'
254
+ },
152
255
  {
153
256
  key: 'usage-summary',
154
257
  name: 'Monthly usage summary',