@pithy-sh/support 0.1.2 → 0.1.4
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/dist/ai/classify.d.ts +92 -0
- package/dist/ai/classify.d.ts.map +1 -0
- package/dist/ai/classify.js +153 -0
- package/dist/attachment/store.d.ts +55 -0
- package/dist/attachment/store.d.ts.map +1 -0
- package/dist/attachment/store.js +69 -0
- package/dist/audit/actions.d.ts +70 -0
- package/dist/audit/actions.d.ts.map +1 -0
- package/dist/audit/actions.js +70 -0
- package/dist/capability.d.ts +95 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +189 -0
- package/dist/client/projection.d.ts +58 -0
- package/dist/client/projection.d.ts.map +1 -0
- package/dist/client/projection.js +2 -0
- package/dist/config/config.d.ts +217 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +171 -0
- package/dist/data/attachment.d.ts +32 -0
- package/dist/data/attachment.d.ts.map +1 -0
- package/dist/data/attachment.js +34 -0
- package/dist/data/billingScope.d.ts +32 -0
- package/dist/data/billingScope.d.ts.map +1 -0
- package/dist/data/billingScope.js +34 -0
- package/dist/data/categories.d.ts +56 -0
- package/dist/data/categories.d.ts.map +1 -0
- package/dist/data/categories.js +99 -0
- package/dist/data/classification.d.ts +36 -0
- package/dist/data/classification.d.ts.map +1 -0
- package/dist/data/classification.js +30 -0
- package/dist/data/enums.d.ts +84 -0
- package/dist/data/enums.d.ts.map +1 -0
- package/dist/data/enums.js +72 -0
- package/dist/data/flag.d.ts +27 -0
- package/dist/data/flag.d.ts.map +1 -0
- package/dist/data/flag.js +29 -0
- package/dist/data/message.d.ts +83 -0
- package/dist/data/message.d.ts.map +1 -0
- package/dist/data/message.js +86 -0
- package/dist/data/tables.d.ts +162 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +46 -0
- package/dist/data/thread.d.ts +79 -0
- package/dist/data/thread.d.ts.map +1 -0
- package/dist/data/thread.js +67 -0
- package/dist/error/errors.d.ts +66 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +84 -0
- package/dist/http/guards.d.ts +46 -0
- package/dist/http/guards.d.ts.map +1 -0
- package/dist/http/guards.js +55 -0
- package/dist/http/handlers.d.ts +91 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +250 -0
- package/dist/http/resolve.d.ts +46 -0
- package/dist/http/resolve.d.ts.map +1 -0
- package/dist/http/resolve.js +74 -0
- package/dist/http/responses.d.ts +831 -0
- package/dist/http/responses.d.ts.map +1 -0
- package/dist/http/responses.js +305 -0
- package/dist/http/routes.d.ts +111 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +64 -0
- package/dist/http/schemas.d.ts +123 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +91 -0
- package/dist/http/scopes.d.ts +58 -0
- package/dist/http/scopes.d.ts.map +1 -0
- package/dist/http/scopes.js +109 -0
- package/dist/http/views.d.ts +38 -0
- package/dist/http/views.d.ts.map +1 -0
- package/dist/http/views.js +146 -0
- package/dist/inbound/authenticity.d.ts +83 -0
- package/dist/inbound/authenticity.d.ts.map +1 -0
- package/dist/inbound/authenticity.js +63 -0
- package/dist/inbound/guard.d.ts +76 -0
- package/dist/inbound/guard.d.ts.map +1 -0
- package/dist/inbound/guard.js +80 -0
- package/dist/inbound/handler.d.ts +30 -0
- package/dist/inbound/handler.d.ts.map +1 -0
- package/dist/inbound/handler.js +84 -0
- package/dist/inbound/ingest.d.ts +106 -0
- package/dist/inbound/ingest.d.ts.map +1 -0
- package/dist/inbound/ingest.js +328 -0
- package/dist/inbound/recipient.d.ts +18 -0
- package/dist/inbound/recipient.d.ts.map +1 -0
- package/dist/inbound/recipient.js +48 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/link/sender.d.ts +162 -0
- package/dist/link/sender.d.ts.map +1 -0
- package/dist/link/sender.js +202 -0
- package/dist/migrations/0001_threads.d.ts +28 -0
- package/dist/migrations/0001_threads.d.ts.map +1 -0
- package/dist/migrations/0001_threads.js +105 -0
- package/dist/mime/address.d.ts +24 -0
- package/dist/mime/address.d.ts.map +1 -0
- package/dist/mime/address.js +30 -0
- package/dist/mime/parse.d.ts +98 -0
- package/dist/mime/parse.d.ts.map +1 -0
- package/dist/mime/parse.js +148 -0
- package/dist/mime/sanitize.d.ts +27 -0
- package/dist/mime/sanitize.d.ts.map +1 -0
- package/dist/mime/sanitize.js +214 -0
- package/dist/mime/threading.d.ts +81 -0
- package/dist/mime/threading.d.ts.map +1 -0
- package/dist/mime/threading.js +113 -0
- package/dist/mime/truncate.d.ts +19 -0
- package/dist/mime/truncate.d.ts.map +1 -0
- package/dist/mime/truncate.js +49 -0
- package/dist/provision/provisionSupport.d.ts +160 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -0
- package/dist/provision/provisionSupport.js +112 -0
- package/dist/provision/resolveSupportConfig.d.ts +47 -0
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
- package/dist/provision/resolveSupportConfig.js +24 -0
- package/dist/reply/send.d.ts +125 -0
- package/dist/reply/send.d.ts.map +1 -0
- package/dist/reply/send.js +155 -0
- package/dist/reply/snippets.d.ts +81 -0
- package/dist/reply/snippets.d.ts.map +1 -0
- package/dist/reply/snippets.js +137 -0
- package/dist/secret/registry.d.ts +20 -0
- package/dist/secret/registry.d.ts.map +1 -0
- package/dist/secret/registry.js +23 -0
- package/dist/seeds/example.d.ts +5 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +352 -0
- package/dist/store/paging.d.ts +20 -0
- package/dist/store/paging.d.ts.map +1 -0
- package/dist/store/paging.js +22 -0
- package/dist/store/search.d.ts +69 -0
- package/dist/store/search.d.ts.map +1 -0
- package/dist/store/search.js +162 -0
- package/dist/store/searchIndex.d.ts +55 -0
- package/dist/store/searchIndex.d.ts.map +1 -0
- package/dist/store/searchIndex.js +68 -0
- package/dist/store/threads.d.ts +149 -0
- package/dist/store/threads.d.ts.map +1 -0
- package/dist/store/threads.js +199 -0
- package/dist/submission/encoding.d.ts +23 -0
- package/dist/submission/encoding.d.ts.map +1 -0
- package/dist/submission/encoding.js +57 -0
- package/dist/submission/guard.d.ts +73 -0
- package/dist/submission/guard.d.ts.map +1 -0
- package/dist/submission/guard.js +48 -0
- package/dist/submission/submit.d.ts +154 -0
- package/dist/submission/submit.d.ts.map +1 -0
- package/dist/submission/submit.js +321 -0
- package/dist/version.generated.d.ts +7 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +9 -0
- package/dist/workflows/classify.d.ts +65 -0
- package/dist/workflows/classify.d.ts.map +1 -0
- package/dist/workflows/classify.js +70 -0
- package/dist/workflows/retryPolicy.d.ts +41 -0
- package/dist/workflows/retryPolicy.d.ts.map +1 -0
- package/dist/workflows/retryPolicy.js +45 -0
- package/dist/workflows/specs.d.ts +45 -0
- package/dist/workflows/specs.d.ts.map +1 -0
- package/dist/workflows/specs.js +48 -0
- package/dist/workflows/worker.d.ts +50 -0
- package/dist/workflows/worker.d.ts.map +1 -0
- package/dist/workflows/worker.js +39 -0
- package/package.json +24 -15
- package/src/http/handlers.ts +1 -1
- package/src/submission/encoding.ts +1 -1
- package/src/submission/submit.ts +1 -1
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
|
|
4
|
+
import { type ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
5
|
+
/**
|
|
6
|
+
* The provisioning orchestration for the support capability — the live counterpart to
|
|
7
|
+
* `pithy add support`'s config wiring.
|
|
8
|
+
*
|
|
9
|
+
* Three things have to exist before a support inbox works, and none of them can be a binding an
|
|
10
|
+
* adopter hand-writes: the R2 bucket attachments and raw messages land in, the prebuilt classification
|
|
11
|
+
* worker per environment, and — the one that actually delivers the mail — an Email Routing rule
|
|
12
|
+
* pointing the support address at the app worker.
|
|
13
|
+
*
|
|
14
|
+
* The live Cloudflare/wrangler steps sit behind the {@link SupportProvisioner} seam, so the
|
|
15
|
+
* orchestration (order, idempotency, per-env fan-out) is unit-tested without touching Cloudflare.
|
|
16
|
+
* Every step is idempotent; re-running is a no-op.
|
|
17
|
+
*
|
|
18
|
+
* ## The routing rule is opt-in, and that is a safety decision
|
|
19
|
+
*
|
|
20
|
+
* `ensureRoutingRule` returns `skipped: true` when no routing config is supplied, exactly as
|
|
21
|
+
* `@pithy-sh/email`'s does — because **enabling Email Routing on a zone points its MX at Cloudflare.**
|
|
22
|
+
* Creating a rule on the wrong zone would move an adopter's real inbound mail off their existing
|
|
23
|
+
* provider, which is not a mistake a provisioning command gets to make on their behalf. So the zone,
|
|
24
|
+
* the address, and the target worker are all explicit flags, and a project that has not decided yet
|
|
25
|
+
* provisions everything else and adds the rule later.
|
|
26
|
+
*
|
|
27
|
+
* The rule name is `<project>-global-support-inbound`, deliberately distinct from `@pithy-sh/email`'s
|
|
28
|
+
* `<project>-global-email-bounce`. Idempotency in `ensureWorkerRoute` keys on the rule *name*, so
|
|
29
|
+
* sharing one would make whichever capability provisioned second silently believe its rule already
|
|
30
|
+
* existed — and the project segment is what stops two Pithy projects on one zone doing the same to
|
|
31
|
+
* each other, with the loser's customer mail delivered to the winner's Worker.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The name of the Email Routing rule this capability creates, for one project. Distinct from the
|
|
35
|
+
* bounce handler's, and `global` in the environment slot because there is one rule per zone: the
|
|
36
|
+
* environments are separated by which app Worker the rule is pointed at, not by the rule.
|
|
37
|
+
*
|
|
38
|
+
* **Still on the generic composer**, and deliberately: an Email Routing rule is not a namespace
|
|
39
|
+
* `@pithy-sh/core/src/naming/limits` carries a verified Cloudflare cap for, and the facade exists so a
|
|
40
|
+
* kind of thing brings its own number rather than borrowing one. It takes the conservative default
|
|
41
|
+
* until that namespace lands — the same call `@pithy-sh/email`'s bounce rule makes.
|
|
42
|
+
*/
|
|
43
|
+
export declare function supportRoutingRuleName(project: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* The deployed Worker name for a project's environment — also its resolved config basename. Composed
|
|
46
|
+
* through core's naming facade under the **`worker`** namespace, so it is measured against a Worker
|
|
47
|
+
* script's 63 and the environment is validated on the way through. One source, so the name the CLI
|
|
48
|
+
* audits and deletes under cannot drift from the name it deploys under.
|
|
49
|
+
*/
|
|
50
|
+
export declare function supportWorkerName(project: string, env: ManagedEnvironment): string;
|
|
51
|
+
/** The live Cloudflare/wrangler seam. Each step must be idempotent. */
|
|
52
|
+
export interface SupportProvisioner {
|
|
53
|
+
/**
|
|
54
|
+
* Verify account prerequisites before any resource is created — most importantly a registered
|
|
55
|
+
* `workers.dev` subdomain, which Cloudflare requires to deploy the Workflow-hosting worker.
|
|
56
|
+
*/
|
|
57
|
+
preflight(): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Create (or reuse) the R2 bucket attachments and raw messages live in. Idempotent.
|
|
60
|
+
*
|
|
61
|
+
* Returns `skipped: true` when the capability is configured with attachments off — a bucket nobody
|
|
62
|
+
* writes to is a resource an adopter did not ask for.
|
|
63
|
+
*/
|
|
64
|
+
ensureBucket(): Promise<{
|
|
65
|
+
bucket: string;
|
|
66
|
+
created: boolean;
|
|
67
|
+
skipped: boolean;
|
|
68
|
+
}>;
|
|
69
|
+
/** Deploy the prebuilt classification worker for this environment. */
|
|
70
|
+
deployWorker(env: ManagedEnvironment): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Create or drop the full-text index in this environment's app database, to match `search.fts`.
|
|
73
|
+
*
|
|
74
|
+
* A provisioning step rather than a migration, because the index is **derived** — every row in it
|
|
75
|
+
* comes from `pithy_support_messages` and `reindexThread` rebuilds it on demand. That is the line a
|
|
76
|
+
* migration is for: schema whose loss loses data. Keeping it here is also what makes the flag safe
|
|
77
|
+
* to toggle at all, since a config-conditional migration removed from the set is corruption to
|
|
78
|
+
* Kysely and blocks `pithy migrate` for every capability sharing the database.
|
|
79
|
+
*/
|
|
80
|
+
ensureSearchIndex(env: ManagedEnvironment): Promise<{
|
|
81
|
+
created: boolean;
|
|
82
|
+
dropped: boolean;
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Ensure the inbound Email Routing rule that delivers the support address to the app worker.
|
|
86
|
+
* Idempotent, keyed on the rule name. Returns `skipped: true` when no routing config was supplied.
|
|
87
|
+
*/
|
|
88
|
+
ensureRoutingRule(): Promise<{
|
|
89
|
+
created: boolean;
|
|
90
|
+
skipped: boolean;
|
|
91
|
+
}>;
|
|
92
|
+
}
|
|
93
|
+
/** What provisioning produced. */
|
|
94
|
+
export interface SupportProvisionResult {
|
|
95
|
+
/** The R2 bucket, and whether it had to be created. */
|
|
96
|
+
bucket: {
|
|
97
|
+
bucket: string;
|
|
98
|
+
created: boolean;
|
|
99
|
+
skipped: boolean;
|
|
100
|
+
};
|
|
101
|
+
/** The environments a classification worker was deployed for. */
|
|
102
|
+
environments: ManagedEnvironment[];
|
|
103
|
+
/** What the full-text index did, per environment — created, dropped, or already correct. */
|
|
104
|
+
search: Array<{
|
|
105
|
+
env: ManagedEnvironment;
|
|
106
|
+
created: boolean;
|
|
107
|
+
dropped: boolean;
|
|
108
|
+
}>;
|
|
109
|
+
/** Whether the inbound routing rule was created, already present, or skipped. */
|
|
110
|
+
routing: {
|
|
111
|
+
created: boolean;
|
|
112
|
+
skipped: boolean;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Provision the support infrastructure.
|
|
117
|
+
*
|
|
118
|
+
* The order matters and is the inverse of how it fails: the bucket exists before a worker that could
|
|
119
|
+
* write to it, and **the routing rule is last** — creating it first would start delivering mail to a
|
|
120
|
+
* Worker whose classification host is not deployed yet, which is a window where real customer
|
|
121
|
+
* messages arrive and stay `uncategorized` with nothing to say why.
|
|
122
|
+
*
|
|
123
|
+
* `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
|
|
124
|
+
* environment is provisioned; an environment this skipped would be one the project deploys to with no
|
|
125
|
+
* resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
|
|
126
|
+
*/
|
|
127
|
+
export declare function provisionSupport(provisioner: SupportProvisioner, environments: DeclaredEnvironments | readonly string[]): Promise<SupportProvisionResult>;
|
|
128
|
+
/** The teardown seam — the inverse of {@link SupportProvisioner}. Every step idempotent. */
|
|
129
|
+
export interface SupportDeprovisioner {
|
|
130
|
+
/** Delete the env's classification worker. Idempotent. */
|
|
131
|
+
deleteWorker(env: ManagedEnvironment): Promise<void>;
|
|
132
|
+
/** Remove the inbound routing rule, so mail stops being delivered here. Idempotent. */
|
|
133
|
+
removeRoutingRule(): Promise<{
|
|
134
|
+
removed: boolean;
|
|
135
|
+
}>;
|
|
136
|
+
/**
|
|
137
|
+
* Delete the R2 bucket **and everything in it** — every attachment and every raw message an adopter's
|
|
138
|
+
* customers ever sent. Destructive, so the orchestration only calls it when explicitly asked.
|
|
139
|
+
*/
|
|
140
|
+
deleteBucket(): Promise<void>;
|
|
141
|
+
}
|
|
142
|
+
/** Teardown options. By default the bucket is **kept**: it holds correspondence, not cache. */
|
|
143
|
+
export interface SupportDeprovisionOptions {
|
|
144
|
+
/** Also delete the R2 bucket and its contents. Off by default. */
|
|
145
|
+
deleteStorage?: boolean;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Tear down the support infrastructure, reversing {@link provisionSupport}.
|
|
149
|
+
*
|
|
150
|
+
* The routing rule goes **first**, and that ordering is the whole point: stop new mail arriving before
|
|
151
|
+
* removing the workers that would have handled it, or messages land in a Worker with no classification
|
|
152
|
+
* host during the teardown. Stored correspondence is preserved unless explicitly requested — losing an
|
|
153
|
+
* adopter's support history to a teardown flag would be unrecoverable.
|
|
154
|
+
*
|
|
155
|
+
* `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
|
|
156
|
+
* environment is provisioned; an environment this skipped would be one the project deploys to with no
|
|
157
|
+
* resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
|
|
158
|
+
*/
|
|
159
|
+
export declare function deprovisionSupport(deprovisioner: SupportDeprovisioner, environments: DeclaredEnvironments | readonly string[], options?: SupportDeprovisionOptions): Promise<void>;
|
|
160
|
+
//# sourceMappingURL=provisionSupport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provisionSupport.d.ts","sourceRoot":"","sources":["../../src/provision/provisionSupport.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,uCAAuC,CAAC;AAIlF,OAAO,EAAE,KAAK,kBAAkB,EAAuB,MAAM,6BAA6B,CAAC;AAG3F;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,kBAAkB,GAAG,MAAM,CAElF;AAED,uEAAuE;AACvE,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B;;;;;OAKG;IACH,YAAY,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAChF,sEAAsE;IACtE,YAAY,CAAC,GAAG,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD;;;;;;;;OAQG;IACH,iBAAiB,CAAC,GAAG,EAAE,kBAAkB,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAC5F;;;OAGG;IACH,iBAAiB,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACtE;AAED,kCAAkC;AAClC,MAAM,WAAW,sBAAsB;IACrC,uDAAuD;IACvD,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IAC/D,iEAAiE;IACjE,YAAY,EAAE,kBAAkB,EAAE,CAAC;IACnC,4FAA4F;IAC5F,MAAM,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,kBAAkB,CAAC;QAAC,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAC/E,iFAAiF;IACjF,OAAO,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;CACjD;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,kBAAkB,EAC/B,YAAY,EAAE,oBAAoB,GAAG,SAAS,MAAM,EAAE,GACrD,OAAO,CAAC,sBAAsB,CAAC,CAYjC;AAED,4FAA4F;AAC5F,MAAM,WAAW,oBAAoB;IACnC,0DAA0D;IAC1D,YAAY,CAAC,GAAG,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,uFAAuF;IACvF,iBAAiB,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACnD;;;OAGG;IACH,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/B;AAED,+FAA+F;AAC/F,MAAM,WAAW,yBAAyB;IACxC,kEAAkE;IAClE,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,kBAAkB,CACtC,aAAa,EAAE,oBAAoB,EACnC,YAAY,EAAE,oBAAoB,GAAG,SAAS,MAAM,EAAE,EACtD,OAAO,GAAE,yBAA8B,GACtC,OAAO,CAAC,IAAI,CAAC,CAMf"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SUPPORT_CAPABILITY } from "../workflows/specs.js";
|
|
4
|
+
import { GLOBAL_SCOPE } from "@pithy-sh/core/src/naming/environment";
|
|
5
|
+
import { resourceName } from "@pithy-sh/core/src/naming/resource";
|
|
6
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
7
|
+
import { managedEnvironments } from "@pithy-sh/secrets/src/scope";
|
|
8
|
+
//#region src/provision/provisionSupport.ts
|
|
9
|
+
/**
|
|
10
|
+
* The provisioning orchestration for the support capability — the live counterpart to
|
|
11
|
+
* `pithy add support`'s config wiring.
|
|
12
|
+
*
|
|
13
|
+
* Three things have to exist before a support inbox works, and none of them can be a binding an
|
|
14
|
+
* adopter hand-writes: the R2 bucket attachments and raw messages land in, the prebuilt classification
|
|
15
|
+
* worker per environment, and — the one that actually delivers the mail — an Email Routing rule
|
|
16
|
+
* pointing the support address at the app worker.
|
|
17
|
+
*
|
|
18
|
+
* The live Cloudflare/wrangler steps sit behind the {@link SupportProvisioner} seam, so the
|
|
19
|
+
* orchestration (order, idempotency, per-env fan-out) is unit-tested without touching Cloudflare.
|
|
20
|
+
* Every step is idempotent; re-running is a no-op.
|
|
21
|
+
*
|
|
22
|
+
* ## The routing rule is opt-in, and that is a safety decision
|
|
23
|
+
*
|
|
24
|
+
* `ensureRoutingRule` returns `skipped: true` when no routing config is supplied, exactly as
|
|
25
|
+
* `@pithy-sh/email`'s does — because **enabling Email Routing on a zone points its MX at Cloudflare.**
|
|
26
|
+
* Creating a rule on the wrong zone would move an adopter's real inbound mail off their existing
|
|
27
|
+
* provider, which is not a mistake a provisioning command gets to make on their behalf. So the zone,
|
|
28
|
+
* the address, and the target worker are all explicit flags, and a project that has not decided yet
|
|
29
|
+
* provisions everything else and adds the rule later.
|
|
30
|
+
*
|
|
31
|
+
* The rule name is `<project>-global-support-inbound`, deliberately distinct from `@pithy-sh/email`'s
|
|
32
|
+
* `<project>-global-email-bounce`. Idempotency in `ensureWorkerRoute` keys on the rule *name*, so
|
|
33
|
+
* sharing one would make whichever capability provisioned second silently believe its rule already
|
|
34
|
+
* existed — and the project segment is what stops two Pithy projects on one zone doing the same to
|
|
35
|
+
* each other, with the loser's customer mail delivered to the winner's Worker.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The name of the Email Routing rule this capability creates, for one project. Distinct from the
|
|
39
|
+
* bounce handler's, and `global` in the environment slot because there is one rule per zone: the
|
|
40
|
+
* environments are separated by which app Worker the rule is pointed at, not by the rule.
|
|
41
|
+
*
|
|
42
|
+
* **Still on the generic composer**, and deliberately: an Email Routing rule is not a namespace
|
|
43
|
+
* `@pithy-sh/core/src/naming/limits` carries a verified Cloudflare cap for, and the facade exists so a
|
|
44
|
+
* kind of thing brings its own number rather than borrowing one. It takes the conservative default
|
|
45
|
+
* until that namespace lands — the same call `@pithy-sh/email`'s bounce rule makes.
|
|
46
|
+
*/
|
|
47
|
+
function supportRoutingRuleName(project) {
|
|
48
|
+
return resourceName({
|
|
49
|
+
project,
|
|
50
|
+
env: GLOBAL_SCOPE,
|
|
51
|
+
thing: `${SUPPORT_CAPABILITY}-inbound`
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The deployed Worker name for a project's environment — also its resolved config basename. Composed
|
|
56
|
+
* through core's naming facade under the **`worker`** namespace, so it is measured against a Worker
|
|
57
|
+
* script's 63 and the environment is validated on the way through. One source, so the name the CLI
|
|
58
|
+
* audits and deletes under cannot drift from the name it deploys under.
|
|
59
|
+
*/
|
|
60
|
+
function supportWorkerName(project, env) {
|
|
61
|
+
return resourceNames(project).env(env).worker(SUPPORT_CAPABILITY);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Provision the support infrastructure.
|
|
65
|
+
*
|
|
66
|
+
* The order matters and is the inverse of how it fails: the bucket exists before a worker that could
|
|
67
|
+
* write to it, and **the routing rule is last** — creating it first would start delivering mail to a
|
|
68
|
+
* Worker whose classification host is not deployed yet, which is a window where real customer
|
|
69
|
+
* messages arrive and stay `uncategorized` with nothing to say why.
|
|
70
|
+
*
|
|
71
|
+
* `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
|
|
72
|
+
* environment is provisioned; an environment this skipped would be one the project deploys to with no
|
|
73
|
+
* resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
|
|
74
|
+
*/
|
|
75
|
+
async function provisionSupport(provisioner, environments) {
|
|
76
|
+
await provisioner.preflight();
|
|
77
|
+
const bucket = await provisioner.ensureBucket();
|
|
78
|
+
const search = [];
|
|
79
|
+
for (const env of managedEnvironments(environments)) {
|
|
80
|
+
await provisioner.deployWorker(env);
|
|
81
|
+
search.push({
|
|
82
|
+
env,
|
|
83
|
+
...await provisioner.ensureSearchIndex(env)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const routing = await provisioner.ensureRoutingRule();
|
|
87
|
+
return {
|
|
88
|
+
bucket,
|
|
89
|
+
environments: managedEnvironments(environments),
|
|
90
|
+
routing,
|
|
91
|
+
search
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Tear down the support infrastructure, reversing {@link provisionSupport}.
|
|
96
|
+
*
|
|
97
|
+
* The routing rule goes **first**, and that ordering is the whole point: stop new mail arriving before
|
|
98
|
+
* removing the workers that would have handled it, or messages land in a Worker with no classification
|
|
99
|
+
* host during the teardown. Stored correspondence is preserved unless explicitly requested — losing an
|
|
100
|
+
* adopter's support history to a teardown flag would be unrecoverable.
|
|
101
|
+
*
|
|
102
|
+
* `environments` is the project's declaration from the root `pithy.config.ts` (#241). Every declared
|
|
103
|
+
* environment is provisioned; an environment this skipped would be one the project deploys to with no
|
|
104
|
+
* resources behind it — the silence the closed `ManagedEnvironment` enum used to produce.
|
|
105
|
+
*/
|
|
106
|
+
async function deprovisionSupport(deprovisioner, environments, options = {}) {
|
|
107
|
+
await deprovisioner.removeRoutingRule();
|
|
108
|
+
for (const env of managedEnvironments(environments)) await deprovisioner.deleteWorker(env);
|
|
109
|
+
if (options.deleteStorage) await deprovisioner.deleteBucket();
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
export { deprovisionSupport, provisionSupport, supportRoutingRuleName, supportWorkerName };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { type WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
|
|
4
|
+
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
5
|
+
import type { SupportConfig } from "../config/config";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the support worker's committed `wrangler.jsonc` template into one environment's standalone
|
|
8
|
+
* config. Every per-environment decision lives here; everything static — the compatibility date, the
|
|
9
|
+
* AI binding, the Workflow class name — stays as the template committed it.
|
|
10
|
+
*
|
|
11
|
+
* Thin over core's {@link resolveWorkflowHost}, which owns the mechanics (clone, fill by binding name,
|
|
12
|
+
* stamp `ENVIRONMENT`). This file owns only what is support's: which binding maps to which provisioned
|
|
13
|
+
* resource, the Workflow name derived from support's own specs, and the serialized config the worker
|
|
14
|
+
* parses.
|
|
15
|
+
*
|
|
16
|
+
* **`AI` is marked remote.** Workflows cannot use remote bindings in general, so a host always runs
|
|
17
|
+
* locally under `wrangler dev` — and Workers AI has no local emulation, so without this flag the
|
|
18
|
+
* classification step has nothing to call in development. Same treatment `@pithy-sh/vector` gives
|
|
19
|
+
* Vectorize, for the same reason.
|
|
20
|
+
*
|
|
21
|
+
* There is no secrets binding and no secrets database, and that absence is deliberate: this worker
|
|
22
|
+
* reads a message and writes a classification. The AI binding is its entire dependency, so it holds no
|
|
23
|
+
* credential at all — which is the smallest blast radius a deployed worker can have.
|
|
24
|
+
*/
|
|
25
|
+
/** The resolved resource ids + per-env values for one environment's support-worker deploy. */
|
|
26
|
+
export interface SupportConfigParams {
|
|
27
|
+
/**
|
|
28
|
+
* The project name — the `<project>` segment the deployed worker and its classification Workflow
|
|
29
|
+
* lead with. The root `pithy.config.ts` `name`, resolved by `requireProjectName` and never guessed.
|
|
30
|
+
*/
|
|
31
|
+
project: string;
|
|
32
|
+
/** The target environment. */
|
|
33
|
+
env: ManagedEnvironment;
|
|
34
|
+
/** The app database id for this environment — where the support tables live. */
|
|
35
|
+
appDatabaseId: string;
|
|
36
|
+
/**
|
|
37
|
+
* The app's resolved support config, serialized into the worker's `SUPPORT_CONFIG` var.
|
|
38
|
+
*
|
|
39
|
+
* This is how an adopter's federated categories reach the prompt. The worker is deployed from this
|
|
40
|
+
* package and cannot import their code, so the taxonomy has to travel as data — which it does,
|
|
41
|
+
* because it always was data.
|
|
42
|
+
*/
|
|
43
|
+
supportConfig: SupportConfig;
|
|
44
|
+
}
|
|
45
|
+
/** Fill the template for one environment. */
|
|
46
|
+
export declare function resolveSupportConfig(template: WorkflowHostTemplate, params: SupportConfigParams): WorkflowHostTemplate;
|
|
47
|
+
//# sourceMappingURL=resolveSupportConfig.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolveSupportConfig.d.ts","sourceRoot":"","sources":["../../src/provision/resolveSupportConfig.ts"],"names":[],"mappings":"AAGA,OAAO,EAAyC,KAAK,oBAAoB,EAAE,MAAM,kCAAkC,CAAC;AACpH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AACtE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD;;;;;;;;;;;;;;;;;;GAkBG;AAEH,8FAA8F;AAC9F,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,8BAA8B;IAC9B,GAAG,EAAE,kBAAkB,CAAC;IACxB,gFAAgF;IAChF,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;OAMG;IACH,aAAa,EAAE,aAAa,CAAC;CAC9B;AAED,6CAA6C;AAC7C,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,oBAAoB,EAC9B,MAAM,EAAE,mBAAmB,GAC1B,oBAAoB,CAatB"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SUPPORT_CAPABILITY, supportWorkflowRegistry } from "../workflows/specs.js";
|
|
4
|
+
import { hostWorkflowsFor, resolveWorkflowHost } from "@pithy-sh/core/src/workflow/host";
|
|
5
|
+
//#region src/provision/resolveSupportConfig.ts
|
|
6
|
+
/** Fill the template for one environment. */
|
|
7
|
+
function resolveSupportConfig(template, params) {
|
|
8
|
+
const { project, env } = params;
|
|
9
|
+
return resolveWorkflowHost(template, {
|
|
10
|
+
project,
|
|
11
|
+
capability: SUPPORT_CAPABILITY,
|
|
12
|
+
env,
|
|
13
|
+
databaseIds: { DB: params.appDatabaseId },
|
|
14
|
+
remoteBindings: ["AI"],
|
|
15
|
+
vars: { SUPPORT_CONFIG: JSON.stringify(params.supportConfig) },
|
|
16
|
+
workflows: hostWorkflowsFor(supportWorkflowRegistry, {
|
|
17
|
+
project,
|
|
18
|
+
capability: SUPPORT_CAPABILITY,
|
|
19
|
+
env
|
|
20
|
+
}).workflows
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { resolveSupportConfig };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
4
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
5
|
+
import type { SupportConfig } from "../config/config";
|
|
6
|
+
import { type SupportDatabase } from "../data/tables";
|
|
7
|
+
/**
|
|
8
|
+
* Sending a reply — through `@pithy-sh/email`'s durable send path, never directly.
|
|
9
|
+
*
|
|
10
|
+
* ## Why the reply is composed here and not in the dashboard
|
|
11
|
+
*
|
|
12
|
+
* Three things have to be true of an answer, and a dashboard can guarantee none of them. It has to
|
|
13
|
+
* leave from the adopter's domain carrying the adopter's DKIM, so it is not filtered as a forgery.
|
|
14
|
+
* It has to be durable and retryable, which is what the email capability's job row and Workflow buy.
|
|
15
|
+
* And it has to set `In-Reply-To` and `References` correctly — which only the Worker can do, because
|
|
16
|
+
* only the Worker holds the thread's chain. Implemented dashboard-side, threading breaks in the
|
|
17
|
+
* customer's mail client and every conversation fragments into one message per answer.
|
|
18
|
+
*
|
|
19
|
+
* So the dashboard POSTs a body. That is the entire contract, and it is why the reply route is small.
|
|
20
|
+
*
|
|
21
|
+
* ## The sent Message-ID is not ours to know
|
|
22
|
+
*
|
|
23
|
+
* Cloudflare assigns the real `Message-ID` at send time and does not tell the enqueuer, so the
|
|
24
|
+
* outbound row stores `emailJobId` and leaves `mimeMessageId` null. Threading back still works, and
|
|
25
|
+
* this is the part worth being clear about: when the customer replies, their `References` carries the
|
|
26
|
+
* whole ancestry — including the *inbound* ids this capability stored itself — so `parentCandidates`
|
|
27
|
+
* finds the thread through one of those even though it has never seen the id of its own reply.
|
|
28
|
+
*
|
|
29
|
+
* ## Storing the answer and sending it are two steps, and one of them is optional
|
|
30
|
+
*
|
|
31
|
+
* Everything above describes mail. An `app` thread has a second destination that already exists:
|
|
32
|
+
* `readOwnThread` hands the submitter every message on their own conversation, outbound ones
|
|
33
|
+
* included, so an answer stored and never sent is an answer they will read next time they open it.
|
|
34
|
+
*
|
|
35
|
+
* That is the whole shape of in-app delivery — the outbound row, the thread counters and the audit
|
|
36
|
+
* event are written exactly as they are for mail, and only the `enqueue` is skipped. It is taken in
|
|
37
|
+
* two situations, and they are different in kind:
|
|
38
|
+
*
|
|
39
|
+
* - **The adopter chose it.** `reply.deliverInApp` on a project whose mail works perfectly well.
|
|
40
|
+
* Email Routing takes over a zone's MX, so a project already running mail on that domain cannot
|
|
41
|
+
* receive support replies without disturbing everything else on it — and a fallback conditioned on
|
|
42
|
+
* mail being *impossible* is unreachable by exactly the adopter who most wants this.
|
|
43
|
+
* - **There is nothing to send with.** No address a reply could come back to, or no email capability
|
|
44
|
+
* composed at all. Storing the answer beats refusing it, because the person can still read it.
|
|
45
|
+
*
|
|
46
|
+
* An `email` thread never takes it. Its sender has no read-back — there is no session, only an
|
|
47
|
+
* address — so an answer stored there is one nobody would ever see, and a missing reply address on a
|
|
48
|
+
* mail thread stays the misconfiguration it always was.
|
|
49
|
+
*
|
|
50
|
+
* **The two are never rendered the same.** "Sent by email" and "waiting in the app" are different
|
|
51
|
+
* promises about when somebody will read the answer, and `ReplyResult` is a union rather than an
|
|
52
|
+
* object with an optional `jobId` so that a console has to say which one it is showing.
|
|
53
|
+
*/
|
|
54
|
+
/** What a reply needs, all injectable. */
|
|
55
|
+
export interface ReplyDeps {
|
|
56
|
+
/** The support tables. */
|
|
57
|
+
db: SupportDatabase;
|
|
58
|
+
/** The resolved config. */
|
|
59
|
+
config: SupportConfig;
|
|
60
|
+
/**
|
|
61
|
+
* The email capability's `enqueue`, already bound to the request env. Support never assembles the
|
|
62
|
+
* send infrastructure — it hands over a recipient, a template id, and a payload.
|
|
63
|
+
*
|
|
64
|
+
* **Optional, because a project can compose support without composing email.** Absent, an `app`
|
|
65
|
+
* thread is answered in the app rather than refused, and only a mail thread has nothing left to
|
|
66
|
+
* try. The refusal is raised here rather than by the caller so that the one place that knows
|
|
67
|
+
* whether mail is needed is the one that asks for it.
|
|
68
|
+
*/
|
|
69
|
+
enqueue?: (input: {
|
|
70
|
+
to: string;
|
|
71
|
+
template: string;
|
|
72
|
+
payload: unknown;
|
|
73
|
+
replyTo?: string;
|
|
74
|
+
/** The language to write the shell in — the reporter's, taken off their submission context. */
|
|
75
|
+
locale?: string;
|
|
76
|
+
inReplyTo?: string;
|
|
77
|
+
references?: string;
|
|
78
|
+
}) => Promise<{
|
|
79
|
+
jobId: string;
|
|
80
|
+
}>;
|
|
81
|
+
/** Whether the full-text index is in use. */
|
|
82
|
+
fts: boolean;
|
|
83
|
+
/** The audit seam. */
|
|
84
|
+
emit: AuditEmit;
|
|
85
|
+
/** The request logger. */
|
|
86
|
+
log: Logger;
|
|
87
|
+
/** Generate a row id. */
|
|
88
|
+
newId: () => string;
|
|
89
|
+
/** Now. */
|
|
90
|
+
now: () => Date;
|
|
91
|
+
}
|
|
92
|
+
/** What the operator is sending. */
|
|
93
|
+
export interface ReplyInput {
|
|
94
|
+
/** The thread being answered. */
|
|
95
|
+
threadId: string;
|
|
96
|
+
/** The reply text, as a human wrote and edited it. */
|
|
97
|
+
body: string;
|
|
98
|
+
/** Who is answering, signed at the bottom. Optional. */
|
|
99
|
+
agentName?: string;
|
|
100
|
+
/** The verified control-plane subject sending it — the audit actor. */
|
|
101
|
+
viewer: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* What the reply produced, discriminated on the channel it was delivered over.
|
|
105
|
+
*
|
|
106
|
+
* A union rather than one object with an optional `jobId`, because the two outcomes are different
|
|
107
|
+
* promises about when the customer reads the answer — and an optional field is exactly what lets a
|
|
108
|
+
* console render "sent" over both of them.
|
|
109
|
+
*/
|
|
110
|
+
export type ReplyResult = {
|
|
111
|
+
/** Handed to the email capability's durable send path. */
|
|
112
|
+
channel: "email";
|
|
113
|
+
/** The outbound `pithy_support_messages.id`. */
|
|
114
|
+
messageId: string;
|
|
115
|
+
/** The `pithy_email_jobs.id` the send was enqueued as. */
|
|
116
|
+
jobId: string;
|
|
117
|
+
} | {
|
|
118
|
+
/** Stored for the submitter to read in the app. No mail was sent, and there is no job. */
|
|
119
|
+
channel: "app";
|
|
120
|
+
/** The outbound `pithy_support_messages.id`. */
|
|
121
|
+
messageId: string;
|
|
122
|
+
};
|
|
123
|
+
/** Send a reply on a thread. */
|
|
124
|
+
export declare function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<ReplyResult>;
|
|
125
|
+
//# sourceMappingURL=send.d.ts.map
|
|
@@ -0,0 +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;AACnE,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,+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,6CAA6C;IAC7C,GAAG,EAAE,OAAO,CAAC;IACb,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,CA2LxF"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SupportNotFoundError, SupportReplyFailedError } from "../error/errors.js";
|
|
4
|
+
import { SupportMessage } from "../data/message.js";
|
|
5
|
+
import { SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
|
|
6
|
+
import { SupportAuditActions } from "../audit/actions.js";
|
|
7
|
+
import { buildReferencesHeader, replySubject } from "../mime/threading.js";
|
|
8
|
+
import { indexMessage } from "../store/search.js";
|
|
9
|
+
//#region src/reply/send.ts
|
|
10
|
+
/** Send a reply on a thread. */
|
|
11
|
+
async function sendReply(deps, input) {
|
|
12
|
+
if (!deps.config.reply.enabled) throw new SupportReplyFailedError({
|
|
13
|
+
message: "Replying is disabled on this deployment.",
|
|
14
|
+
action: "Set `reply.enabled` on the support capability in pithy.config.ts, then redeploy.",
|
|
15
|
+
detail: "support config has reply.enabled = false"
|
|
16
|
+
});
|
|
17
|
+
const thread = await deps.db.selectFrom(SUPPORT_THREADS_TABLE).select([
|
|
18
|
+
"id",
|
|
19
|
+
"channel",
|
|
20
|
+
"subject",
|
|
21
|
+
"fromAddress",
|
|
22
|
+
"fromName",
|
|
23
|
+
"inboxAddress",
|
|
24
|
+
"messageCount"
|
|
25
|
+
]).where("id", "=", input.threadId).executeTakeFirst();
|
|
26
|
+
if (!thread) throw new SupportNotFoundError({ detail: `no support thread ${input.threadId}` });
|
|
27
|
+
const parent = await deps.db.selectFrom(SUPPORT_MESSAGES_TABLE).select([
|
|
28
|
+
"mimeMessageId",
|
|
29
|
+
"mimeReferences",
|
|
30
|
+
"context"
|
|
31
|
+
]).where("threadId", "=", input.threadId).where("direction", "=", "inbound").orderBy("receivedAt", "desc").orderBy("id", "desc").limit(1).executeTakeFirst();
|
|
32
|
+
/**
|
|
33
|
+
* The language to answer in: the one the reporter's app was rendering in when they wrote.
|
|
34
|
+
*
|
|
35
|
+
* **The customer's, never the agent's.** A reply is composed inside an operator's request, and that
|
|
36
|
+
* request's negotiated locale is the operator's — so reading `c.var.t` here would answer a Spanish
|
|
37
|
+
* customer in whatever language the support console happens to be open in. The submission context is
|
|
38
|
+
* the only thing on this thread that says something about the *reader*, and it is already collected
|
|
39
|
+
* because a locale-shaped bug is only reproducible in the reporter's locale.
|
|
40
|
+
*
|
|
41
|
+
* It moves the shell and not the letter. `supportReply`'s words are the adopter's — a human wrote
|
|
42
|
+
* them, and a catalog cannot translate a sentence it has never seen — so what follows this tag is the
|
|
43
|
+
* document's `lang` and `dir` and the footer. That is the honest half, and it is the half that decides
|
|
44
|
+
* whether an Arabic reply lays out right-to-left.
|
|
45
|
+
*
|
|
46
|
+
* Absent on every mail-path message, which is most of them, and absent renders as it always has.
|
|
47
|
+
*/
|
|
48
|
+
const parentContext = SupportMessage.shape.context.safeParse(parent?.context);
|
|
49
|
+
const replyLocale = parentContext.success ? parentContext.data?.locale ?? void 0 : void 0;
|
|
50
|
+
const parentReferences = SupportMessage.shape.mimeReferences.safeParse(parent?.mimeReferences);
|
|
51
|
+
const chain = parentReferences.success && Array.isArray(parentReferences.data) ? parentReferences.data : [];
|
|
52
|
+
const references = buildReferencesHeader(chain, parent?.mimeMessageId ?? void 0);
|
|
53
|
+
const subject = replySubject(thread.subject);
|
|
54
|
+
const now = deps.now();
|
|
55
|
+
const replyTo = deps.config.reply.replyToAddress ?? thread.inboxAddress;
|
|
56
|
+
const enqueue = deps.enqueue;
|
|
57
|
+
const deliverable = enqueue !== void 0 && replyTo !== null && replyTo !== void 0;
|
|
58
|
+
const inApp = thread.channel === "app" && (deps.config.reply.deliverInApp || !deliverable);
|
|
59
|
+
let jobId = null;
|
|
60
|
+
if (!inApp) {
|
|
61
|
+
if (!enqueue) throw new SupportReplyFailedError({
|
|
62
|
+
message: "This deployment cannot send mail.",
|
|
63
|
+
action: "Add the email capability (`pithy add email`) and provision it, then retry.",
|
|
64
|
+
detail: `thread ${input.threadId} needs a mailed reply and no email capability is composed`
|
|
65
|
+
});
|
|
66
|
+
if (!replyTo) throw new SupportReplyFailedError({
|
|
67
|
+
message: "This deployment has no address a reply can come back to.",
|
|
68
|
+
action: "Set `inboundAddresses` or `reply.replyToAddress` on the support capability, then retry.",
|
|
69
|
+
detail: `thread ${input.threadId} has no inboxAddress and no reply.replyToAddress is configured`
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
jobId = (await enqueue({
|
|
73
|
+
to: thread.fromAddress,
|
|
74
|
+
template: "supportReply",
|
|
75
|
+
payload: {
|
|
76
|
+
subject,
|
|
77
|
+
body: input.body,
|
|
78
|
+
...input.agentName ? { agentName: input.agentName } : {}
|
|
79
|
+
},
|
|
80
|
+
replyTo,
|
|
81
|
+
locale: replyLocale,
|
|
82
|
+
inReplyTo: parent?.mimeMessageId ? `<${parent.mimeMessageId}>` : void 0,
|
|
83
|
+
references: references.length > 0 ? references : void 0
|
|
84
|
+
})).jobId;
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
throw new SupportReplyFailedError({ detail: `enqueue failed for thread ${input.threadId}` }, { cause });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const messageId = deps.newId();
|
|
90
|
+
const message = {
|
|
91
|
+
id: messageId,
|
|
92
|
+
threadId: input.threadId,
|
|
93
|
+
direction: "outbound",
|
|
94
|
+
channel: inApp ? "app" : "email",
|
|
95
|
+
submittedByUserId: null,
|
|
96
|
+
context: null,
|
|
97
|
+
mimeMessageId: null,
|
|
98
|
+
mimeInReplyTo: parent?.mimeMessageId ?? null,
|
|
99
|
+
mimeReferences: chain.length > 0 ? chain : null,
|
|
100
|
+
fromAddress: inApp ? null : replyTo,
|
|
101
|
+
fromName: null,
|
|
102
|
+
toAddress: inApp ? null : thread.fromAddress,
|
|
103
|
+
subject,
|
|
104
|
+
textBody: input.body,
|
|
105
|
+
htmlBody: null,
|
|
106
|
+
emailJobId: jobId,
|
|
107
|
+
rawKey: null,
|
|
108
|
+
rawBytes: null,
|
|
109
|
+
receivedAt: now,
|
|
110
|
+
createdAt: now
|
|
111
|
+
};
|
|
112
|
+
await deps.db.insertInto(SUPPORT_MESSAGES_TABLE).values(SupportMessage.encode(message)).execute();
|
|
113
|
+
if (deps.fts) try {
|
|
114
|
+
await indexMessage(deps.db, {
|
|
115
|
+
threadId: input.threadId,
|
|
116
|
+
messageId,
|
|
117
|
+
subject,
|
|
118
|
+
body: input.body
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
deps.log.warn("support reply not indexed", {
|
|
122
|
+
messageId,
|
|
123
|
+
error
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
await deps.db.updateTable(SUPPORT_THREADS_TABLE).set((eb) => ({
|
|
127
|
+
messageCount: eb("messageCount", "+", 1),
|
|
128
|
+
lastMessageAt: now.getTime(),
|
|
129
|
+
updatedAt: now.getTime()
|
|
130
|
+
})).where("id", "=", input.threadId).execute();
|
|
131
|
+
await deps.emit({
|
|
132
|
+
action: SupportAuditActions.replySent,
|
|
133
|
+
outcome: "success",
|
|
134
|
+
actorType: "control-plane",
|
|
135
|
+
actorId: input.viewer,
|
|
136
|
+
resourceType: "support_thread",
|
|
137
|
+
resourceId: input.threadId,
|
|
138
|
+
metadata: {
|
|
139
|
+
channel: message.channel,
|
|
140
|
+
messageId,
|
|
141
|
+
threaded: Boolean(parent?.mimeMessageId),
|
|
142
|
+
...jobId === null ? {} : { jobId }
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
return jobId === null ? {
|
|
146
|
+
channel: "app",
|
|
147
|
+
messageId
|
|
148
|
+
} : {
|
|
149
|
+
channel: "email",
|
|
150
|
+
messageId,
|
|
151
|
+
jobId
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
export { sendReply };
|