@pithy-sh/support 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- 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.map +1 -1
- package/dist/http/handlers.js +2 -1
- package/dist/http/responses.d.ts +5 -0
- package/dist/http/responses.d.ts.map +1 -1
- package/dist/http/responses.js +3 -2
- package/dist/http/schemas.d.ts.map +1 -1
- package/dist/http/schemas.js +1 -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/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +8 -8
- package/pithy.manifest.json +1 -1
- package/src/capability.ts +18 -15
- package/src/config/config.ts +13 -5
- package/src/http/handlers.ts +1 -0
- package/src/http/responses.ts +9 -1
- package/src/http/schemas.ts +4 -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/version.generated.ts +1 -1
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/version.generated.ts
CHANGED