@pithy-sh/support 0.6.2 → 0.8.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.
Files changed (55) hide show
  1. package/README.md +3 -1
  2. package/dist/capability.d.ts +1 -1
  3. package/dist/capability.d.ts.map +1 -1
  4. package/dist/capability.js +5 -4
  5. package/dist/config/config.d.ts +7 -5
  6. package/dist/config/config.d.ts.map +1 -1
  7. package/dist/config/config.js +6 -5
  8. package/dist/http/handlers.d.ts +2 -2
  9. package/dist/http/handlers.d.ts.map +1 -1
  10. package/dist/http/handlers.js +3 -2
  11. package/dist/http/responses.d.ts +9 -5
  12. package/dist/http/responses.d.ts.map +1 -1
  13. package/dist/http/responses.js +10 -12
  14. package/dist/http/routes.d.ts +3 -3
  15. package/dist/http/routes.d.ts.map +1 -1
  16. package/dist/http/routes.js +4 -3
  17. package/dist/http/schemas.d.ts +11 -9
  18. package/dist/http/schemas.d.ts.map +1 -1
  19. package/dist/http/schemas.js +15 -8
  20. package/dist/http/scopes.d.ts.map +1 -1
  21. package/dist/http/scopes.js +2 -1
  22. package/dist/provision/provisionSupport.d.ts +19 -20
  23. package/dist/provision/provisionSupport.d.ts.map +1 -1
  24. package/dist/provision/provisionSupport.js +14 -17
  25. package/dist/provision/sendingCheck.d.ts +7 -0
  26. package/dist/provision/sendingCheck.d.ts.map +1 -0
  27. package/dist/provision/sendingCheck.js +63 -0
  28. package/dist/reply/fromName.d.ts +20 -0
  29. package/dist/reply/fromName.d.ts.map +1 -0
  30. package/dist/reply/fromName.js +29 -0
  31. package/dist/reply/send.d.ts +10 -1
  32. package/dist/reply/send.d.ts.map +1 -1
  33. package/dist/reply/send.js +16 -2
  34. package/dist/reply/sender.d.ts +16 -0
  35. package/dist/reply/sender.d.ts.map +1 -0
  36. package/dist/reply/sender.js +27 -0
  37. package/dist/store/threads.d.ts +1 -1
  38. package/dist/version.generated.d.ts +1 -1
  39. package/dist/version.generated.js +1 -1
  40. package/package.json +8 -8
  41. package/pithy.manifest.json +2 -2
  42. package/src/capability.ts +18 -15
  43. package/src/config/config.ts +13 -5
  44. package/src/http/handlers.ts +4 -3
  45. package/src/http/responses.ts +18 -13
  46. package/src/http/routes.ts +9 -8
  47. package/src/http/schemas.ts +23 -13
  48. package/src/http/scopes.ts +2 -1
  49. package/src/provision/provisionSupport.ts +20 -21
  50. package/src/provision/sendingCheck.ts +69 -0
  51. package/src/reply/fromName.ts +37 -0
  52. package/src/reply/send.ts +31 -3
  53. package/src/reply/sender.ts +35 -0
  54. package/src/store/threads.ts +1 -1
  55. package/src/version.generated.ts +1 -1
@@ -15,32 +15,29 @@ import { type DeprovisionTarget, type ManagedEnvironment } from "@pithy-sh/secre
15
15
  * orchestration (order, idempotency, per-env fan-out) is unit-tested without touching Cloudflare.
16
16
  * Every step is idempotent; re-running is a no-op.
17
17
  *
18
- * ## The routing rule is opt-in, and that is a safety decision
18
+ * ## The routing rule never enables Email Routing
19
19
  *
20
- * `ensureRoutingRule` returns `skipped: true` when no routing config is supplied, exactly as
21
- * `@pithy-sh/email`'s does — because **enabling Email Routing on a zone points its MX at Cloudflare.**
22
- * Creating a rule on the wrong zone would move an adopter's real inbound mail off their existing
23
- * provider, which is not a mistake a provisioning command gets to make on their behalf. So the zone,
24
- * the address, and the target worker are all explicit flags, and a project that has not decided yet
25
- * provisions everything else and adds the rule later.
20
+ * **Enabling Email Routing for a domain points its MX at Cloudflare**, so it stays the adopter's act. The CLI's
21
+ * routing step finds the zone that holds each environment's inbound address, refuses a domain Email Routing is
22
+ * not enabled for, and only then creates or updates the exact-address rule that sends it to that environment's
23
+ * app worker. It never enables routing and never touches DNS.
26
24
  *
27
- * The rule name is `<project>-global-support-inbound`, deliberately distinct from `@pithy-sh/email`'s
28
- * `<project>-global-email-bounce`. Idempotency in `ensureWorkerRoute` keys on the rule *name*, so
29
- * sharing one would make whichever capability provisioned second silently believe its rule already
30
- * existed — and the project segment is what stops two Pithy projects on one zone doing the same to
31
- * each other, with the loser's customer mail delivered to the winner's Worker.
25
+ * A rule is per environment, named `<project>-<env>-support-inbound`: each environment's address goes to its own
26
+ * app worker. The project-global `<project>-global-support-inbound` is what the explicit `--routing-zone`,
27
+ * `--inbound-address` and `--app-worker` override creates, distinct from `@pithy-sh/email`'s
28
+ * `<project>-global-email-bounce`, because that path keys idempotency on the rule's name.
32
29
  */
