@pithy-sh/support 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +2 -0
  2. package/dist/capability.d.ts +1 -1
  3. package/dist/capability.d.ts.map +1 -1
  4. package/dist/capability.js +5 -4
  5. package/dist/config/config.d.ts +7 -5
  6. package/dist/config/config.d.ts.map +1 -1
  7. package/dist/config/config.js +6 -5
  8. package/dist/http/handlers.d.ts.map +1 -1
  9. package/dist/http/handlers.js +2 -1
  10. package/dist/http/responses.d.ts +5 -0
  11. package/dist/http/responses.d.ts.map +1 -1
  12. package/dist/http/responses.js +3 -2
  13. package/dist/http/schemas.d.ts.map +1 -1
  14. package/dist/http/schemas.js +1 -1
  15. package/dist/provision/provisionSupport.d.ts +19 -20
  16. package/dist/provision/provisionSupport.d.ts.map +1 -1
  17. package/dist/provision/provisionSupport.js +14 -17
  18. package/dist/provision/sendingCheck.d.ts +7 -0
  19. package/dist/provision/sendingCheck.d.ts.map +1 -0
  20. package/dist/provision/sendingCheck.js +63 -0
  21. package/dist/reply/fromName.d.ts +20 -0
  22. package/dist/reply/fromName.d.ts.map +1 -0
  23. package/dist/reply/fromName.js +29 -0
  24. package/dist/reply/send.d.ts +10 -1
  25. package/dist/reply/send.d.ts.map +1 -1
  26. package/dist/reply/send.js +16 -2
  27. package/dist/reply/sender.d.ts +16 -0
  28. package/dist/reply/sender.d.ts.map +1 -0
  29. package/dist/reply/sender.js +27 -0
  30. package/dist/version.generated.d.ts +1 -1
  31. package/dist/version.generated.js +1 -1
  32. package/package.json +8 -8
  33. package/pithy.manifest.json +1 -1
  34. package/src/capability.ts +18 -15
  35. package/src/config/config.ts +13 -5
  36. package/src/http/handlers.ts +1 -0
  37. package/src/http/responses.ts +9 -1
  38. package/src/http/schemas.ts +4 -1
  39. package/src/provision/provisionSupport.ts +20 -21
  40. package/src/provision/sendingCheck.ts +69 -0
  41. package/src/reply/fromName.ts +37 -0
  42. package/src/reply/send.ts +31 -3
  43. package/src/reply/sender.ts +35 -0
  44. package/src/version.generated.ts +1 -1
package/src/reply/send.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
4
5
  import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
5
6
  import { commandName } from "@pithy-sh/core/src/cli/command";
6
7
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
@@ -10,6 +11,7 @@ import { SupportMessage } from "../data/message";
10
11
  import { SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE, type SupportDatabase } from "../data/tables";
11
12
  import { SupportNotFoundError, SupportReplyFailedError } from "../error/errors";
12
13
  import { buildReferencesHeader, replySubject } from "../mime/threading";
14
+ import { claimedSenders, replyFromName } from "./fromName";
13
15
 
14
16
  /**
15
17
  * Sending a reply — through `@pithy-sh/email`'s durable send path, never directly.
@@ -79,6 +81,12 @@ export interface ReplyDeps {
79
81
  template: string;
80
82
  payload: unknown;
81
83
  replyTo?: string;
84
+ /**
85
+ * Who the reply is sent as: the thread's inbox, under `reply.fromName` with the agent's name written in.
86
+ * `name` is absent when no agent name was given, and the email capability's `fromName` is used. The seam
87
+ * `compose` binds refuses an address support does not claim.
88
+ */
89
+ from?: { address: string; name?: string };
82
90
  /** The language to write the shell in — the reporter's, taken off their submission context. */
83
91
  locale?: string;
84
92
  inReplyTo?: string;
