@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.
- package/README.md +3 -1
- package/dist/capability.d.ts +1 -1
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +5 -4
- package/dist/config/config.d.ts +7 -5
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +6 -5
- package/dist/http/handlers.d.ts +2 -2
- package/dist/http/handlers.d.ts.map +1 -1
- package/dist/http/handlers.js +3 -2
- package/dist/http/responses.d.ts +9 -5
- package/dist/http/responses.d.ts.map +1 -1
- package/dist/http/responses.js +10 -12
- package/dist/http/routes.d.ts +3 -3
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +4 -3
- package/dist/http/schemas.d.ts +11 -9
- package/dist/http/schemas.d.ts.map +1 -1
- package/dist/http/schemas.js +15 -8
- package/dist/http/scopes.d.ts.map +1 -1
- package/dist/http/scopes.js +2 -1
- package/dist/provision/provisionSupport.d.ts +19 -20
- package/dist/provision/provisionSupport.d.ts.map +1 -1
- package/dist/provision/provisionSupport.js +14 -17
- package/dist/provision/sendingCheck.d.ts +7 -0
- package/dist/provision/sendingCheck.d.ts.map +1 -0
- package/dist/provision/sendingCheck.js +63 -0
- package/dist/reply/fromName.d.ts +20 -0
- package/dist/reply/fromName.d.ts.map +1 -0
- package/dist/reply/fromName.js +29 -0
- package/dist/reply/send.d.ts +10 -1
- package/dist/reply/send.d.ts.map +1 -1
- package/dist/reply/send.js +16 -2
- package/dist/reply/sender.d.ts +16 -0
- package/dist/reply/sender.d.ts.map +1 -0
- package/dist/reply/sender.js +27 -0
- package/dist/store/threads.d.ts +1 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +8 -8
- package/pithy.manifest.json +2 -2
- package/src/capability.ts +18 -15
- package/src/config/config.ts +13 -5
- package/src/http/handlers.ts +4 -3
- package/src/http/responses.ts +18 -13
- package/src/http/routes.ts +9 -8
- package/src/http/schemas.ts +23 -13
- package/src/http/scopes.ts +2 -1
- package/src/provision/provisionSupport.ts +20 -21
- package/src/provision/sendingCheck.ts +69 -0
- package/src/reply/fromName.ts +37 -0
- package/src/reply/send.ts +31 -3
- package/src/reply/sender.ts +35 -0
- package/src/store/threads.ts +1 -1
- package/src/version.generated.ts +1 -1
package/src/config/config.ts
CHANGED
|
@@ -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
|
|
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.
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
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;
|
package/src/http/handlers.ts
CHANGED
|
@@ -56,7 +56,7 @@ import type {
|
|
|
56
56
|
MyThreadsQuery,
|
|
57
57
|
RepliesQuery,
|
|
58
58
|
ReplyInput,
|
|
59
|
-
|
|
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:
|
|
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 ===
|
|
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
|
);
|
package/src/http/responses.ts
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/** `
|
|
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
|
|
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
|
|
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
|
|
514
|
-
*
|
|
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
|
-
/** `
|
|
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
|
|
package/src/http/routes.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
MyThreadsQuery,
|
|
48
48
|
RepliesQuery,
|
|
49
49
|
ReplyInput,
|
|
50
|
-
|
|
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
|
-
*
|
|
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 —
|
|
118
|
-
*
|
|
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
|
-
|
|
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("
|
|
252
|
-
async (c) => c.json(await searchInbox(await resolve(c), c.req.valid("
|
|
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(
|
package/src/http/schemas.ts
CHANGED
|
@@ -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; `
|
|
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
|
-
/**
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
.
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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>;
|
package/src/http/scopes.ts
CHANGED
|
@@ -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: "
|
|
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
|
|
31
|
+
* ## The routing rule never enables Email Routing
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
41
|
-
* `<project>-global-
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
136
|
-
*
|
|
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.
|
|
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
|
|
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
|
+
}
|
package/src/store/threads.ts
CHANGED
|
@@ -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; `
|
|
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 {
|
package/src/version.generated.ts
CHANGED