33
30
  /**
34
- * The name of the Email Routing rule this capability creates, for one project. Distinct from the
35
- * bounce handler's, and `global` in the environment slot because there is one rule per zone: the
36
- * environments are separated by which app Worker the rule is pointed at, not by the rule.
31
+ * The name of an Email Routing rule this capability creates, for one project: `<project>-<env>-support-inbound`
32
+ * for one environment's address, or `<project>-global-support-inbound` for the override that routes one address
33
+ * for the whole project. Distinct from the bounce handler's either way.
37
34
  *
38
35
  * **Still on the generic composer**, and deliberately: an Email Routing rule is not a namespace
39
36
  * `@pithy-sh/core/src/naming/limits` carries a verified Cloudflare cap for, and the facade exists so a
40
37
  * kind of thing brings its own number rather than borrowing one. It takes the conservative default
41
38
  * until that namespace lands — the same call `@pithy-sh/email`'s bounce rule makes.
42
39
  */
43
- export declare function supportRoutingRuleName(project: string): string;
40
+ export declare function supportRoutingRuleName(project: string, env?: ManagedEnvironment): string;
44
41
  /**
45
42
  * The deployed Worker name for a project's environment — also its resolved config basename. Composed
46
43
  * through core's naming facade under the **`worker`** namespace, so it is measured against a Worker
@@ -121,8 +118,9 @@ export interface SupportProvisioner {
121
118
  backfilled: boolean;
122
119
  }>;
123
120
  /**
124
- * Ensure the inbound Email Routing rule that delivers the support address to the app worker.
125
- * Idempotent, keyed on the rule name. Returns `skipped: true` when no routing config was supplied.
121
+ * Ensure the inbound Email Routing rules that deliver the support addresses to the app worker: each provisioned
122
+ * environment's own, or the one the override names. Idempotent. Returns `skipped: true` when there was nothing
123
+ * to route.
126
124
  */
