@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
@@ -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;
@@ -56,7 +56,7 @@ import type {
56
56
  MyThreadsQuery,
57
57
  RepliesQuery,
58
58
  ReplyInput,
59
- SearchThreadsQuery,
59
+ SearchThreadsBody,
60
60
  SubmitFeedbackInput,
61
61
  } from "./schemas";
62
62
  import {
@@ -148,7 +148,7 @@ export async function listInbox(
148
148
  */
149
149
  export async function searchInbox(
150
150
  deps: HandlerDeps,
151
- query: SearchThreadsQuery,
151
+ query: SearchThreadsBody,
152
152
  viewer: string,
153
153
  ): Promise<SupportSearchResponse> {
154
154
  const outcome = await searchThreads(
@@ -165,7 +165,7 @@ export async function searchInbox(
165
165
  q: query.q,
166
166
  ...(query.mode !== undefined ? { mode: query.mode } : {}),
167
167
  ...(query.limit !== undefined ? { limit: query.limit } : {}),
168
- archived: query.archived === "true",
168
+ archived: query.archived === true,
169
169
  category: query.category,
170
170
  declaredCategory: query.declaredCategory,
171
171
  priority: query.priority,
@@ -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
  );
@@ -34,16 +34,14 @@ import { SupportSubmissionContext } from "../data/message";
34
34
  *
35
35
  * **A browser imports this file, so it reaches no module that needs the Workers runtime.** That is not
36
36
  * a preference about bundle size; it is what "a client validates what comes back" means once the client
37
- * is a Vite program with the DOM lib and no Workers types. This file imported `SUPPORT_BILLING_SCOPE`
38
- * from `link/sender.ts` for one release and brought D1, Kysely and two capabilities' table maps with it,
39
- * which took `pithy-sh/dashboard`'s client build down on an error in `@pithy-sh/auth` (#419). The rule is
40
- * held by `tooling/browser-scopes`, which compiles this module with `types: []` and asks the compiler
37
+ * is a Vite program with the DOM lib and no Workers types: an import of `link/sender.ts` would bring D1,
38
+ * Kysely and two capabilities' table maps into that build. The rule is held by `tooling/browser-scopes`, which compiles this module with `types: []` and asks the compiler
41
39
  * which files that program included.
42
40
  *
43
41
  * **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
44
42
  * version boundary — a management client validates a response with this schema against a customer's
45
43
  * Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
46
- * below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
44
+ * below that release and takes the whole pane with it. Absent then means *this Worker cannot
47
45
  * say*, which is a different fact from `null`.
48
46
  */
49
47
 
@@ -110,7 +108,7 @@ export const SupportThreadView = z
110
108
  .describe(
111
109
  "The control-plane subject that archived it, for convenience only — the answer to 'who marked this done' comes from the audit trail.",
112
110
  ),
113
- // Optional as well as nullable (#450, #816): absent says *this Worker predates assignment*, which a client
111
+ // Optional as well as nullable: absent says *this Worker predates assignment*, which a client
114
112
  // must not read as *nobody has it*.
115
113
  assigneeRef: z
116
114
  .string()
@@ -214,6 +212,12 @@ export const SupportAttachmentView = z
214
212
  .describe(
215
213
  "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
214
  ),
215
+ messageId: z
216
+ .string()
217
+ .optional()
218
+ .describe(
219
+ "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.",
220
+ ),
217
221
  })
218
222
  .describe("One attachment as a client receives it — metadata and a signed URL, never the storage key.");
219
223
  export type SupportAttachmentView = z.output<typeof SupportAttachmentView>;
@@ -340,7 +344,7 @@ export const SupportSearchResultView = z
340
344
  .describe("One thread a search found, named by its best-matching message, with that message's raw scores.");
341
345
  export type SupportSearchResultView = z.output<typeof SupportSearchResultView>;
342
346
 
343
- /** `GET {base}/threads/search`. */
347
+ /** `POST {base}/threads/search`. */
344
348
  export const SupportSearchResponse = z
345
349
  .object({
346
350
  mode: z
@@ -407,10 +411,12 @@ export const SupportThreadResponse = z
407
411
  .object({
408
412
  thread: SupportThreadView.describe("The conversation."),
409
413
  messages: z.array(SupportMessageView).describe("Its messages, oldest first — a conversation reads downward."),
410
- attachments: z.array(SupportAttachmentView).describe("Its attachments, across every message."),
414
+ attachments: z
415
+ .array(SupportAttachmentView)
416
+ .describe("Its attachments, across every message, each naming the message it arrived on in `messageId`."),
411
417
  sender: SenderContextView.describe("What the app already knows about the sender."),
412
418
  replies: z.array(SupportReplyView).describe("The canned replies, ordered for this thread's category."),
413
- // Optional (#450): absent from a Worker that predates assignment.
419
+ // Optional: absent from a Worker that predates assignment.
414
420
  assignments: z
415
421
  .array(SupportAssignmentView)
416
422
  .optional()
@@ -510,9 +516,8 @@ export type SupportFlagsResponse = z.output<typeof SupportFlagsResponse>;
510
516
  * Below is the same projection for the other consumer: a management client reading a Worker **it does
511
517
  * not control**. That client is crossing a trust boundary — a fork, a bug, a half-finished deploy, or a
512
518
  * hostile Worker each put a member here that no enum of ours declares — and under the strict schema one
513
- * such token costs it the entire response. It cost `pithy-sh/dashboard#15` exactly that: one
514
- * unrecognized `channel` and the support pane rendered zero of twenty-five conversations. The client
515
- * then had to widen our shape locally, which is the hand-held mirror `#113` exists to forbid.
519
+ * such token costs it the entire response: one unrecognized `channel` and a support pane renders none of
520
+ * its conversations, and the client is left widening our shape locally, the hand-held mirror the kit forbids.
516
521
  *
517
522
  * So the tolerance is published rather than reimplemented, by the one pattern
518
523
  * `@pithy-sh/core/src/projection/asRead` states for the whole kit: same objects, every enum read as a
@@ -545,7 +550,7 @@ export type SupportMessageViewAsRead = z.output<typeof SupportMessageViewAsRead>
545
550
  export const SupportThreadsResponseAsRead = asRead(SupportThreadsResponse);
546
551
  export type SupportThreadsResponseAsRead = z.output<typeof SupportThreadsResponseAsRead>;
547
552
 
548
- /** `GET {base}/threads/search`, as a client that does not control the Worker must read it. */
553
+ /** `POST {base}/threads/search`, as a client that does not control the Worker must read it. */
549
554
  export const SupportSearchResponseAsRead = asRead(SupportSearchResponse);
550
555
  export type SupportSearchResponseAsRead = z.output<typeof SupportSearchResponseAsRead>;
551
556
 
@@ -47,7 +47,7 @@ import {
47
47
  MyThreadsQuery,
48
48
  RepliesQuery,
49
49
  ReplyInput,
50
- SearchThreadsQuery,
50
+ SearchThreadsBody,
51
51
  SubmitFeedbackInput,
52
52
  ThreadIdParam,
53
53
  } from "./schemas";
@@ -64,7 +64,7 @@ import {
64
64
  * The support routes, their verification strategies, and what each accepts:
65
65
  *
66
66
  * GET /support/threads → the inbox (control-plane: support:threads:read) query: ListThreadsQuery
67
- * GET /support/threads/search → search (control-plane: support:threads:read) query: SearchThreadsQuery
67
+ * POST /support/threads/search → search (control-plane: support:threads:read) json: SearchThreadsBody
68
68
  * GET /support/threads/:id → one thread (control-plane: support:threads:read) param: ThreadIdParam
69
69
  * POST /support/threads/:id/archive → done/reopen (control-plane: support:threads:archive) param + json
70
70
  * POST /support/threads/:id/assign → who has it (control-plane: support:threads:assign) param + json
@@ -114,8 +114,8 @@ import {
114
114
  * person who most needs to reach support is often the one whose access is broken, and a role the kit
115
115
  * invented would make one adopter's account model a condition on everybody's ability to report a bug.
116
116
  *
117
- * But an adopter whose *own* model makes some submissions act-on-behalf-of — `pithy-sh/dashboard#10`'s
118
- * discount application is made for an organization, and a member may not make one — needs somewhere to
117
+ * But an adopter whose *own* model makes some submissions act-on-behalf-of — a discount application made
118
+ * for an organization, which a member may not make — needs somewhere to
119
119
  * put that check, and it must not be the client. **The seam for it already exists, in the composition
120
120
  * contract rather than in this capability's config**, and it is documented here because it was not
121
121
  * discoverable rather than because it was missing:
@@ -238,18 +238,19 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
238
238
  async (c) => c.json(await listInbox(await resolve(c), c.req.valid("query"), viewer(c))),
239
239
  );
240
240
 
241
- // Before `/threads/:id`, so `search` is never read as a thread id.
242
- app.get(
241
+ app.post(
243
242
  `${base}/threads/search`,
244
243
  describeRoute({
245
244
  summary: "Search the inbox, by words and by meaning, ranked as one.",
245
+ description:
246
+ "The words travel in the body, because they are often a customer's address or order number and a query string is written to every request log between the client and the Worker. Read-only.",
246
247
  verification: "control-plane",
247
248
  scopes: [SUPPORT_THREADS_READ_SCOPE],
248
249
  responses: { 200: SupportSearchResponse },
249
250
  }),
250
251
  requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
251
- validate("query", SearchThreadsQuery),
252
- async (c) => c.json(await searchInbox(await resolve(c), c.req.valid("query"), viewer(c))),
252
+ validate("json", SearchThreadsBody),
253
+ async (c) => c.json(await searchInbox(await resolve(c), c.req.valid("json"), viewer(c))),
253
254
  );
254
255
 
255
256
  app.get(
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { freeText } from "@pithy-sh/core/src/http/freeText";
4
5
  import { PresenceParticipantName, PresenceParticipantRef } from "@pithy-sh/core/src/presence/frames";
5
6
  import { z } from "zod";
6
7
  import { MAX_SUBMISSION_ATTACHMENTS, MAX_SUBMISSION_BODY_CHARS, MAX_SUBMISSION_SUBJECT_CHARS } from "../config/config";
@@ -82,7 +83,7 @@ const threadFilterFields = {
82
83
  /**
83
84
  * The inbox listing query.
84
85
  *
85
- * **Strict, and there is no `q`.** Listing lists; `GET {basePath}/threads/search` searches. An unknown
86
+ * **Strict, and there is no `q`.** Listing lists; `POST {basePath}/threads/search` searches. An unknown
86
87
  * key is refused as `validation/invalid_input` rather than ignored, so a client sending `q` is told so
87
88
  * instead of getting the unfiltered inbox back as though it were the answer.
88
89
  */
@@ -114,19 +115,22 @@ export const SupportSearchMode = z
114
115
  );
115
116
  export type SupportSearchMode = z.infer<typeof SupportSearchMode>;
116
117
 
117
- /** The search query. */
118
- export const SearchThreadsQuery = z
118
+ /**
119
+ * The body of `POST {basePath}/threads/search`: the words, the mode, how many, and the inbox listing's filters.
120
+ * A body, not a query string, because the words are often a customer's address or order number and a URL is
121
+ * written to every request log between the client and the Worker. Strict, so a misspelled filter is refused
122
+ * rather than silently widening the search.
123
+ */
124
+ export const SearchThreadsBody = z
119
125
  .object({
120
- q: z
121
- .string()
122
- .trim()
123
- .min(1)
124
- .max(MAX_SEARCH_TERM)
125
- .describe("What to search for, in the customer's words or yours. An order number, an error, or a description."),
126
+ ...threadFilterFields,
127
+ q: freeText(z.string().trim().min(1).max(MAX_SEARCH_TERM)).describe(
128
+ "What to search for, in the customer's words or yours. An order number, an error, or a description.",
129
+ ),
126
130
  mode: SupportSearchMode.optional().describe(
127
131
  "Which backends to run. Absent runs every one that is on, as `hybrid`. `semantic` or `hybrid` while `search.semantic` is off is refused, naming the flag.",
128
132
  ),
129
- limit: z.coerce
133
+ limit: z
130
134
  .number()
131
135
  .int()
132
136
  .min(1)
@@ -135,11 +139,14 @@ export const SearchThreadsQuery = z
135
139
  .describe(
136
140
  `How many threads to return, best first. Default ${DEFAULT_SEARCH_LIMIT}, at most ${MAX_SEARCH_LIMIT}. No cursor: a ranked list has a top, not pages.`,
137
141
  ),
138
- ...threadFilterFields,
142
+ archived: z
143
+ .boolean()
144
+ .optional()
145
+ .describe("`true` for the done pile, `false` or absent for the open inbox — which is the default on purpose."),
139
146
  })
140
147
  .strict()
141
148
  .describe("A search over the inbox: the words, the mode, how many, and the same filters the inbox listing takes.");
142
- export type SearchThreadsQuery = z.infer<typeof SearchThreadsQuery>;
149
+ export type SearchThreadsBody = z.infer<typeof SearchThreadsBody>;
143
150
 
144
151
  /** The archive/unarchive body. */
145
152
  export const ArchiveThreadInput = z
@@ -200,8 +207,11 @@ export const ReplyInput = z
200
207
  .string()
201
208
  .min(1)
202
209
  .max(80)
210
+ .regex(/^[^@<>"]*$/, "A name may not contain @, <, > or a double quote.")
203
211
  .optional()
204
- .describe("Who is answering, signed at the bottom. Omitted rather than guessed."),
212
+ .describe(
213
+ "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.",
214
+ ),
205
215
  })
206
216
  .describe("An answer to send to the customer.");
207
217
  export type ReplyInput = z.infer<typeof ReplyInput>;
@@ -87,11 +87,12 @@ export function supportAdminRoutes(basePath: string): AdminRouteDeclaration[] {
87
87
  summary: "List the inbox, newest first, filtered by category, priority, assignee, and archived.",
88
88
  },
89
89
  {
90
- method: "GET",
90
+ method: "POST",
91
91
  operation: "threads.search",
92
92
  path: `${basePath}/threads/search`,
93
93
  scope: SUPPORT_THREADS_READ_SCOPE,
94
94
  kind: "reads",
95
+ readInBody: true,
95
96
  summary: "Search the inbox by words and by meaning, ranked as one, with the same filters as the list.",
96
97
  },
97
98
  {
@@ -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
+ }
package/src/reply/send.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
4
5
  import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
5
6
  import { commandName } from "@pithy-sh/core/src/cli/command";
6
7
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
@@ -10,6 +11,7 @@ import { SupportMessage } from "../data/message";
10
11
  import { SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE, type SupportDatabase } from "../data/tables";
11
12
  import { SupportNotFoundError, SupportReplyFailedError } from "../error/errors";
12
13
  import { buildReferencesHeader, replySubject } from "../mime/threading";
14
+ import { claimedSenders, replyFromName } from "./fromName";
13
15
 
14
16
  /**
15
17
  * Sending a reply — through `@pithy-sh/email`'s durable send path, never directly.
@@ -79,6 +81,12 @@ export interface ReplyDeps {
79
81
  template: string;
80
82
  payload: unknown;
81
83
  replyTo?: string;
84
+ /**
85
+ * Who the reply is sent as: the thread's inbox, under `reply.fromName` with the agent's name written in.
86
+ * `name` is absent when no agent name was given, and the email capability's `fromName` is used. The seam
87
+ * `compose` binds refuses an address support does not claim.
88
+ */
89
+ from?: { address: string; name?: string };
82
90
  /** The language to write the shell in — the reporter's, taken off their submission context. */
83
91
  locale?: string;
84
92
  inReplyTo?: string;
@@ -100,7 +108,7 @@ export interface ReplyInput {
100
108
  threadId: string;
101
109
  /** The reply text, as a human wrote and edited it. */
102
110
  body: string;
103
- /** Who is answering, signed at the bottom. Optional. */
111
+ /** Who is answering: signed at the bottom, and written into the From line through `reply.fromName`. Optional. */
104
112
  agentName?: string;
105
113
  /** The verified control-plane subject sending it — the audit actor. */
106
114
  viewer: string;
@@ -194,6 +202,16 @@ export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<Rep
194
202
  // **Both can be absent, and only on an `app` thread**: a project collecting in-app feedback with no
195
203
  // mail configured has no address a thread arrived at and none to answer from.
196
204
  const replyTo = deps.config.reply.replyToAddress ?? thread.inboxAddress;
205
+ // The reply leaves from the inbox the customer wrote to, under the name of whoever answered — or from
206
+ // `reply.replyToAddress` when that inbox is not one support claims, or there is none. A domain that cannot send
207
+ // fails the job naming it; nothing falls back to another sender then.
208
+ const claimed = claimedSenders(deps.config);
209
+ const inboxClaimed =
210
+ typeof thread.inboxAddress === "string" && claimed.includes(normalizeAddress(thread.inboxAddress));
211
+ const fromAddress = inboxClaimed
212
+ ? thread.inboxAddress
213
+ : (deps.config.reply.replyToAddress ?? thread.inboxAddress ?? null);
214
+ const fromName = replyFromName(deps.config.reply.fromName, input.agentName);
197
215
  const enqueue = deps.enqueue;
198
216
 
199
217
  // The one decision in this function. An `app` thread has a second destination — the submitter's own
@@ -225,12 +243,22 @@ export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<Rep
225
243
  detail: `thread ${input.threadId} has no inboxAddress and no reply.replyToAddress is configured`,
226
244
  });
227
245
  }
246
+ if (fromAddress && !claimed.includes(normalizeAddress(fromAddress))) {
247
+ // The seam would refuse it too. Said here, because only support knows why: the thread arrived at an inbox
248
+ // this configuration no longer claims.
249
+ throw new SupportReplyFailedError({
250
+ message: `This thread's inbox, ${fromAddress}, is no longer an address support claims, so it cannot be answered from it.`,
251
+ action: `Add ${fromAddress} back to \`inboundAddresses\`, or set \`reply.replyToAddress\` to an address support claims, then retry.`,
252
+ detail: `thread ${input.threadId} arrived at ${fromAddress}, which is not among the claimed senders`,
253
+ });
254
+ }
228
255
  try {
229
256
  const enqueued = await enqueue({
230
257
  to: thread.fromAddress,
231
258
  template: "supportReply",
232
259
  payload: { subject, body: input.body, ...(input.agentName ? { agentName: input.agentName } : {}) },
233
260
  replyTo,
261
+ ...(fromAddress ? { from: { address: fromAddress, ...(fromName ? { name: fromName } : {}) } } : {}),
234
262
  locale: replyLocale,
235
263
  inReplyTo: parent?.mimeMessageId ? `<${parent.mimeMessageId}>` : undefined,
236
264
  references: references.length > 0 ? references : undefined,
@@ -260,8 +288,8 @@ export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<Rep
260
288
  // No envelope on an in-app answer. `replyTo` may even be set — a project that chose in-app
261
289
  // delivery can have perfectly good mail — but writing it here would claim a send that did not
262
290
  // happen.
263
- fromAddress: inApp ? null : replyTo,
264
- fromName: null,
291
+ fromAddress: inApp ? null : (fromAddress ?? replyTo),
292
+ fromName: inApp ? null : (fromName ?? null),
265
293
  toAddress: inApp ? null : thread.fromAddress,
266
294
  subject,
267
295
  textBody: input.body,
@@ -0,0 +1,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { EmailCapability } from "@pithy-sh/email/src/capability";
7
+ import { isEmailCapability } from "@pithy-sh/email/src/capability";
8
+ import type { SupportConfig } from "../config/config";
9
+ import { claimedSenders } from "./fromName";
10
+
11
+ /** The `@pithy-sh/email` range whose capability carries `enqueueAs`. */
12
+ export const EMAIL_WITH_ENQUEUE_AS = "^0.9.0";
13
+
14
+ /**
15
+ * The enqueue a reply is sent through: the composed email capability's `enqueueAs`, bound to the addresses
16
+ * support claims. Undefined when no email capability is composed, which degrades the reply route alone.
17
+ *
18
+ * An email capability with no `enqueueAs` is a release older than the seam, and is refused here, at compose,
19
+ * rather than failing every reply.
20
+ */
21
+ export function replySender(
22
+ capabilities: readonly Capability[],
23
+ config: SupportConfig,
24
+ ): EmailCapability["enqueue"] | undefined {
25
+ const email = capabilities.find(isEmailCapability);
26
+ if (!email) return undefined;
27
+ if (typeof email.enqueueAs !== "function") {
28
+ throw new InternalError({
29
+ message: "The composed email capability is too old to send support replies.",
30
+ action: `Upgrade @pithy-sh/email to ${EMAIL_WITH_ENQUEUE_AS}, the release that carries enqueueAs, then redeploy.`,
31
+ detail: "email capability composed without enqueueAs",
32
+ });
33
+ }
34
+ return email.enqueueAs(claimedSenders(config));
35
+ }
@@ -97,7 +97,7 @@ export interface ThreadFilters {
97
97
  /**
98
98
  * What a caller may filter the inbox by, and where to resume.
99
99
  *
100
- * **No free text.** Listing lists; `GET {basePath}/threads/search` searches, ranked, with a limit and no
100
+ * **No free text.** Listing lists; `POST {basePath}/threads/search` searches, ranked, with a limit and no
101
101
  * cursor. A `q` here could say whether a thread matched, never how well.
102
102
  */
103
103
  export interface ListThreadsQuery extends ThreadFilters {
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/support";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.6.2";
16
+ export const PACKAGE_VERSION = "0.8.0";