@aglyn/plugins-outreach 1.0.0-beta.274 → 1.0.0-beta.277

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 (61) hide show
  1. package/README.md +7 -2
  2. package/package.json +14 -14
  3. package/src/lib/components/enrollment-actions.d.ts +11 -4
  4. package/src/lib/components/enrollment-actions.js +16 -0
  5. package/src/lib/components/enrollment-actions.js.map +1 -1
  6. package/src/lib/components/enrollment-detail.js +40 -13
  7. package/src/lib/components/enrollment-detail.js.map +1 -1
  8. package/src/lib/components/mailbox-card.js +13 -2
  9. package/src/lib/components/mailbox-card.js.map +1 -1
  10. package/src/lib/components/record-reply-button.d.ts +52 -0
  11. package/src/lib/components/record-reply-button.js +92 -0
  12. package/src/lib/components/record-reply-button.js.map +1 -0
  13. package/src/lib/components/reply-dialog.d.ts +67 -0
  14. package/src/lib/components/reply-dialog.js +353 -0
  15. package/src/lib/components/reply-dialog.js.map +1 -0
  16. package/src/lib/components/use-outreach-api.d.ts +5 -1
  17. package/src/lib/components/use-outreach-api.js +10 -0
  18. package/src/lib/components/use-outreach-api.js.map +1 -1
  19. package/src/lib/constants/api-routes.d.ts +8 -0
  20. package/src/lib/constants/api-routes.js +6 -0
  21. package/src/lib/constants/api-routes.js.map +1 -1
  22. package/src/lib/engine/index.d.ts +1 -0
  23. package/src/lib/engine/index.js +1 -0
  24. package/src/lib/engine/index.js.map +1 -1
  25. package/src/lib/engine/manual-reply.d.ts +122 -0
  26. package/src/lib/engine/manual-reply.js +311 -0
  27. package/src/lib/engine/manual-reply.js.map +1 -0
  28. package/src/lib/model/enrollment-history.d.ts +2 -0
  29. package/src/lib/model/enrollment-history.js +10 -5
  30. package/src/lib/model/enrollment-history.js.map +1 -1
  31. package/src/lib/model/enrollment-timeline.d.ts +3 -1
  32. package/src/lib/model/enrollment-timeline.js +90 -9
  33. package/src/lib/model/enrollment-timeline.js.map +1 -1
  34. package/src/lib/model/outreach-api.d.ts +68 -2
  35. package/src/lib/model/outreach-api.js.map +1 -1
  36. package/src/lib/model/outreach.types.d.ts +91 -3
  37. package/src/lib/model/outreach.types.js +6 -0
  38. package/src/lib/model/outreach.types.js.map +1 -1
  39. package/src/lib/model/stored-records.d.ts +7 -1
  40. package/src/lib/model/stored-records.js +37 -1
  41. package/src/lib/model/stored-records.js.map +1 -1
  42. package/src/lib/plugin.d.ts +4 -0
  43. package/src/lib/plugin.js +17 -0
  44. package/src/lib/plugin.js.map +1 -1
  45. package/src/lib/routes/register-routes.d.ts +7 -0
  46. package/src/lib/routes/register-routes.js +26 -0
  47. package/src/lib/routes/register-routes.js.map +1 -1
  48. package/src/lib/routes/reply-routes.d.ts +92 -0
  49. package/src/lib/routes/reply-routes.js +644 -0
  50. package/src/lib/routes/reply-routes.js.map +1 -0
  51. package/src/lib/runtime/click-events.d.ts +13 -0
  52. package/src/lib/runtime/click-events.js +19 -6
  53. package/src/lib/runtime/click-events.js.map +1 -1
  54. package/src/lib/runtime/click-link.d.ts +8 -0
  55. package/src/lib/runtime/click-link.js +7 -0
  56. package/src/lib/runtime/click-link.js.map +1 -1
  57. package/src/lib/runtime/sync-job.js +14 -0
  58. package/src/lib/runtime/sync-job.js.map +1 -1
  59. package/src/lib/transport/send-message.d.ts +11 -0
  60. package/src/lib/transport/send-message.js +22 -0
  61. package/src/lib/transport/send-message.js.map +1 -1
package/README.md CHANGED
@@ -27,7 +27,10 @@ Two halves, like the CRM plugin:
27
27
  - **Console** (`registerOutreachConsole`, client barrel): the organization-level
28
28
  Sequences hub at `/[orgSlug]/outreach/<section>`, declared through
29
29
  `ConsoleExtension.orgNavItems`, behind `release_outreach`, the
30
- `features.outreach` entitlement and the `outreach.use` permission.
30
+ `features.outreach` entitlement and the `outreach.use` permission. Behind
31
+ the same gates, one widget in a zone the CRM hosts: **Reply in thread from**
32
+ *the mailbox* in a contact's or a lead's header, before Send email
33
+ (`crmRecordEmailActions`, AGL-4020).
31
34
  - **Console API** (`registerOutreachConsoleApi`, `@aglyn/plugins-outreach/server`):
32
35
  handlers under the `outreach/` prefix, served by the console's
33
36
  `/api/[...pluginApi]` dispatcher.
@@ -90,6 +93,7 @@ pure functions with no I/O, exported from the package root:
90
93
  | `mailbox-notice` | the email a mailbox's owner gets when it pauses itself or needs reconnecting |
91
94
  | `gateway-block` | whether a hard bounce is the recipient domain's gateway refusing the sender, rather than one unknown address |