127
125
  ensureRoutingRule(): Promise<{
128
126
  created: boolean;
@@ -192,8 +190,9 @@ export interface SupportDeprovisioner {
192
190
  /** Delete the env's classification worker. Idempotent. */
193
191
  deleteWorker(env: ManagedEnvironment): Promise<void>;
194
192
  /**
195
- * Remove the inbound routing rule, so mail stops being delivered here. Idempotent. Called only when the teardown
196
- * was asked to remove it (`removeRouting`).
193
+ * Remove the project-global inbound routing rule the override created, so mail stops being delivered here.
194
+ * Idempotent. Called only when the teardown was asked to remove it (`removeRouting`). An environment's own rule
195
+ * is the CLI's to remove, with that environment.
197
196
  */
198
197
  removeRoutingRule(): Promise<{
199
198
  removed: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"provisionSupport.d.ts","sourceRoot":"","sources":["../../src/provision/provisionSupport.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"provisionSupport.d.ts","sourceRoot":"","sources":["../../src/provision/provisionSupport.ts"],"names":[],"mappings":"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"}
@@ -20,35 +20,32 @@ import { assertSharedLeavesLast, deprovisionTarget, managedEnvironments } from "
20
20
  * orchestration (order, idempotency, per-env fan-out) is unit-tested without touching Cloudflare.
21
21
  * Every step is idempotent; re-running is a no-op.
22
22
  *
23
- * ## The routing rule is opt-in, and that is a safety decision
23
+ * ## The routing rule never enables Email Routing
24
24
  *
25
- * `ensureRoutingRule` returns `skipped: true` when no routing config is supplied, exactly as
26
- * `@pithy-sh/email`'s does — because **enabling Email Routing on a zone points its MX at Cloudflare.**
27
- * Creating a rule on the wrong zone would move an adopter's real inbound mail off their existing
28
- * provider, which is not a mistake a provisioning command gets to make on their behalf. So the zone,
29
- * the address, and the target worker are all explicit flags, and a project that has not decided yet
30
- * provisions everything else and adds the rule later.
25
+ * **Enabling Email Routing for a domain points its MX at Cloudflare**, so it stays the adopter's act. The CLI's
26
+ * routing step finds the zone that holds each environment's inbound address, refuses a domain Email Routing is
27
+ * not enabled for, and only then creates or updates the exact-address rule that sends it to that environment's
28
+ * app worker. It never enables routing and never touches DNS.
31
29
  *
32
- * The rule name is `<project>-global-support-inbound`, deliberately distinct from `@pithy-sh/email`'s
33
- * `<project>-global-email-bounce`. Idempotency in `ensureWorkerRoute` keys on the rule *name*, so
34
- * sharing one would make whichever capability provisioned second silently believe its rule already
35
- * existed — and the project segment is what stops two Pithy projects on one zone doing the same to
36
- * each other, with the loser's customer mail delivered to the winner's Worker.
30
+ * A rule is per environment, named `<project>-<env>-support-inbound`: each environment's address goes to its own
31
+ * app worker. The project-global `<project>-global-support-inbound` is what the explicit `--routing-zone`,
32
+ * `--inbound-address` and `--app-worker` override creates, distinct from `@pithy-sh/email`'s
33
+ * `<project>-global-email-bounce`, because that path keys idempotency on the rule's name.
37
34
  */
38
35
  /**
39
- * The name of the Email Routing rule this capability creates, for one project. Distinct from the
40
- * bounce handler's, and `global` in the environment slot because there is one rule per zone: the
41
- * environments are separated by which app Worker the rule is pointed at, not by the rule.
36
+ * The name of an Email Routing rule this capability creates, for one project: `<project>-<env>-support-inbound`
37
+ * for one environment's address, or `<project>-global-support-inbound` for the override that routes one address
38
+ * for the whole project. Distinct from the bounce handler's either way.
42
39
  *
43
40
  * **Still on the generic composer**, and deliberately: an Email Routing rule is not a namespace
44
41
  * `@pithy-sh/core/src/naming/limits` carries a verified Cloudflare cap for, and the facade exists so a
45
42
  * kind of thing brings its own number rather than borrowing one. It takes the conservative default
46
43
  * until that namespace lands — the same call `@pithy-sh/email`'s bounce rule makes.
47
44
  */
48
- function supportRoutingRuleName(project) {
45
+ function supportRoutingRuleName(project, env) {
49
46
  return resourceName({
50
47
  project,
51
- env: GLOBAL_SCOPE,
48
+ env: env ?? GLOBAL_SCOPE,
52
49
  thing: `${SUPPORT_CAPABILITY}-inbound`
53
50
  });
54
51
  }
@@ -0,0 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { CapabilitySettings } from "@pithy-sh/core/src/capability/settings";
4
+ import type { SupportConfig } from "../config/config";
5
+ /** Support's sending check, built from one composition's resolved config. */
6
+ export declare function supportSendingSettings(config: SupportConfig): CapabilitySettings;
7
+ //# sourceMappingURL=sendingCheck.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sendingCheck.d.ts","sourceRoot":"","sources":["../../src/provision/sendingCheck.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAmB,MAAM,wCAAwC,CAAC;AAElG,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAoBtD,6EAA6E;AAC7E,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,aAAa,GAAG,kBAAkB,CA0ChF"}
@@ -0,0 +1,63 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
4
+ //#region src/provision/sendingCheck.ts
5
+ /**
6
+ * Whether each address a reply is sent from can send, as `pithy doctor` asks it.
7
+ *
8
+ * A mailed reply is sent from the inbox the thread arrived at, so an inbox on a domain Email Service cannot send
9
+ * from receives mail and cannot answer it: every reply fails at send with the domain named. Asked once per domain,
10
+ * only while replies are on. Nothing here writes.
11
+ */
12
+ /** The domain half of an address, lowercased, or `null` where it carries none. */
13
+ function domainOf(address) {
14
+ const at = address.lastIndexOf("@");
15
+ if (at < 0 || at === address.length - 1) return null;
16
+ return address.slice(at + 1).trim().toLowerCase();
17
+ }
18
+ /** Support's sending check, built from one composition's resolved config. */
19
+ function supportSendingSettings(config) {
20
+ return {
21
+ local: () => [],
22
+ account: async (context) => {
23
+ if (!config.reply.enabled) return [];
24
+ const senders = [...config.inboundAddresses.map((address) => ({
25
+ address,
26
+ setting: "inboundAddresses"
27
+ })), ...config.reply.replyToAddress ? [{
28
+ address: config.reply.replyToAddress,
29
+ setting: "reply.replyToAddress"
30
+ }] : []];
31
+ const byDomain = /* @__PURE__ */ new Map();
32
+ for (const { address, setting } of senders) {
33
+ const domain = domainOf(address);
34
+ if (domain === null) continue;
35
+ const entry = byDomain.get(domain) ?? {
36
+ setting,
37
+ addresses: []
38
+ };
39
+ if (!entry.addresses.includes(address)) entry.addresses.push(address);
40
+ byDomain.set(domain, entry);
41
+ }
42
+ if (byDomain.size === 0) return [];
43
+ const ask = context.account.sendingDomain;
44
+ if (!ask) throw new InternalError({
45
+ message: "This doctor cannot ask whether a domain can send.",
46
+ detail: "SettingsAccountReader.sendingDomain is absent."
47
+ });
48
+ const findings = [];
49
+ for (const [domain, { setting, addresses }] of byDomain) {
50
+ if (await ask.call(context.account, domain)) continue;
51
+ findings.push({
52
+ setting,
53
+ environment: null,
54
+ problem: `${domain} cannot send on Email Service, so a reply from ${addresses.join(", ")} fails.`,
55
+ action: `Onboard ${domain} for sending in the Cloudflare dashboard under Email Service, then rerun doctor.`
56
+ });
57
+ }
58
+ return findings;
59
+ }
60
+ };
61
+ }
62
+ //#endregion
63
+ export { supportSendingSettings };
@@ -0,0 +1,20 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { SupportConfig } from "../config/config";
4
+ /** The placeholder `reply.fromName` writes the answering agent's name into. */
5
+ export declare const AGENT_PLACEHOLDER = "{agent}";
6
+ /**
7
+ * The display name a reply is sent under: `reply.fromName` with the agent's name written in.
8
+ *
9
+ * A pattern with no placeholder is a fixed name, used whoever answered. A pattern with one is undefined when no
10
+ * agent name is given, so the email capability's own `fromName` is used. Line breaks and control characters are
11
+ * folded to spaces, because the result is a header value.
12
+ */
13
+ export declare function replyFromName(pattern: string, agentName: string | undefined): string | undefined;
14
+ /**
15
+ * Every address support may send a reply as: its `inboundAddresses` and `reply.replyToAddress`, normalized and
16
+ * once each. `compose` binds the email capability's `enqueueAs` to exactly these, so a reply cannot be sent as
17
+ * an address this inbox does not claim.
18
+ */
19
+ export declare function claimedSenders(config: SupportConfig): string[];
20
+ //# sourceMappingURL=fromName.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fromName.d.ts","sourceRoot":"","sources":["../../src/reply/fromName.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAWhG;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,EAAE,CAG9D"}
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/reply/fromName.ts
4
+ /** The placeholder `reply.fromName` writes the answering agent's name into. */
5
+ const AGENT_PLACEHOLDER = "{agent}";
6
+ /**
7
+ * The display name a reply is sent under: `reply.fromName` with the agent's name written in.
8
+ *
9
+ * A pattern with no placeholder is a fixed name, used whoever answered. A pattern with one is undefined when no
10
+ * agent name is given, so the email capability's own `fromName` is used. Line breaks and control characters are
11
+ * folded to spaces, because the result is a header value.
12
+ */
13
+ function replyFromName(pattern, agentName) {
14
+ const agent = agentName?.trim() ?? "";
15
+ if (pattern.includes("{agent}") && !agent) return void 0;
16
+ const name = pattern.split(AGENT_PLACEHOLDER).join(agent).replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/ {2,}/g, " ").trim();
17
+ return name === "" ? void 0 : name;
18
+ }
19
+ /**
20
+ * Every address support may send a reply as: its `inboundAddresses` and `reply.replyToAddress`, normalized and
21
+ * once each. `compose` binds the email capability's `enqueueAs` to exactly these, so a reply cannot be sent as
22
+ * an address this inbox does not claim.
23
+ */
24
+ function claimedSenders(config) {
25
+ const addresses = [...config.inboundAddresses, ...config.reply.replyToAddress ? [config.reply.replyToAddress] : []];
26
+ return [...new Set(addresses)];
27
+ }
28
+ //#endregion
29
+ export { AGENT_PLACEHOLDER, claimedSenders, replyFromName };
@@ -71,6 +71,15 @@ export interface ReplyDeps {
71
71
  template: string;
72
72
  payload: unknown;
73
73
  replyTo?: string;
74
+ /**
75
+ * Who the reply is sent as: the thread's inbox, under `reply.fromName` with the agent's name written in.
76
+ * `name` is absent when no agent name was given, and the email capability's `fromName` is used. The seam
77
+ * `compose` binds refuses an address support does not claim.
78
+ */
79
+ from?: {
80
+ address: string;
81
+ name?: string;
82
+ };
74
83
  /** The language to write the shell in — the reporter's, taken off their submission context. */
75
84
  locale?: string;
76
85
  inReplyTo?: string;
@@ -93,7 +102,7 @@ export interface ReplyInput {
93
102
  threadId: string;
94
103
  /** The reply text, as a human wrote and edited it. */
95
104
  body: string;
96
- /** Who is answering, signed at the bottom. Optional. */
105
+ /** Who is answering: signed at the bottom, and written into the From line through `reply.fromName`. Optional. */
97
106
  agentName?: string;
98
107
  /** The verified control-plane subject sending it — the audit actor. */
99
108
  viewer: string;
@@ -1 +1 @@
1
- {"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../../src/reply/send.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAEnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAiD,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAIrG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAEH,0CAA0C;AAC1C,MAAM,WAAW,SAAS;IACxB,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE;QAChB,EAAE,EAAE,MAAM,CAAC;QACX,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,+FAA+F;QAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjC,sBAAsB;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,0BAA0B;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,oCAAoC;AACpC,MAAM,WAAW,UAAU;IACzB,iCAAiC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GACnB;IACE,0DAA0D;IAC1D,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;CACf,GACD;IACE,0FAA0F;IAC1F,OAAO,EAAE,KAAK,CAAC;IACf,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEN,gCAAgC;AAChC,wBAAsB,SAAS,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAkLxF"}
1
+ {"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../../src/reply/send.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAEnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAiD,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAKrG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAEH,0CAA0C;AAC1C,MAAM,WAAW,SAAS;IACxB,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE;QAChB,EAAE,EAAE,MAAM,CAAC;QACX,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB;;;;WAIG;QACH,IAAI,CAAC,EAAE;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAC1C,+FAA+F;QAC/F,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjC,sBAAsB;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,0BAA0B;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,oCAAoC;AACpC,MAAM,WAAW,UAAU;IACzB,iCAAiC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,iHAAiH;IACjH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GACnB;IACE,0DAA0D;IAC1D,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;CACf,GACD;IACE,0FAA0F;IAC1F,OAAO,EAAE,KAAK,CAAC;IACf,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEN,gCAAgC;AAChC,wBAAsB,SAAS,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAsMxF"}
@@ -5,6 +5,8 @@ import { SupportMessage } from "../data/message.js";
5
5
  import { SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
6
6
  import { SupportAuditActions } from "../audit/actions.js";
7
7
  import { buildReferencesHeader, replySubject } from "../mime/threading.js";
8
+ import { claimedSenders, replyFromName } from "./fromName.js";
9
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
8
10
  import { commandName } from "@pithy-sh/core/src/cli/command";
9
11
  //#region src/reply/send.ts
10
12
  /** Send a reply on a thread. */
@@ -53,6 +55,9 @@ async function sendReply(deps, input) {
53
55
  const subject = replySubject(thread.subject);
54
56
  const now = deps.now();
55
57
  const replyTo = deps.config.reply.replyToAddress ?? thread.inboxAddress;
58
+ const claimed = claimedSenders(deps.config);
59
+ const fromAddress = typeof thread.inboxAddress === "string" && claimed.includes(normalizeAddress(thread.inboxAddress)) ? thread.inboxAddress : deps.config.reply.replyToAddress ?? thread.inboxAddress ?? null;
60
+ const fromName = replyFromName(deps.config.reply.fromName, input.agentName);
56
61
  const enqueue = deps.enqueue;
57
62
  const deliverable = enqueue !== void 0 && replyTo !== null && replyTo !== void 0;
58
63
  const inApp = thread.channel === "app" && (deps.config.reply.deliverInApp || !deliverable);
@@ -68,6 +73,11 @@ async function sendReply(deps, input) {
68
73
  action: "Set `inboundAddresses` or `reply.replyToAddress` on the support capability, then retry.",
69
74
  detail: `thread ${input.threadId} has no inboxAddress and no reply.replyToAddress is configured`
70
75
  });
76
+ if (fromAddress && !claimed.includes(normalizeAddress(fromAddress))) throw new SupportReplyFailedError({
77
+ message: `This thread's inbox, ${fromAddress}, is no longer an address support claims, so it cannot be answered from it.`,
78
+ action: `Add ${fromAddress} back to \`inboundAddresses\`, or set \`reply.replyToAddress\` to an address support claims, then retry.`,
79
+ detail: `thread ${input.threadId} arrived at ${fromAddress}, which is not among the claimed senders`
80
+ });
71
81
  try {
72
82
  jobId = (await enqueue({
73
83
  to: thread.fromAddress,
@@ -78,6 +88,10 @@ async function sendReply(deps, input) {
78
88
  ...input.agentName ? { agentName: input.agentName } : {}
79
89
  },
80
90
  replyTo,
91
+ ...fromAddress ? { from: {
92
+ address: fromAddress,
93
+ ...fromName ? { name: fromName } : {}
94
+ } } : {},
81
95
  locale: replyLocale,
82
96
  inReplyTo: parent?.mimeMessageId ? `<${parent.mimeMessageId}>` : void 0,
83
97
  references: references.length > 0 ? references : void 0
@@ -97,8 +111,8 @@ async function sendReply(deps, input) {
97
111
  mimeMessageId: null,
98
112
  mimeInReplyTo: parent?.mimeMessageId ?? null,
99
113
  mimeReferences: chain.length > 0 ? chain : null,
100
- fromAddress: inApp ? null : replyTo,
101
- fromName: null,
114
+ fromAddress: inApp ? null : fromAddress ?? replyTo,
115
+ fromName: inApp ? null : fromName ?? null,
102
116
  toAddress: inApp ? null : thread.fromAddress,
103
117
  subject,
104
118
  textBody: input.body,
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
4
+ import type { EmailCapability } from "@pithy-sh/email/src/capability";
5
+ import type { SupportConfig } from "../config/config";
6
+ /** The `@pithy-sh/email` range whose capability carries `enqueueAs`. */
7
+ export declare const EMAIL_WITH_ENQUEUE_AS = "^0.9.0";
8
+ /**
9
+ * The enqueue a reply is sent through: the composed email capability's `enqueueAs`, bound to the addresses
10
+ * support claims. Undefined when no email capability is composed, which degrades the reply route alone.
11
+ *
12
+ * An email capability with no `enqueueAs` is a release older than the seam, and is refused here, at compose,
13
+ * rather than failing every reply.
14
+ */
15
+ export declare function replySender(capabilities: readonly Capability[], config: SupportConfig): EmailCapability["enqueue"] | undefined;
16
+ //# sourceMappingURL=sender.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../../src/reply/sender.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAE3E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAEtE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,wEAAwE;AACxE,eAAO,MAAM,qBAAqB,WAAW,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,YAAY,EAAE,SAAS,UAAU,EAAE,EACnC,MAAM,EAAE,aAAa,GACpB,eAAe,CAAC,SAAS,CAAC,GAAG,SAAS,CAWxC"}
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { claimedSenders } from "./fromName.js";
4
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { isEmailCapability } from "@pithy-sh/email/src/capability";
6
+ //#region src/reply/sender.ts
7
+ /** The `@pithy-sh/email` range whose capability carries `enqueueAs`. */
8
+ const EMAIL_WITH_ENQUEUE_AS = "^0.9.0";
9
+ /**
10
+ * The enqueue a reply is sent through: the composed email capability's `enqueueAs`, bound to the addresses
11
+ * support claims. Undefined when no email capability is composed, which degrades the reply route alone.
12
+ *
13
+ * An email capability with no `enqueueAs` is a release older than the seam, and is refused here, at compose,
14
+ * rather than failing every reply.
15
+ */
16
+ function replySender(capabilities, config) {
17
+ const email = capabilities.find(isEmailCapability);
18
+ if (!email) return void 0;
19
+ if (typeof email.enqueueAs !== "function") throw new InternalError({
20
+ message: "The composed email capability is too old to send support replies.",
21
+ action: `Upgrade @pithy-sh/email to ${EMAIL_WITH_ENQUEUE_AS}, the release that carries enqueueAs, then redeploy.`,
22
+ detail: "email capability composed without enqueueAs"
23
+ });
24
+ return email.enqueueAs(claimedSenders(config));
25
+ }
26
+ //#endregion
27
+ export { EMAIL_WITH_ENQUEUE_AS, replySender };
@@ -66,7 +66,7 @@ export interface ThreadFilters {
66
66
  /**
67
67
  * What a caller may filter the inbox by, and where to resume.
68
68
  *
69
- * **No free text.** Listing lists; `GET {basePath}/threads/search` searches, ranked, with a limit and no
69
+ * **No free text.** Listing lists; `POST {basePath}/threads/search` searches, ranked, with a limit and no
70
70
  * cursor. A `q` here could say whether a thread matched, never how well.
71
71
  */
72
72
  export interface ListThreadsQuery extends ThreadFilters {
@@ -3,5 +3,5 @@
3
3
  /** This package's npm name — the join key against a release feed. */
4
4
  export declare const PACKAGE_NAME = "@pithy-sh/support";
5
5
  /** This package's version, stamped from its own package.json at generation time. */
6
- export declare const PACKAGE_VERSION = "0.6.2";
6
+ export declare const PACKAGE_VERSION = "0.8.0";
7
7
  //# sourceMappingURL=version.generated.d.ts.map
@@ -4,6 +4,6 @@
4
4
  /** This package's npm name — the join key against a release feed. */
5
5
  const PACKAGE_NAME = "@pithy-sh/support";
6
6
  /** This package's version, stamped from its own package.json at generation time. */
7
- const PACKAGE_VERSION = "0.6.2";
7
+ const PACKAGE_VERSION = "0.8.0";
8
8
  //#endregion
9
9
  export { PACKAGE_NAME, PACKAGE_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/support",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,13 +40,13 @@
40
40
  "tldts": "^7.4.10"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pithy-sh/auth": "^0.12.2",
44
- "@pithy-sh/core": "^0.16.0",
45
- "@pithy-sh/email": "^0.8.2",
46
- "@pithy-sh/payments": "^0.7.2",
47
- "@pithy-sh/secrets": "^0.6.4",
48
- "@pithy-sh/storage": "^0.7.2",
49
- "@pithy-sh/vector": "^0.5.4",
43
+ "@pithy-sh/auth": "^0.14.0",
44
+ "@pithy-sh/core": "^0.18.0",
45
+ "@pithy-sh/email": "^0.10.0",
46
+ "@pithy-sh/payments": "^0.8.1",
47
+ "@pithy-sh/secrets": "^0.6.6",
48
+ "@pithy-sh/storage": "^0.7.4",
49
+ "@pithy-sh/vector": "^0.5.6",
50
50
  "hono": "^4.13.2",
51
51
  "kysely": "^0.29.0",
52
52
  "zod": "^4.4.0"
@@ -35,7 +35,7 @@
35
35
  "scaffold": [
36
36
  "Pick the address this inbox will answer on, and put it in `inboundAddresses`. **Use a subdomain, never your apex.** Enabling Email Routing on a zone points its MX at Cloudflare, so `support@yourdomain.com` would move all of your real inbound mail off your current provider. `support@help.yourdomain.com` costs you a subdomain zone and nothing else. Until this is set the inbox is inert and ignores every message.",
37
37
  "Ensure the secrets capability is provisioned (`pithy add secrets` then `pithy secrets provision`) — the R2 credentials attachment URLs are signed with are stored and read through @pithy-sh/secrets.",
38
- "Run `pithy support provision --routing-zone <zone-id> --inbound-address <addr> --app-worker <name>`. It creates the `SUPPORT_BUCKET` R2 bucket, deploys the prebuilt classification worker for each environment, and creates the Email Routing rule that delivers mail to your app worker. Idempotent; re-run it freely.",
38
+ "Turn on Email Routing for the address's domain in the Cloudflare dashboard (a subdomain is set up on its parent zone), then run `pithy support provision`. It creates the `SUPPORT_BUCKET` R2 bucket, deploys the prebuilt classification worker for each environment, and routes each environment's `inboundAddresses` to that environment's app worker, finding the zone from the address. It never turns Email Routing on. Idempotent; re-run it freely.",
39
39
  "Add the `DB` (D1) binding to each wrangler.jsonc env. `SUPPORT_BUCKET` (R2) is written for you when your config keeps attachments, the raw MIME copy, or in-app uploads, and left out entirely when it keeps none of them. Add the `SUPPORT_CLASSIFY` Workflow binding your app dispatches to after storing a message — `pithy support provision` writes it.",
40
40
  "Add the controlplane capability (`pithy add controlplane`) and run `pithy dashboard connect`. Every management route is control-plane and default-denied, except to your own staff through `support({ staff })`: without a registered connection the admin surface answers 403, which is the correct posture for an inbox holding other people's private correspondence. Grant the six scopes deliberately — `support:threads:reply` sends mail to real people under your domain and is not the same permission as reading, and `support:threads:assign` decides who answers whom.",
41
41
  "Add the email capability (`pithy add email`) if you want to answer from the dashboard. Without it the inbox still receives, threads, and classifies; the reply route returns `support/reply_failed` naming what is missing.",
@@ -45,7 +45,7 @@
45
45
  "If your feedback screen offers a chooser, send its answer as `declaredCategory`. It lands in its own column beside `category` and is never overwritten by the classifier — one is a claim by the person writing, the other a judgement about them, and an operator triaging an inbox needs to tell them apart. `GET /support/threads` filters on either. A key outside your taxonomy is refused rather than stored, and a category sent with a `threadId` is refused too: a chooser belongs on the form that opens a request.",
46
46
  "`POST /support/feedback` is gated on a session and same-origin and never on a role — a general intake that is role-gated is not one. If your own account model makes some submissions act-on-behalf-of, put that check in your `app` capability's `middleware` over your own `basePath`: every capability's middleware mounts before any capability's routes, so yours runs after auth has resolved the session and before this capability's `requireAuth()`.",
47
47
  "If your own signed-in users work the inbox, name two powers in `support({ staff: { readPower, assignPower } })` and give them to a role in your organization catalog's `unassignable` list that `organization({ ownership })` does not confer, so only an operator can confer them — anybody may found an organization and take its ownership role, so `support()` refuses a staff power there. `readPower` lets a staff member join a thread's presence room; `assignPower` lets them assign it at `POST /support/staff/threads/:id/assign`. Without `staff`, both answer to management clients only.",
48
- "Search the inbox at `GET /support/threads/search` — keyword by default, ranked; the inbox list takes no `q`. Turn on `search: { semantic: true }` to find a paraphrase as well as an exact word: it embeds every inbound message on your own Workers AI binding, a second inference per message on top of classification, so it is off until you choose it. Then run `pithy support provision`, which creates the Vectorize index, binds it, and backfills it once; `pithy vector reprocess --index support-messages` re-embeds after a model change.",
48
+ "Search the inbox at `POST /support/threads/search`, with the words and the filters in a JSON body, so a customer's address typed into the box never lands in a request log — keyword by default, ranked; the inbox list takes no `q`. Turn on `search: { semantic: true }` to find a paraphrase as well as an exact word: it embeds every inbound message on your own Workers AI binding, a second inference per message on top of classification, so it is off until you choose it. Then run `pithy support provision`, which creates the Vectorize index, binds it, and backfills it once; `pithy vector reprocess --index support-messages` re-embeds after a model change.",
49
49
  "Run `pithy support provision` after adding support or upgrading it: `search.fts` is on by default, and the FTS5 index and its triggers are provisioned rather than migrated. Until then search falls back to a `LIKE` scan and `pithy doctor` names the environment. Back the database up with `pithy backup`, which skips the index, and restore it with `pithy restore`, which rebuilds it — a plain `wrangler d1 export` fails on any database holding an FTS5 table."
50
50
  ],
51
51
  "configOptions": [
package/src/capability.ts CHANGED
@@ -9,7 +9,6 @@ import type { DatabaseSpecMap } from "@pithy-sh/core/src/data/databases";
9
9
  import type { KvNamespaceSpecMap } from "@pithy-sh/core/src/kv/namespaces";
10
10
  import { workflowBindings } from "@pithy-sh/core/src/workflow/bindings";
11
11
  import type { EmailCapability } from "@pithy-sh/email/src/capability";
12
- import { isEmailCapability } from "@pithy-sh/email/src/capability";
13
12
  import type { Migration } from "kysely/migration";
14
13
  import type { SupportClientProjection } from "./client/projection";
15
14
  import {
@@ -28,7 +27,9 @@ import { senderPeers } from "./link/peers";
28
27
  import type { SenderPeers } from "./link/sender";
29
28
  import { SUPPORT_MIGRATIONS } from "./migrations/set";
30
29
  import { supportPresence } from "./presence/room";
30
+ import { supportSendingSettings } from "./provision/sendingCheck";
31
31
  import { supportUserPurge } from "./purge/contribution";
32
+ import { replySender } from "./reply/sender";
32
33
  import { resolveReplies, type SupportReplySnippets } from "./reply/snippets";
33
34
  import { inapplicableAttachmentSecrets, SUPPORT_BUCKET_BINDING, supportSecretsRegistry } from "./secret/registry";
34
35
  import { supportExampleSeed } from "./seeds/example";
@@ -80,7 +81,7 @@ export interface SupportWiring {
80
81
  enqueueEmail: EmailCapability["enqueue"] | undefined;
81
82
  /**
82
83
  * The optional peers the sender link reads through — `auth()`'s and `payments()`'s surfaces, set by
83
- * `compose` when each is composed. Found, never imported (#645): see `link/sender.ts`.
84
+ * `compose` when each is composed. Found, never imported: see `link/sender.ts`.
84
85
  */
85
86
  peers: SenderPeers;
86
87
  /**
@@ -241,10 +242,10 @@ export function support(options: SupportOptions = {}): SupportCapability {
241
242
  // **Declared only when this configuration would ever write to it.** A manifest is one static file
242
243
  // and cannot vary with config, so the manifest says `optional` and the composed instance decides
243
244
  // — which is exactly what the CLI's `effectiveBindings` reads, and what `controlplane` has always
244
- // done for its KV. Declaring it unconditionally made `pithy upgrade` write six R2 stanzas across
245
- // three environments for an inbox with attachments off, and `pithy doctor` call them missing
246
- // forever once they were deleted by hand (#440). The three settings behind `supportNeedsBucket`
247
- // all default `true`, so nothing changes for a project that has not turned one off.
245
+ // done for its KV. Declared unconditionally, `pithy upgrade` would write six R2 stanzas across
246
+ // three environments for an inbox with attachments off, and `pithy doctor` would call them missing
247
+ // once they were deleted by hand. The three settings behind `supportNeedsBucket` all default
248
+ // `true`, so a project that has not turned one off declares the bucket.
248
249
  ...(supportNeedsBucket(resolved) ? [{ type: "r2" as const, name: SUPPORT_BUCKET_BINDING, optional: true }] : []),
249
250
  // The classification Workflow the inbound handler dispatches to, derived from the spec rather
250
251
  // than listed again — one declaration, so a binding rename cannot leave the two disagreeing.
@@ -267,20 +268,23 @@ export function support(options: SupportOptions = {}): SupportCapability {
267
268
  // The package this capability ships in and the version it ships at, both stamped by
268
269
  // `scripts/stampVersions.ts` — a Worker cannot read its own package.json. Reported per capability by
269
270
  // the control-plane manifest, and reported together: a release feed is keyed by package name, so the
270
- // version alone leaves a client guessing the key (#626).
271
+ // version alone leaves a client guessing the key.
271
272
  version: PACKAGE_VERSION,
272
273
  package: PACKAGE_NAME,
273
274
  // Attachment presigning reads an R2 credential bundle through @pithy-sh/secrets.
274
275
  dependsOn: ["secrets"],
275
276
  secretRegistry: supportSecretsRegistry,
276
- // And whether this composition can reach it at all (#541). The binding is suppressed when nothing
277
+ // And whether this composition can reach it at all. The binding is suppressed when nothing
277
278
  // would ever write to the bucket, so there is no decline for the CLI to join against — the
278
279
  // capability is the only thing that knows, and it already computed the answer for the binding.
279
280
  inapplicableSecrets: inapplicableAttachmentSecrets(resolved),
280
281
  workflows: supportWorkflows,
281
282
  requiredBindings,
282
- // Provisioning creates the routing rule that delivers mail to this Worker.
283
- ciPermissions: ["email:routing"],
283
+ // Provisioning finds the zone each inbound address lives in, checks Email Routing is enabled for it, and routes
284
+ // the address to this Worker; the CI token is granted that on those zones.
285
+ inboundRouting: { addresses: resolved.inboundAddresses },
286
+ // `pithy doctor` asks whether each inbound address's domain can send, since a reply is sent from it.
287
+ settings: supportSendingSettings(resolved),
284
288
  databases: {
285
289
  app: {
286
290
  binding: "DB",
@@ -303,9 +307,8 @@ export function support(options: SupportOptions = {}): SupportCapability {
303
307
  compose: ({ capabilities }) => {
304
308
  // A staff block no request could be served by safely is refused here, at boot. See `staff/staff.ts`.
305
309
  assertStaffComposition(resolved.staff, capabilities);
306
- const email = capabilities.find(isEmailCapability);
307
- wiring.enqueueEmail = email?.enqueue;
308
- // Auth and payments, found rather than imported (#645), and refused here when this Worker's config needs
310
+ wiring.enqueueEmail = replySender(capabilities, resolved);
311
+ // Auth and payments, found rather than imported, and refused here when this Worker's config needs
309
312
  // one it cannot reach. See `link/peers.ts`.
310
313
  wiring.peers = senderPeers(capabilities, resolved);
311
314
  },
@@ -319,7 +322,7 @@ export function support(options: SupportOptions = {}): SupportCapability {
319
322
  ...(supportMessageIndex(resolved.search)
320
323
  ? { vectorIndexes: [{ name: SUPPORT_MESSAGES_INDEX, reprocessJob: "reprocess" }] }
321
324
  : {}),
322
- // Who else has a thread open (#816): one `support.thread` room per thread, on core's presence seam.
325
+ // Who else has a thread open: one `support.thread` room per thread, on core's presence seam.
323
326
  // Declaring it is the whole of the wiring — core adds the room's binding, socket and ticket routes.
324
327
  presence: supportPresence(mountPath, wiring),
325
328
  routes: registerSupportRoutes({
@@ -332,7 +335,7 @@ export function support(options: SupportOptions = {}): SupportCapability {
332
335
  seeds: [supportExampleSeed],
333
336
  // A person's threads — linked to their account, or sent from their address and linked to no other — with every
334
337
  // message, attachment and raw mail in them: exported on an access request and erased by their purge, as its own
335
- // step (#814).
338
+ // step.
336
339
  purge: [supportUserPurge("DB", { semantic: supportMessageIndex(resolved.search) !== undefined })],
337
340
  });
338
341