@pithy-sh/support 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +2 -0
  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.map +1 -1
  9. package/dist/http/handlers.js +2 -1
  10. package/dist/http/responses.d.ts +5 -0
  11. package/dist/http/responses.d.ts.map +1 -1
  12. package/dist/http/responses.js +3 -2
  13. package/dist/http/schemas.d.ts.map +1 -1
  14. package/dist/http/schemas.js +1 -1
  15. package/dist/provision/provisionSupport.d.ts +19 -20
  16. package/dist/provision/provisionSupport.d.ts.map +1 -1
  17. package/dist/provision/provisionSupport.js +14 -17
  18. package/dist/provision/sendingCheck.d.ts +7 -0
  19. package/dist/provision/sendingCheck.d.ts.map +1 -0
  20. package/dist/provision/sendingCheck.js +63 -0
  21. package/dist/reply/fromName.d.ts +20 -0
  22. package/dist/reply/fromName.d.ts.map +1 -0
  23. package/dist/reply/fromName.js +29 -0
  24. package/dist/reply/send.d.ts +10 -1
  25. package/dist/reply/send.d.ts.map +1 -1
  26. package/dist/reply/send.js +16 -2
  27. package/dist/reply/sender.d.ts +16 -0
  28. package/dist/reply/sender.d.ts.map +1 -0
  29. package/dist/reply/sender.js +27 -0
  30. package/dist/version.generated.d.ts +1 -1
  31. package/dist/version.generated.js +1 -1
  32. package/package.json +8 -8
  33. package/pithy.manifest.json +1 -1
  34. package/src/capability.ts +18 -15
  35. package/src/config/config.ts +13 -5
  36. package/src/http/handlers.ts +1 -0
  37. package/src/http/responses.ts +9 -1
  38. package/src/http/schemas.ts +4 -1
  39. package/src/provision/provisionSupport.ts +20 -21
  40. package/src/provision/sendingCheck.ts +69 -0
  41. package/src/reply/fromName.ts +37 -0
  42. package/src/reply/send.ts +31 -3
  43. package/src/reply/sender.ts +35 -0
  44. package/src/version.generated.ts +1 -1
@@ -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 };
@@ -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.1";
6
+ export declare const PACKAGE_VERSION = "0.7.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.1";
7
+ const PACKAGE_VERSION = "0.7.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.1",
3
+ "version": "0.7.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.1",
44
- "@pithy-sh/core": "^0.15.0",
45
- "@pithy-sh/email": "^0.8.1",
46
- "@pithy-sh/payments": "^0.7.1",
47
- "@pithy-sh/secrets": "^0.6.3",
48
- "@pithy-sh/storage": "^0.7.1",
49
- "@pithy-sh/vector": "^0.5.3",
43
+ "@pithy-sh/auth": "^0.13.0",
44
+ "@pithy-sh/core": "^0.17.0",
45
+ "@pithy-sh/email": "^0.9.0",
46
+ "@pithy-sh/payments": "^0.8.0",
47
+ "@pithy-sh/secrets": "^0.6.5",
48
+ "@pithy-sh/storage": "^0.7.3",
49
+ "@pithy-sh/vector": "^0.5.5",
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.",
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
 
@@ -306,6 +306,14 @@ export const SupportReplyConfig = z
306
306
  .describe(
307
307
  "The `Reply-To` a reply carries — the address the customer's answer comes back to, which must be one of `inboundAddresses` or the conversation ends there. Defaults to the inbox address the thread arrived on, which is almost always what you want.",
308
308
  ),
309
+ fromName: z
310
+ .string()
311
+ .min(1)
312
+ .max(120)
313
+ .default("{agent}")
314
+ .describe(
315
+ "The display name a mailed reply is sent under, with `{agent}` replaced by the answering agent's name: `{agent} at Acme` sends `\"Jim Scott at Acme\" <support@help.example.com>`. The address is the inbox the thread arrived at, or `reply.replyToAddress` for a thread whose inbox is not in `inboundAddresses` or that arrived at none. With no agent name the email capability's `fromName` is used instead; a name with no `{agent}`, such as `Acme Support`, is used for every reply.",
316
+ ),
309
317
  deliverInApp: z
310
318
  .boolean()
311
319
  .default(false)
@@ -403,7 +411,7 @@ export function supportSemantic(search: SupportSearchConfig): SupportSemanticCon
403
411
 
404
412
  /** The full support configuration. */