92
95
  | `do-not-contact-domain` | how a domain on the do-not-contact list is spelled |
96
+ | `manual-reply` | answering a person who wrote back (AGL-4020): what the enrollment keeps of their messages, the in-thread answer (`Re:`, `In-Reply-To`, `References`, their message quoted) and what still refuses it |
93
97
 
94
98
  `engine/do-not-contact` derives the do-not-contact document id with
95
99
  `node:crypto`, so it is not in the barrel: import
@@ -103,7 +107,8 @@ console alone — each run opens a rep's sealed grant:
103
107
  | module | does |
104
108
  | -- | -- |
105
109
  | `runtime/send-job` | `outreach-send`: due enrollments claimed in a transaction that reserves the mailbox's day, gates asked again, the email composed — with the `List-Unsubscribe` header when the sequence turns it on (AGL-3296), and its links' `outreachLinks` documents written first when it tracks clicks (AGL-3297) — and sent, the step recorded and filed on the contact's timeline; task steps file the rep's task |
106
- | `runtime/sync-job` | `outreach-sync`: replies, out-of-office answers, opt-outs and bounces read with `format=full` and classified by the engine; the mailbox's health evidence and its automatic pause |
110
+ | `runtime/sync-job` | `outreach-sync`: replies, out-of-office answers, opt-outs and bounces read with `format=full` and classified by the engine; what the person wrote kept on the enrollment (AGL-4020); the mailbox's health evidence and its automatic pause |
111
+ | `routes/reply-routes`, `components/reply-dialog` | **Reply in thread** (AGL-4020): `outreach/enrollments/thread` reads what the person wrote — from the mailbox, for a reply synced before replies were kept — and `outreach/enrollments/reply` sends a member's answer from the enrollment's mailbox in the same thread, kept as a `manual_reply` step record and on the CRM timeline, counted on `health.daily.<day>.manualReplies` and never against the cap; on a sequence that tracks clicks its links go out as short links, each a click of the person's and never the sequence's (AGL-4022) |
107
112
  | `runtime/unsubscribe-link`, `runtime/unsubscribe-route` | the signed one-click link (`GET` and RFC 8058 `POST /api/outreach/unsubscribe`), a recipient link that outlives a paused rollout, and the `+unsubscribe` mailto |
108
113
  | `runtime/click-link`, `runtime/click-route` | click tracking: the short link every send carries (`GET /api/outreach/l/<id>`, resolved through the top-level `outreachLinks` document, AGL-3297) and the signed `GET /api/outreach/click?t=…` link older emails carry; both recipient links, redirect first, then the click counted once per person with scanners kept apart; on the mailbox domain's verified `links.` host as `https://links.<domain>/<id>` (AGL-3306), which the console middleware rewrites to the same route |
109
114
  | `routes/link-domain-routes`, `components/link-domains-card` | Sequences → Mailboxes → **Link domains** (AGL-3306): `outreach/link-domains` lists each mailbox domain's `links.` host and sets one up, checks it or removes it through `@aglyn/tenant-data-admin/server/tracking-hosts` — the domain driver attaches it, an HTTPS probe verifies it; set-up and removal are an owner's or admin's |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-outreach",
3
- "version": "1.0.0-beta.274",
3
+ "version": "1.0.0-beta.277",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,19 +25,19 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.274",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.274",
30
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.274",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.274",
32
- "@aglyn/shared-ui-next": "1.0.0-beta.274",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.274",
34
- "@aglyn/shared-util-email": "1.0.0-beta.274",
35
- "@aglyn/shared-util-http": "1.0.0-beta.274",
36
- "@aglyn/shared-util-timestamp": "1.0.0-beta.274",
37
- "@aglyn/shared-util-tools": "1.0.0-beta.274",
38
- "@aglyn/tenant-data-admin": "1.0.0-beta.274",
39
- "@aglyn/tenant-feature-instance": "1.0.0-beta.274",
40
- "@aglyn/tenant-runtime": "1.0.0-beta.274",
28
+ "@aglyn/aglyn": "1.0.0-beta.277",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.277",
30
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.277",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.277",
32
+ "@aglyn/shared-ui-next": "1.0.0-beta.277",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.277",
34
+ "@aglyn/shared-util-email": "1.0.0-beta.277",
35
+ "@aglyn/shared-util-http": "1.0.0-beta.277",
36
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.277",
37
+ "@aglyn/shared-util-tools": "1.0.0-beta.277",
38
+ "@aglyn/tenant-data-admin": "1.0.0-beta.277",
39
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.277",
40
+ "@aglyn/tenant-runtime": "1.0.0-beta.277",
41
41
  "@swc/helpers": "0.5.23"
42
42
  },