@@ -100,7 +108,7 @@ export interface ReplyInput {
100
108
  threadId: string;
101
109
  /** The reply text, as a human wrote and edited it. */
102
110
  body: string;
103
- /** Who is answering, signed at the bottom. Optional. */
111
+ /** Who is answering: signed at the bottom, and written into the From line through `reply.fromName`. Optional. */
104
112
  agentName?: string;
105
113
  /** The verified control-plane subject sending it — the audit actor. */
106
114
  viewer: string;
@@ -194,6 +202,16 @@ export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<Rep
194
202
  // **Both can be absent, and only on an `app` thread**: a project collecting in-app feedback with no
195
203
  // mail configured has no address a thread arrived at and none to answer from.
196
204
  const replyTo = deps.config.reply.replyToAddress ?? thread.inboxAddress;
205
+ // The reply leaves from the inbox the customer wrote to, under the name of whoever answered — or from
206
+ // `reply.replyToAddress` when that inbox is not one support claims, or there is none. A domain that cannot send
207
+ // fails the job naming it; nothing falls back to another sender then.
208
+ const claimed = claimedSenders(deps.config);
209
+ const inboxClaimed =
210
+ typeof thread.inboxAddress === "string" && claimed.includes(normalizeAddress(thread.inboxAddress));
211
+ const fromAddress = inboxClaimed
212
+ ? thread.inboxAddress
213
+ : (deps.config.reply.replyToAddress ?? thread.inboxAddress ?? null);
214
+ const fromName = replyFromName(deps.config.reply.fromName, input.agentName);
197
215
  const enqueue = deps.enqueue;
198
216
 
199
217
  // The one decision in this function. An `app` thread has a second destination — the submitter's own
@@ -225,12 +243,22 @@ export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<Rep
225
243
  detail: `thread ${input.threadId} has no inboxAddress and no reply.replyToAddress is configured`,
226
244
  });
227
245
  }
246
+ if (fromAddress && !claimed.includes(normalizeAddress(fromAddress))) {
247
+ // The seam would refuse it too. Said here, because only support knows why: the thread arrived at an inbox
248
+ // this configuration no longer claims.
249
+ throw new SupportReplyFailedError({
250
+ message: `This thread's inbox, ${fromAddress}, is no longer an address support claims, so it cannot be answered from it.`,
251
+ action: `Add ${fromAddress} back to \`inboundAddresses\`, or set \`reply.replyToAddress\` to an address support claims, then retry.`,
252
+ detail: `thread ${input.threadId} arrived at ${fromAddress}, which is not among the claimed senders`,
253
+ });
254
+ }
228
255
  try {
229
256
  const enqueued = await enqueue({
230
257
  to: thread.fromAddress,
231
258
  template: "supportReply",
232
259
  payload: { subject, body: input.body, ...(input.agentName ? { agentName: input.agentName } : {}) },
233
260
  replyTo,
261
+ ...(fromAddress ? { from: { address: fromAddress, ...(fromName ? { name: fromName } : {}) } } : {}),
234
262
  locale: replyLocale,
235
263
  inReplyTo: parent?.mimeMessageId ? `<${parent.mimeMessageId}>` : undefined,
236
264
  references: references.length > 0 ? references : undefined,
@@ -260,8 +288,8 @@ export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<Rep
260
288
  // No envelope on an in-app answer. `replyTo` may even be set — a project that chose in-app
261
289
  // delivery can have perfectly good mail — but writing it here would claim a send that did not
262
290
  // happen.
263
- fromAddress: inApp ? null : replyTo,
264
- fromName: null,
291
+ fromAddress: inApp ? null : (fromAddress ?? replyTo),
292
+ fromName: inApp ? null : (fromName ?? null),
265
293
  toAddress: inApp ? null : thread.fromAddress,
266
294
  subject,
267
295
  textBody: input.body,
@@ -0,0 +1,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { EmailCapability } from "@pithy-sh/email/src/capability";
7
+ import { isEmailCapability } from "@pithy-sh/email/src/capability";
8
+ import type { SupportConfig } from "../config/config";
9
+ import { claimedSenders } from "./fromName";
10
+
11
+ /** The `@pithy-sh/email` range whose capability carries `enqueueAs`. */
12
+ export const EMAIL_WITH_ENQUEUE_AS = "^0.9.0";
13
+
14
+ /**
15
+ * The enqueue a reply is sent through: the composed email capability's `enqueueAs`, bound to the addresses
16
+ * support claims. Undefined when no email capability is composed, which degrades the reply route alone.
17
+ *
18
+ * An email capability with no `enqueueAs` is a release older than the seam, and is refused here, at compose,
19
+ * rather than failing every reply.
20
+ */
21
+ export function replySender(
22
+ capabilities: readonly Capability[],
23
+ config: SupportConfig,
24
+ ): EmailCapability["enqueue"] | undefined {
25
+ const email = capabilities.find(isEmailCapability);
26
+ if (!email) return undefined;
27
+ if (typeof email.enqueueAs !== "function") {
28
+ throw new InternalError({
29
+ message: "The composed email capability is too old to send support replies.",
30
+ action: `Upgrade @pithy-sh/email to ${EMAIL_WITH_ENQUEUE_AS}, the release that carries enqueueAs, then redeploy.`,
31
+ detail: "email capability composed without enqueueAs",
32
+ });
33
+ }
34
+ return email.enqueueAs(claimedSenders(config));
35
+ }
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/support";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.6.1";
16
+ export const PACKAGE_VERSION = "0.7.0";