405
413
  /**
406
- * **Who of the app's own signed-in users works the inbox (#816).** Off until named.
414
+ * **Who of the app's own signed-in users works the inbox.** Off until named.
407
415
  *
408
416
  * The inbox holds every customer's correspondence, so a signed-in user reaches it only through a power the
409
417
  * adopter names in their own role catalog, held in the organization the request acts for — the same seam
@@ -489,10 +497,10 @@ export type SupportConfigInput = z.input<typeof SupportConfig>;
489
497
  * All three default `true`, so the ordinary project is unaffected by this predicate existing.
490
498
  *
491
499
  * Two callers, and they must agree: `capability.ts` declares the binding only when this is true, and
492
- * `pithy support provision` creates the bucket only when this is true. They disagreed before #440 —
493
- * the provisioner asked about `enabled` and `retainRaw` and never about submissions, so a project that
494
- * wanted uploads but no mail attachments got a binding pointing at a bucket nothing had created, and
495
- * every submitted file was dropped with a warning. One predicate is what makes that unrepresentable.
500
+ * `pithy support provision` creates the bucket only when this is true. Were they to ask different
501
+ * questions, a project that wanted uploads but no mail attachments would get a binding pointing at a
502
+ * bucket nothing had created, and every submitted file would be dropped with a warning. One predicate is
503
+ * what makes that unrepresentable.
496
504
  */
497
505
  export function supportNeedsBucket(config: SupportConfig): boolean {
498
506
  return config.attachments.enabled || config.attachments.retainRaw || config.submission.attachments.enabled;
@@ -208,6 +208,7 @@ async function presentAttachments(
208
208
  sha256: attachment.sha256,
209
209
  inline: attachment.inline,
210
210
  url,
211
+ messageId: attachment.messageId,
211
212
  };
212
213
  }),
213
214
  );
@@ -214,6 +214,12 @@ export const SupportAttachmentView = z
214
214
  .describe(
215
215
  "A signed URL valid for a few minutes, or null when no credentials were available to sign one. The `storageKey` is never here: it is server-derived precisely so a client cannot name an object or guess the one beside it.",
216
216
  ),
217
+ messageId: z
218
+ .string()
219
+ .optional()
220
+ .describe(
221
+ "The id of the message it arrived on, so a client can show each file under the message that carried it. Absent from a Worker that predates it.",
222
+ ),
217
223
  })
218
224
  .describe("One attachment as a client receives it — metadata and a signed URL, never the storage key.");
219
225
  export type SupportAttachmentView = z.output<typeof SupportAttachmentView>;