43
43
  "peerDependencies": {
@@ -9,9 +9,14 @@ import type { OutreachApi } from './use-outreach-api';
9
9
  * the header of the person's own page (AGL-3332). The same actions, the same
10
10
  * words, the same two confirmations — a Stop that asked first in one place
11
11
  * and not in the other would be two products.
12
+ *
13
+ * REPLY IN THREAD (AGL-4020) is offered once the person wrote back — a
14
+ * reply, or an automatic answer — and opens the composer that answers them
15
+ * from the enrollment's own mailbox. Not to a person who opted out or whose
16
+ * address bounced: nothing would reach them, or they asked for nothing to.
12
17
  */
13
- /** The four the action route takes, and curating the next step (AGL-3324). */
14
- export type OutreachEnrollmentRowAction = OutreachEnrollmentAction | 'curate';
18
+ /** The four the action route takes, curating the next step (AGL-3324), and answering a reply (AGL-4020). */
19
+ export type OutreachEnrollmentRowAction = OutreachEnrollmentAction | 'curate' | 'reply';
15
20
  /** What a member may do to an enrollment in the status it is in. */
16
21
  export declare function outreachEnrollmentActionsFor(enrollment: OutreachEnrollment, steps: readonly OutreachSequenceStep[]): OutreachEnrollmentRowAction[];
17
22
  export declare const OUTREACH_ENROLLMENT_ACTION_LABELS: Record<OutreachEnrollmentRowAction, string>;
@@ -20,14 +25,16 @@ export declare const OUTREACH_DESTRUCTIVE_ENROLLMENT_ACTIONS: readonly OutreachE
20
25
  export interface OutreachEnrollmentActions {
21
26
  /** The enrollment an action is running on, if any. */
22
27
  busy: string | null;
23
- /** Runs one action: straight away, after its confirmation, or in the curate dialog. */
28
+ /** Runs one action: straight away, after its confirmation, or in the curate or reply dialog. */
24
29
  choose(enrollment: OutreachEnrollment, action: OutreachEnrollmentRowAction): void;
25
30
  /** The actions as menu items, the destructive ones marked. */
26
31
  menuItems(enrollment: OutreachEnrollment, only?: (action: OutreachEnrollmentRowAction) => boolean): RowActionsMenuItem[];
27
- /** The confirmation and the curate dialog, rendered once wherever the actions are offered. */
32
+ /** The confirmation, the curate dialog and the reply composer, rendered once wherever the actions are offered. */
28
33
  dialogs: ReactNode;
29
34
  }
30
35
  export declare function useOutreachEnrollmentActions(input: {
31
36
  api: OutreachApi;
32
37
  steps: readonly OutreachSequenceStep[];
38
+ /** The mailbox's zone, for the times the reply composer shows. */
39
+ timeZone?: string | null;
33
40
  }): OutreachEnrollmentActions;
@@ -18,9 +18,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
18
18
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
19
19
  import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Stack, TextField } from "@mui/material";
20
20
  import { useState } from "react";
21
+ import { outreachEnrollmentHasInbound } from "../engine/manual-reply.js";
21
22
  import { OutreachCurateStepDialog, outreachNextEmailStepIndex } from "./curate-step-dialog.js";
23
+ import { OutreachReplyDialog } from "./reply-dialog.js";
22
24
  /** What a member may do to an enrollment in the status it is in. */ export function outreachEnrollmentActionsFor(enrollment, steps) {
23
25
  const actions = [];
26
+ // Answer them in the thread, once they wrote back (AGL-4020).
27
+ if (outreachEnrollmentHasInbound(enrollment) && enrollment.status !== 'opted_out' && enrollment.status !== 'bounced') {
28
+ actions.push('reply');
29
+ }
24
30
  // Rewrite the next email for this one person, while one is still to go.
25
31
  if (outreachNextEmailStepIndex(enrollment, steps) !== null) actions.push('curate');
26
32
  if (enrollment.status === 'active') actions.push('pause', 'stop');
@@ -29,6 +35,7 @@ import { OutreachCurateStepDialog, outreachNextEmailStepIndex } from "./curate-s
29
35
  return actions;
30
36
  }
31
37
  export const OUTREACH_ENROLLMENT_ACTION_LABELS = {
38
+ reply: 'Reply in thread',
32
39
  curate: 'Curate next step',
33
40
  pause: 'Pause',
34
41
  resume: 'Resume',
@@ -64,6 +71,7 @@ export function useOutreachEnrollmentActions(input) {
64
71
  const [detail, setDetail] = useState('');
65
72
  const [busy, setBusy] = useState(null);
66
73
  const [curating, setCurating] = useState(null);
74
+ const [replying, setReplying] = useState(null);
67
75
  const act = async (enrollment, action, why)=>{
68
76
  setBusy(enrollment.id);
69
77
  try {
@@ -84,6 +92,8 @@ export function useOutreachEnrollmentActions(input) {
84
92
  const choose = (enrollment, action)=>{
85
93
  if (action === 'curate') {
86
94
  setCurating(enrollment);
95
+ } else if (action === 'reply') {
96
+ setReplying(enrollment);
87
97
  } else if (CONFIRM[action]) {
88
98
  setDetail('');
89
99
  setConfirming({
@@ -109,6 +119,12 @@ export function useOutreachEnrollmentActions(input) {
109
119
  api: api,
110
120
  onClose: ()=>setCurating(null)
111
121
  }),
122
+ /*#__PURE__*/ _jsx(OutreachReplyDialog, {
123
+ enrollment: replying,
124
+ api: api,
125
+ timeZone: input.timeZone,
126
+ onClose: ()=>setReplying(null)
127
+ }),
112
128
  /*#__PURE__*/ _jsx(Dialog, {
113
129
  open: Boolean(confirming),
114
130
  onClose: ()=>setConfirming(null),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/enrollment-actions.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogContentText,\n DialogTitle,\n Stack,\n TextField,\n} from '@mui/material'\nimport { type ReactNode, useState } from 'react'\nimport type { OutreachEnrollmentAction } from '../model/outreach-api'\nimport type { OutreachEnrollment, OutreachSequenceStep } from '../model/outreach.types'\nimport { OutreachCurateStepDialog, outreachNextEmailStepIndex } from './curate-step-dialog'\nimport type { OutreachApi } from './use-outreach-api'\n\n/**\n * WHAT A MEMBER CAN DO TO ONE ENROLLMENT (AGL-2980, AGL-3324), in one place\n * for the two places it is offered: a row's menu on the Enrollments tab, and\n * the header of the person's own page (AGL-3332). The same actions, the same\n * words, the same two confirmations — a Stop that asked first in one place\n * and not in the other would be two products.\n */\n\n/** The four the action route takes, and curating the next step (AGL-3324). */\nexport type OutreachEnrollmentRowAction = OutreachEnrollmentAction | 'curate'\n\n/** What a member may do to an enrollment in the status it is in. */\nexport function outreachEnrollmentActionsFor(\n enrollment: OutreachEnrollment,\n steps: readonly OutreachSequenceStep[],\n): OutreachEnrollmentRowAction[] {\n const actions: OutreachEnrollmentRowAction[] = []\n // Rewrite the next email for this one person, while one is still to go.\n if (outreachNextEmailStepIndex(enrollment, steps) !== null) actions.push('curate')\n if (enrollment.status === 'active') actions.push('pause', 'stop')\n if (enrollment.status === 'paused') actions.push('resume', 'stop')\n if (enrollment.status !== 'opted_out') actions.push('do_not_contact')\n return actions\n}\n\nexport const OUTREACH_ENROLLMENT_ACTION_LABELS: Record<OutreachEnrollmentRowAction, string> = {\n curate: 'Curate next step',\n pause: 'Pause',\n resume: 'Resume',\n stop: 'Stop',\n do_not_contact: 'Mark do-not-contact',\n}\n\n/** The two actions that end something for good. */\nexport const OUTREACH_DESTRUCTIVE_ENROLLMENT_ACTIONS: readonly OutreachEnrollmentRowAction[] = [\n 'stop',\n 'do_not_contact',\n]\n\n/** What the snackbar says once an action lands. */\nconst DONE_LABELS: Record<Exclude<OutreachEnrollmentAction, 'do_not_contact'>, string> = {\n pause: 'Paused.',\n resume: 'Resumed.',\n stop: 'Stopped.',\n}\n\n/** The two actions that end something for good ask first. */\nconst CONFIRM: Partial<Record<OutreachEnrollmentAction, { title: string; body: string; label: string }>> = {\n stop: {\n title: 'Stop this enrollment?',\n body: 'They won’t get any more steps from this sequence, and they can’t be enrolled in it again.',\n label: 'Stop',\n },\n do_not_contact: {\n title: 'Mark do-not-contact?',\n body:\n 'The address goes on your organization’s do-not-contact list, which every sequence checks before ' +\n 'every send, and they are stopped in every sequence they are in.',\n label: 'Mark do-not-contact',\n },\n}\n\nexport interface OutreachEnrollmentActions {\n /** The enrollment an action is running on, if any. */\n busy: string | null\n /** Runs one action: straight away, after its confirmation, or in the curate dialog. */\n choose(enrollment: OutreachEnrollment, action: OutreachEnrollmentRowAction): void\n /** The actions as menu items, the destructive ones marked. */\n menuItems(\n enrollment: OutreachEnrollment,\n only?: (action: OutreachEnrollmentRowAction) => boolean,\n ): RowActionsMenuItem[]\n /** The confirmation and the curate dialog, rendered once wherever the actions are offered. */\n dialogs: ReactNode\n}\n\nexport function useOutreachEnrollmentActions(input: {\n api: OutreachApi\n steps: readonly OutreachSequenceStep[]\n}): OutreachEnrollmentActions {\n const { api, steps } = input\n const { enqueueSnackbar } = useSnackbar()\n const [confirming, setConfirming] = useState<{\n enrollment: OutreachEnrollment\n action: OutreachEnrollmentAction\n } | null>(null)\n const [detail, setDetail] = useState('')\n const [busy, setBusy] = useState<string | null>(null)\n const [curating, setCurating] = useState<OutreachEnrollment | null>(null)\n\n const act = async (enrollment: OutreachEnrollment, action: OutreachEnrollmentAction, why?: string) => {\n setBusy(enrollment.id)\n try {\n const answer = await api.actOnEnrollment(enrollment.id, action, why)\n const others = answer.stoppedOthers\n enqueueSnackbar(\n action === 'do_not_contact'\n ? `Marked do-not-contact${others ? `, and stopped in ${others} other ${others === 1 ? 'sequence' : 'sequences'}` : ''}.`\n : answer.changed\n ? DONE_LABELS[action]\n : 'Nothing to change.',\n { variant: 'success' },\n )\n } catch (error) {\n enqueueSnackbar((error as Error).message, { variant: 'error', allowDuplicate: true })\n } finally {\n setBusy(null)\n }\n }\n\n const choose = (enrollment: OutreachEnrollment, action: OutreachEnrollmentRowAction) => {\n if (action === 'curate') {\n setCurating(enrollment)\n } else if (CONFIRM[action]) {\n setDetail('')\n setConfirming({ enrollment, action })\n } else {\n void act(enrollment, action)\n }\n }\n\n const menuItems = (\n enrollment: OutreachEnrollment,\n only?: (action: OutreachEnrollmentRowAction) => boolean,\n ): RowActionsMenuItem[] =>\n outreachEnrollmentActionsFor(enrollment, steps)\n .filter((action) => !only || only(action))\n .map((action) => ({\n key: action,\n label: OUTREACH_ENROLLMENT_ACTION_LABELS[action],\n onClick: () => choose(enrollment, action),\n destructive: OUTREACH_DESTRUCTIVE_ENROLLMENT_ACTIONS.includes(action),\n disabled: busy === enrollment.id,\n }))\n\n const dialogs = (\n <>\n <OutreachCurateStepDialog enrollment={curating} steps={steps} api={api} onClose={() => setCurating(null)} />\n <Dialog open={Boolean(confirming)} onClose={() => setConfirming(null)} fullWidth maxWidth=\"xs\">\n {confirming ? (\n <>\n <DialogTitle>{CONFIRM[confirming.action]?.title}</DialogTitle>\n <DialogContent>\n <Stack spacing={2}>\n <DialogContentText>{CONFIRM[confirming.action]?.body}</DialogContentText>\n <TextField\n label=\"Why (optional)\"\n value={detail}\n onChange={(event) => setDetail(event.target.value)}\n fullWidth\n slotProps={{ htmlInput: { maxLength: 200 } }}\n />\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={() => setConfirming(null)}>Cancel</Button>\n <Button\n color=\"error\"\n variant=\"contained\"\n onClick={() => {\n const { enrollment, action } = confirming\n setConfirming(null)\n void act(enrollment, action, detail.trim() || undefined)\n }}\n >\n {CONFIRM[confirming.action]?.label}\n </Button>\n </DialogActions>\n </>\n ) : null}\n </Dialog>\n </>\n )\n\n return { busy, choose, menuItems, dialogs }\n}\n"],"names":["useSnackbar","Button","Dialog","DialogActions","DialogContent","DialogContentText","DialogTitle","Stack","TextField","useState","OutreachCurateStepDialog","outreachNextEmailStepIndex","outreachEnrollmentActionsFor","enrollment","steps","actions","push","status","OUTREACH_ENROLLMENT_ACTION_LABELS","curate","pause","resume","stop","do_not_contact","OUTREACH_DESTRUCTIVE_ENROLLMENT_ACTIONS","DONE_LABELS","CONFIRM","title","body","label","useOutreachEnrollmentActions","input","api","enqueueSnackbar","confirming","setConfirming","detail","setDetail","busy","setBusy","curating","setCurating","act","action","why","id","answer","actOnEnrollment","others","stoppedOthers","changed","variant","error","message","allowDuplicate","choose","menuItems","only","filter","map","key","onClick","destructive","includes","disabled","dialogs","onClose","open","Boolean","fullWidth","maxWidth","spacing","value","onChange","event","target","slotProps","htmlInput","maxLength","color","trim","undefined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/enrollment-actions.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogContentText,\n DialogTitle,\n Stack,\n TextField,\n} from '@mui/material'\nimport { type ReactNode, useState } from 'react'\nimport { outreachEnrollmentHasInbound } from '../engine/manual-reply'\nimport type { OutreachEnrollmentAction } from '../model/outreach-api'\nimport type { OutreachEnrollment, OutreachSequenceStep } from '../model/outreach.types'\nimport { OutreachCurateStepDialog, outreachNextEmailStepIndex } from './curate-step-dialog'\nimport { OutreachReplyDialog } from './reply-dialog'\nimport type { OutreachApi } from './use-outreach-api'\n\n/**\n * WHAT A MEMBER CAN DO TO ONE ENROLLMENT (AGL-2980, AGL-3324), in one place\n * for the two places it is offered: a row's menu on the Enrollments tab, and\n * the header of the person's own page (AGL-3332). The same actions, the same\n * words, the same two confirmations — a Stop that asked first in one place\n * and not in the other would be two products.\n *\n * REPLY IN THREAD (AGL-4020) is offered once the person wrote back — a\n * reply, or an automatic answer — and opens the composer that answers them\n * from the enrollment's own mailbox. Not to a person who opted out or whose\n * address bounced: nothing would reach them, or they asked for nothing to.\n */\n\n/** The four the action route takes, curating the next step (AGL-3324), and answering a reply (AGL-4020). */\nexport type OutreachEnrollmentRowAction = OutreachEnrollmentAction | 'curate' | 'reply'\n\n/** What a member may do to an enrollment in the status it is in. */\nexport function outreachEnrollmentActionsFor(\n enrollment: OutreachEnrollment,\n steps: readonly OutreachSequenceStep[],\n): OutreachEnrollmentRowAction[] {\n const actions: OutreachEnrollmentRowAction[] = []\n // Answer them in the thread, once they wrote back (AGL-4020).\n if (\n outreachEnrollmentHasInbound(enrollment) &&\n enrollment.status !== 'opted_out' &&\n enrollment.status !== 'bounced'\n ) {\n actions.push('reply')\n }\n // Rewrite the next email for this one person, while one is still to go.\n if (outreachNextEmailStepIndex(enrollment, steps) !== null) actions.push('curate')\n if (enrollment.status === 'active') actions.push('pause', 'stop')\n if (enrollment.status === 'paused') actions.push('resume', 'stop')\n if (enrollment.status !== 'opted_out') actions.push('do_not_contact')\n return actions\n}\n\nexport const OUTREACH_ENROLLMENT_ACTION_LABELS: Record<OutreachEnrollmentRowAction, string> = {\n reply: 'Reply in thread',\n curate: 'Curate next step',\n pause: 'Pause',\n resume: 'Resume',\n stop: 'Stop',\n do_not_contact: 'Mark do-not-contact',\n}\n\n/** The two actions that end something for good. */\nexport const OUTREACH_DESTRUCTIVE_ENROLLMENT_ACTIONS: readonly OutreachEnrollmentRowAction[] = [\n 'stop',\n 'do_not_contact',\n]\n\n/** What the snackbar says once an action lands. */\nconst DONE_LABELS: Record<Exclude<OutreachEnrollmentAction, 'do_not_contact'>, string> = {\n pause: 'Paused.',\n resume: 'Resumed.',\n stop: 'Stopped.',\n}\n\n/** The two actions that end something for good ask first. */\nconst CONFIRM: Partial<Record<OutreachEnrollmentAction, { title: string; body: string; label: string }>> = {\n stop: {\n title: 'Stop this enrollment?',\n body: 'They won’t get any more steps from this sequence, and they can’t be enrolled in it again.',\n label: 'Stop',\n },\n do_not_contact: {\n title: 'Mark do-not-contact?',\n body:\n 'The address goes on your organization’s do-not-contact list, which every sequence checks before ' +\n 'every send, and they are stopped in every sequence they are in.',\n label: 'Mark do-not-contact',\n },\n}\n\nexport interface OutreachEnrollmentActions {\n /** The enrollment an action is running on, if any. */\n busy: string | null\n /** Runs one action: straight away, after its confirmation, or in the curate or reply dialog. */\n choose(enrollment: OutreachEnrollment, action: OutreachEnrollmentRowAction): void\n /** The actions as menu items, the destructive ones marked. */\n menuItems(\n enrollment: OutreachEnrollment,\n only?: (action: OutreachEnrollmentRowAction) => boolean,\n ): RowActionsMenuItem[]\n /** The confirmation, the curate dialog and the reply composer, rendered once wherever the actions are offered. */\n dialogs: ReactNode\n}\n\nexport function useOutreachEnrollmentActions(input: {\n api: OutreachApi\n steps: readonly OutreachSequenceStep[]\n /** The mailbox's zone, for the times the reply composer shows. */\n timeZone?: string | null\n}): OutreachEnrollmentActions {\n const { api, steps } = input\n const { enqueueSnackbar } = useSnackbar()\n const [confirming, setConfirming] = useState<{\n enrollment: OutreachEnrollment\n action: OutreachEnrollmentAction\n } | null>(null)\n const [detail, setDetail] = useState('')\n const [busy, setBusy] = useState<string | null>(null)\n const [curating, setCurating] = useState<OutreachEnrollment | null>(null)\n const [replying, setReplying] = useState<OutreachEnrollment | null>(null)\n\n const act = async (enrollment: OutreachEnrollment, action: OutreachEnrollmentAction, why?: string) => {\n setBusy(enrollment.id)\n try {\n const answer = await api.actOnEnrollment(enrollment.id, action, why)\n const others = answer.stoppedOthers\n enqueueSnackbar(\n action === 'do_not_contact'\n ? `Marked do-not-contact${others ? `, and stopped in ${others} other ${others === 1 ? 'sequence' : 'sequences'}` : ''}.`\n : answer.changed\n ? DONE_LABELS[action]\n : 'Nothing to change.',\n { variant: 'success' },\n )\n } catch (error) {\n enqueueSnackbar((error as Error).message, { variant: 'error', allowDuplicate: true })\n } finally {\n setBusy(null)\n }\n }\n\n const choose = (enrollment: OutreachEnrollment, action: OutreachEnrollmentRowAction) => {\n if (action === 'curate') {\n setCurating(enrollment)\n } else if (action === 'reply') {\n setReplying(enrollment)\n } else if (CONFIRM[action]) {\n setDetail('')\n setConfirming({ enrollment, action })\n } else {\n void act(enrollment, action)\n }\n }\n\n const menuItems = (\n enrollment: OutreachEnrollment,\n only?: (action: OutreachEnrollmentRowAction) => boolean,\n ): RowActionsMenuItem[] =>\n outreachEnrollmentActionsFor(enrollment, steps)\n .filter((action) => !only || only(action))\n .map((action) => ({\n key: action,\n label: OUTREACH_ENROLLMENT_ACTION_LABELS[action],\n onClick: () => choose(enrollment, action),\n destructive: OUTREACH_DESTRUCTIVE_ENROLLMENT_ACTIONS.includes(action),\n disabled: busy === enrollment.id,\n }))\n\n const dialogs = (\n <>\n <OutreachCurateStepDialog enrollment={curating} steps={steps} api={api} onClose={() => setCurating(null)} />\n <OutreachReplyDialog\n enrollment={replying}\n api={api}\n timeZone={input.timeZone}\n onClose={() => setReplying(null)}\n />\n <Dialog open={Boolean(confirming)} onClose={() => setConfirming(null)} fullWidth maxWidth=\"xs\">\n {confirming ? (\n <>\n <DialogTitle>{CONFIRM[confirming.action]?.title}</DialogTitle>\n <DialogContent>\n <Stack spacing={2}>\n <DialogContentText>{CONFIRM[confirming.action]?.body}</DialogContentText>\n <TextField\n label=\"Why (optional)\"\n value={detail}\n onChange={(event) => setDetail(event.target.value)}\n fullWidth\n slotProps={{ htmlInput: { maxLength: 200 } }}\n />\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={() => setConfirming(null)}>Cancel</Button>\n <Button\n color=\"error\"\n variant=\"contained\"\n onClick={() => {\n const { enrollment, action } = confirming\n setConfirming(null)\n void act(enrollment, action, detail.trim() || undefined)\n }}\n >\n {CONFIRM[confirming.action]?.label}\n </Button>\n </DialogActions>\n </>\n ) : null}\n </Dialog>\n </>\n )\n\n return { busy, choose, menuItems, dialogs }\n}\n"],"names":["useSnackbar","Button","Dialog","DialogActions","DialogContent","DialogContentText","DialogTitle","Stack","TextField","useState","outreachEnrollmentHasInbound","OutreachCurateStepDialog","outreachNextEmailStepIndex","OutreachReplyDialog","outreachEnrollmentActionsFor","enrollment","steps","actions","status","push","OUTREACH_ENROLLMENT_ACTION_LABELS","reply","curate","pause","resume","stop","do_not_contact","OUTREACH_DESTRUCTIVE_ENROLLMENT_ACTIONS","DONE_LABELS","CONFIRM","title","body","label","useOutreachEnrollmentActions","input","api","enqueueSnackbar","confirming","setConfirming","detail","setDetail","busy","setBusy","curating","setCurating","replying","setReplying","act","action","why","id","answer","actOnEnrollment","others","stoppedOthers","changed","variant","error","message","allowDuplicate","choose","menuItems","only","filter","map","key","onClick","destructive","includes","disabled","dialogs","onClose","timeZone","open","Boolean","fullWidth","maxWidth","spacing","value","onChange","event","target","slotProps","htmlInput","maxLength","color","trim","undefined"],"mappings":"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"}
@@ -18,15 +18,16 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
19
  import { PageHeaderRecord, pluginDocsHelp } from "@aglyn/aglyn";
20
20
  import { pluginRecordHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
21
- import { mdiAccountCancelOutline, mdiAccountOutline, mdiAccountPlusOutline, mdiAlertCircleOutline, mdiCheckCircleOutline, mdiChevronDown, mdiChevronUp, mdiClipboardCheckOutline, mdiContentCopy, mdiCursorDefaultClickOutline, mdiEmailAlertOutline, mdiEmailOffOutline, mdiEmailOpenOutline, mdiEmailOutline, mdiHandBackLeftOutline, mdiPauseCircleOutline, mdiPencilOutline, mdiPlayCircleOutline, mdiReply, mdiShieldSearch, mdiSkipNext, mdiStopCircleOutline } from "@aglyn/shared-data-mdi";
21
+ import { mdiAccountCancelOutline, mdiAccountOutline, mdiAccountPlusOutline, mdiAlertCircleOutline, mdiCheckCircleOutline, mdiChevronDown, mdiChevronUp, mdiClipboardCheckOutline, mdiContentCopy, mdiCursorDefaultClickOutline, mdiEmailAlertOutline, mdiEmailOffOutline, mdiEmailOpenOutline, mdiEmailOutline, mdiHandBackLeftOutline, mdiPauseCircleOutline, mdiPencilOutline, mdiMessageReplyTextOutline, mdiPlayCircleOutline, mdiReply, mdiReplyOutline, mdiShieldSearch, mdiSkipNext, mdiStopCircleOutline } from "@aglyn/shared-data-mdi";
22
22
  import { AppLink, CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
23
23
  import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.component";
24
24
  import RowActionsMenu from "@aglyn/shared-ui-jsx/components/row-actions-menu.component";
25
25
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
26
26
  import { useOrgContainerOptions, useOrgMemberOptions } from "@aglyn/tenant-feature-instance";
27
27
  import { Alert, Box, Button, Card, Chip, Collapse, Divider, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, Stack, Tooltip, Typography } from "@mui/material";
28
- import { useCallback, useMemo, useState } from "react";
28
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
29
29
  import { OUTREACH_MAIL_GATEWAY_LABELS } from "../engine/mail-gateway.js";
30
+ import { outreachEnrollmentHasInbound } from "../engine/manual-reply.js";
30
31
  import { outreachNextSendState } from "../engine/next-send.js";
31
32
  import { outreachClickSummary } from "../model/enrollment-engagement.js";
32
33
  import { outreachEnrollmentFigures, outreachEnrollmentTimeline, outreachStepName } from "../model/enrollment-timeline.js";
@@ -78,6 +79,8 @@ const KIND_ICONS = {
78
79
  stopped: mdiStopCircleOutline,
79
80
  'do-not-contact': mdiAccountCancelOutline,
80
81
  replied: mdiReply,
82
+ 'auto-reply': mdiReplyOutline,
83
+ answered: mdiMessageReplyTextOutline,
81
84
  bounced: mdiEmailAlertOutline,
82
85
  'opted-out': mdiEmailOffOutline,
83
86
  held: mdiHandBackLeftOutline,
@@ -526,7 +529,7 @@ const TONE_COLORS = {
526
529
  }
527
530
  export function OutreachEnrollmentDetail(props) {
528
531
  var _props_mailboxes_mailboxes_find, _ref, _ref1, _enrollment_gmailThreadId;
529
- var _enrollment_campaignIds, _sequence_stats, _sequence_stats1;
532
+ var _enrollment_campaignIds, _enrollment_inboundMessages, _sequence_stats, _sequence_stats1;
530
533
  const { orgId, sectionPath, sequenceId, enrollmentId, orgMount } = props;
531
534
  const api = useOutreachApi(orgId);
532
535
  const loadedSequence = useOutreachSequence(orgId, sequenceId);
@@ -549,10 +552,6 @@ export function OutreachEnrollmentDetail(props) {
549
552
  }, [
550
553
  sequence
551
554
  ]);
552
- const actions = useOutreachEnrollmentActions({
553
- api,
554
- steps
555
- });
556
555
  const memberName = useCallback((uid)=>{
557
556
  var _ref;
558
557
  var _roster_options_find;
@@ -570,6 +569,11 @@ export function OutreachEnrollmentDetail(props) {
570
569
  const enrollmentsPath = `${sectionPath}/${sequenceId}/enrollments`;
571
570
  const mailbox = (_props_mailboxes_mailboxes_find = props.mailboxes.mailboxes.find((entry)=>entry.id === ((enrollment == null ? void 0 : enrollment.mailboxId) || (sequence == null ? void 0 : sequence.mailboxId)))) != null ? _props_mailboxes_mailboxes_find : null;
572
571
  const timeZone = (_ref = mailbox == null ? void 0 : mailbox.timezone) != null ? _ref : null;
572
+ const actions = useOutreachEnrollmentActions({
573
+ api,
574
+ steps,
575
+ timeZone
576
+ });
573
577
  const time = (ms)=>formatOutreachTime(ms, timeZone);
574
578
  const timeline = useMemo(()=>{
575
579
  var _ref;
@@ -590,6 +594,24 @@ export function OutreachEnrollmentDetail(props) {
590
594
  timeZone,
591
595
  gateway.data
592
596
  ]);
597
+ /*
598
+ * A reply synced before replies were kept is on the enrollment only as an
599
+ * id (AGL-4020). Once per page, the thread route reads it from the mailbox
600
+ * and keeps it; the enrollment's listener then shows it in the timeline.
601
+ * A member who may not read that mailbox is answered with nothing new.
602
+ */ const backfilled = useRef(null);
603
+ const unreadReply = enrollment !== null && outreachEnrollmentHasInbound(enrollment) && !((_enrollment_inboundMessages = enrollment.inboundMessages) == null ? void 0 : _enrollment_inboundMessages.length);
604
+ useEffect(()=>{
605
+ if (!unreadReply || !enrollmentId || backfilled.current === enrollmentId) return;
606
+ backfilled.current = enrollmentId;
607
+ api.readEnrollmentThread(enrollmentId).catch((error)=>{
608
+ console.warn('[outreach] their reply could not be read from the mailbox', error);
609
+ });
610
+ }, [
611
+ api,
612
+ enrollmentId,
613
+ unreadReply
614
+ ]);
593
615
  const back = /*#__PURE__*/ _jsx(Button, _extends({}, {
594
616
  component: AppLink,
595
617
  componentVariant: 'naked',
@@ -639,11 +661,12 @@ export function OutreachEnrollmentDetail(props) {
639
661
  const open = enrollment.status === 'active' || enrollment.status === 'paused';
640
662
  const held = enrollment.status === 'paused' && enrollment.stopReason === 'gateway_blocked_here';
641
663
  /*
642
- * The one act that moves the person forward is the button: resuming a
643
- * paused one, else curating the next email. Everything else is in the
644
- * menu, the destructive acts included, as on every record page.
664
+ * The one act that moves the person forward is the button: answering a
665
+ * person who wrote back (AGL-4020), else resuming a paused one, else
666
+ * curating the next email. Everything else is in the menu, the
667
+ * destructive acts included, as on every record page.
645
668
  */ const available = outreachEnrollmentActionsFor(enrollment, steps);
646
- const primary = available.includes('resume') ? 'resume' : available.includes('curate') ? 'curate' : null;
669
+ const primary = available.includes('reply') ? 'reply' : available.includes('resume') ? 'resume' : available.includes('curate') ? 'curate' : null;
647
670
  const menu = actions.menuItems(enrollment, (action)=>action !== primary);
648
671
  const dash = (value)=>value ? value.toLocaleString() : '—';
649
672
  const notTracked = !trackClicks && !figures.clicks && !figures.scannerClicks ? 'Not tracked' : undefined;
@@ -691,7 +714,7 @@ export function OutreachEnrollmentDetail(props) {
691
714
  size: "small",
692
715
  disabled: actions.busy === enrollment.id,
693
716
  onClick: ()=>actions.choose(enrollment, primary),
694
- children: held ? 'Resume and send' : OUTREACH_ENROLLMENT_ACTION_LABELS[primary]
717
+ children: held && primary === 'resume' ? 'Resume and send' : OUTREACH_ENROLLMENT_ACTION_LABELS[primary]
695
718
  }) : null,
696
719
  menu.length ? /*#__PURE__*/ _jsx(RowActionsMenu, {
697
720
  label: name,
@@ -804,7 +827,11 @@ export function OutreachEnrollmentDetail(props) {
804
827
  /*#__PURE__*/ _jsx(OutreachFigure, {
805
828
  label: "Replies",
806
829
  value: dash(figures.replies)
807
- })
830
+ }),
831
+ figures.answers ? /*#__PURE__*/ _jsx(OutreachFigure, {
832
+ label: "Answers sent",
833
+ value: dash(figures.answers)
834
+ }) : null
808
835
  ]
809
836
  }) : /*#__PURE__*/ _jsx(Typography, {
810
837
  variant: "body2",