@@ -407,7 +413,9 @@ export const SupportThreadResponse = z
407
413
  .object({
408
414
  thread: SupportThreadView.describe("The conversation."),
409
415
  messages: z.array(SupportMessageView).describe("Its messages, oldest first — a conversation reads downward."),
410
- attachments: z.array(SupportAttachmentView).describe("Its attachments, across every message."),
416
+ attachments: z
417
+ .array(SupportAttachmentView)
418
+ .describe("Its attachments, across every message, each naming the message it arrived on in `messageId`."),
411
419
  sender: SenderContextView.describe("What the app already knows about the sender."),
412
420
  replies: z.array(SupportReplyView).describe("The canned replies, ordered for this thread's category."),
413
421
  // Optional (#450): absent from a Worker that predates assignment.
@@ -200,8 +200,11 @@ export const ReplyInput = z
200
200
  .string()
201
201
  .min(1)
202
202
  .max(80)
203
+ .regex(/^[^@<>"]*$/, "A name may not contain @, <, > or a double quote.")
203
204
  .optional()
204
- .describe("Who is answering, signed at the bottom. Omitted rather than guessed."),
205
+ .describe(
206
+ "Who is answering, signed at the bottom and written into the reply's From line. Omitted rather than guessed. May not contain `@`, `<`, `>` or `\"`, so the display name cannot pass for an address.",
207
+ ),
205
208
  })
206
209
  .describe("An answer to send to the customer.");
207
210
  export type ReplyInput = z.infer<typeof ReplyInput>;
@@ -28,34 +28,31 @@ import { SUPPORT_CAPABILITY, supportWorkflowRegistry } from "../workflows/specs"
28
28
  * orchestration (order, idempotency, per-env fan-out) is unit-tested without touching Cloudflare.
29
29
  * Every step is idempotent; re-running is a no-op.
30
30
  *
31
- * ## The routing rule is opt-in, and that is a safety decision
31
+ * ## The routing rule never enables Email Routing
32
32
  *
33
- * `ensureRoutingRule` returns `skipped: true` when no routing config is supplied, exactly as
34
- * `@pithy-sh/email`'s does — because **enabling Email Routing on a zone points its MX at Cloudflare.**
35
- * Creating a rule on the wrong zone would move an adopter's real inbound mail off their existing
36
- * provider, which is not a mistake a provisioning command gets to make on their behalf. So the zone,
37
- * the address, and the target worker are all explicit flags, and a project that has not decided yet
38
- * provisions everything else and adds the rule later.
33
+ * **Enabling Email Routing for a domain points its MX at Cloudflare**, so it stays the adopter's act. The CLI's
34
+ * routing step finds the zone that holds each environment's inbound address, refuses a domain Email Routing is
35
+ * not enabled for, and only then creates or updates the exact-address rule that sends it to that environment's
36
+ * app worker. It never enables routing and never touches DNS.
39
37
  *
40
- * The rule name is `<project>-global-support-inbound`, deliberately distinct from `@pithy-sh/email`'s
41
- * `<project>-global-email-bounce`. Idempotency in `ensureWorkerRoute` keys on the rule *name*, so
42
- * sharing one would make whichever capability provisioned second silently believe its rule already
43
- * existed — and the project segment is what stops two Pithy projects on one zone doing the same to
44
- * each other, with the loser's customer mail delivered to the winner's Worker.
38
+ * A rule is per environment, named `<project>-<env>-support-inbound`: each environment's address goes to its own
39
+ * app worker. The project-global `<project>-global-support-inbound` is what the explicit `--routing-zone`,
40
+ * `--inbound-address` and `--app-worker` override creates, distinct from `@pithy-sh/email`'s
41
+ * `<project>-global-email-bounce`, because that path keys idempotency on the rule's name.
45
42
  */
46
43
 
47
44
  /**
48
- * The name of the Email Routing rule this capability creates, for one project. Distinct from the
49
- * bounce handler's, and `global` in the environment slot because there is one rule per zone: the
50
- * environments are separated by which app Worker the rule is pointed at, not by the rule.
45
+ * The name of an Email Routing rule this capability creates, for one project: `<project>-<env>-support-inbound`
46
+ * for one environment's address, or `<project>-global-support-inbound` for the override that routes one address
47
+ * for the whole project. Distinct from the bounce handler's either way.
51
48
  *
52
49
  * **Still on the generic composer**, and deliberately: an Email Routing rule is not a namespace
53
50
  * `@pithy-sh/core/src/naming/limits` carries a verified Cloudflare cap for, and the facade exists so a
54
51
  * kind of thing brings its own number rather than borrowing one. It takes the conservative default
55
52
  * until that namespace lands — the same call `@pithy-sh/email`'s bounce rule makes.
56
53
  */
57
- export function supportRoutingRuleName(project: string): string {
58
- return resourceName({ project, env: GLOBAL_SCOPE, thing: `${SUPPORT_CAPABILITY}-inbound` });
54
+ export function supportRoutingRuleName(project: string, env?: ManagedEnvironment): string {
55
+ return resourceName({ project, env: env ?? GLOBAL_SCOPE, thing: `${SUPPORT_CAPABILITY}-inbound` });
59
56
  }
60
57
 
61
58
  /**
@@ -132,8 +129,9 @@ export interface SupportProvisioner {
132
129
  */
133
130
  settleSemanticIndex(env: ManagedEnvironment, created: boolean): Promise<{ dropped: boolean; backfilled: boolean }>;
134
131
  /**
135
- * Ensure the inbound Email Routing rule that delivers the support address to the app worker.
136
- * Idempotent, keyed on the rule name. Returns `skipped: true` when no routing config was supplied.
132
+ * Ensure the inbound Email Routing rules that deliver the support addresses to the app worker: each provisioned
133
+ * environment's own, or the one the override names. Idempotent. Returns `skipped: true` when there was nothing
134
+ * to route.
137
135
  */
138
136
  ensureRoutingRule(): Promise<{ created: boolean; skipped: boolean }>;
139
137
  }
@@ -217,8 +215,9 @@ export interface SupportDeprovisioner {
217
215
  /** Delete the env's classification worker. Idempotent. */
218
216
  deleteWorker(env: ManagedEnvironment): Promise<void>;
219
217
  /**
220
- * Remove the inbound routing rule, so mail stops being delivered here. Idempotent. Called only when the teardown
221
- * was asked to remove it (`removeRouting`).
218
+ * Remove the project-global inbound routing rule the override created, so mail stops being delivered here.
219
+ * Idempotent. Called only when the teardown was asked to remove it (`removeRouting`). An environment's own rule
220
+ * is the CLI's to remove, with that environment.
222
221
  */
223
222
  removeRoutingRule(): Promise<{ removed: boolean }>;
224
223
  /**
@@ -0,0 +1,69 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CapabilitySettings, SettingsFinding } from "@pithy-sh/core/src/capability/settings";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { SupportConfig } from "../config/config";
7
+
8
+ /**
9
+ * Whether each address a reply is sent from can send, as `pithy doctor` asks it.
10
+ *
11
+ * A mailed reply is sent from the inbox the thread arrived at, so an inbox on a domain Email Service cannot send
12
+ * from receives mail and cannot answer it: every reply fails at send with the domain named. Asked once per domain,
13
+ * only while replies are on. Nothing here writes.
14
+ */
15
+
16
+ /** The domain half of an address, lowercased, or `null` where it carries none. */
17
+ function domainOf(address: string): string | null {
18
+ const at = address.lastIndexOf("@");
19
+ if (at < 0 || at === address.length - 1) return null;
20
+ return address
21
+ .slice(at + 1)
22
+ .trim()
23
+ .toLowerCase();
24
+ }
25
+
26
+ /** Support's sending check, built from one composition's resolved config. */
27
+ export function supportSendingSettings(config: SupportConfig): CapabilitySettings {
28
+ return {
29
+ local: () => [],
30
+ account: async (context) => {
31
+ if (!config.reply.enabled) return [];
32
+ // Every address a reply is sent from, under the setting that names it: the inbox a thread arrived at, and
33
+ // `reply.replyToAddress` for a thread that arrived at none.
34
+ const senders = [
35
+ ...config.inboundAddresses.map((address) => ({ address, setting: "inboundAddresses" })),
36
+ ...(config.reply.replyToAddress
37
+ ? [{ address: config.reply.replyToAddress, setting: "reply.replyToAddress" }]
38
+ : []),
39
+ ];
40
+ const byDomain = new Map<string, { setting: string; addresses: string[] }>();
41
+ for (const { address, setting } of senders) {
42
+ const domain = domainOf(address);
43
+ if (domain === null) continue;
44
+ const entry = byDomain.get(domain) ?? { setting, addresses: [] };
45
+ if (!entry.addresses.includes(address)) entry.addresses.push(address);
46
+ byDomain.set(domain, entry);
47
+ }
48
+ if (byDomain.size === 0) return [];
49
+ const ask = context.account.sendingDomain;
50
+ if (!ask) {
51
+ throw new InternalError({
52
+ message: "This doctor cannot ask whether a domain can send.",
53
+ detail: "SettingsAccountReader.sendingDomain is absent.",
54
+ });
55
+ }
56
+ const findings: SettingsFinding[] = [];
57
+ for (const [domain, { setting, addresses }] of byDomain) {
58
+ if (await ask.call(context.account, domain)) continue;
59
+ findings.push({
60
+ setting,
61
+ environment: null,
62
+ problem: `${domain} cannot send on Email Service, so a reply from ${addresses.join(", ")} fails.`,
63
+ action: `Onboard ${domain} for sending in the Cloudflare dashboard under Email Service, then rerun doctor.`,
64
+ });
65
+ }
66
+ return findings;
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SupportConfig } from "../config/config";
5
+
6
+ /** The placeholder `reply.fromName` writes the answering agent's name into. */
7
+ export const AGENT_PLACEHOLDER = "{agent}";
8
+
9
+ /**
10
+ * The display name a reply is sent under: `reply.fromName` with the agent's name written in.
11
+ *
12
+ * A pattern with no placeholder is a fixed name, used whoever answered. A pattern with one is undefined when no
13
+ * agent name is given, so the email capability's own `fromName` is used. Line breaks and control characters are
14
+ * folded to spaces, because the result is a header value.
15
+ */
16
+ export function replyFromName(pattern: string, agentName: string | undefined): string | undefined {
17
+ const agent = agentName?.trim() ?? "";
18
+ if (pattern.includes(AGENT_PLACEHOLDER) && !agent) return undefined;
19
+ const name = pattern
20
+ .split(AGENT_PLACEHOLDER)
21
+ .join(agent)
22
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what this removes.
23
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
24
+ .replace(/ {2,}/g, " ")
25
+ .trim();
26
+ return name === "" ? undefined : name;
27
+ }
28
+
29
+ /**
30
+ * Every address support may send a reply as: its `inboundAddresses` and `reply.replyToAddress`, normalized and
31
+ * once each. `compose` binds the email capability's `enqueueAs` to exactly these, so a reply cannot be sent as
32
+ * an address this inbox does not claim.
33
+ */
34
+ export function claimedSenders(config: SupportConfig): string[] {
35
+ const addresses = [...config.inboundAddresses, ...(config.reply.replyToAddress ? [config.reply.replyToAddress] : [])];
36
+ return [...new Set(addresses)];
37
+ }