@agent-native/dispatch 0.17.3 → 0.17.5
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/actions/create-workspace-resource.d.ts +13 -13
- package/dist/actions/delete-workspace-resource.d.ts +13 -13
- package/dist/actions/provider-api-register.d.ts +10 -10
- package/dist/client/transactional-emails.d.ts +57 -0
- package/dist/client/transactional-emails.d.ts.map +1 -0
- package/dist/client/transactional-emails.js +116 -0
- package/dist/client/transactional-emails.js.map +1 -0
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +8 -1
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/transactional-email-activity.d.ts +24 -0
- package/dist/components/transactional-email-activity.d.ts.map +1 -0
- package/dist/components/transactional-email-activity.js +31 -0
- package/dist/components/transactional-email-activity.js.map +1 -0
- package/dist/components/transactional-email-metrics.d.ts +40 -0
- package/dist/components/transactional-email-metrics.d.ts.map +1 -0
- package/dist/components/transactional-email-metrics.js +44 -0
- package/dist/components/transactional-email-metrics.js.map +1 -0
- package/dist/components/transactional-email-preview.d.ts +7 -0
- package/dist/components/transactional-email-preview.d.ts.map +1 -0
- package/dist/components/transactional-email-preview.js +41 -0
- package/dist/components/transactional-email-preview.js.map +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/transactional-email.$appId.$id.d.ts +5 -0
- package/dist/routes/pages/transactional-email.$appId.$id.d.ts.map +1 -0
- package/dist/routes/pages/transactional-email.$appId.$id.js +144 -0
- package/dist/routes/pages/transactional-email.$appId.$id.js.map +1 -0
- package/dist/routes/pages/transactional-email.d.ts +5 -0
- package/dist/routes/pages/transactional-email.d.ts.map +1 -0
- package/dist/routes/pages/transactional-email.js +139 -0
- package/dist/routes/pages/transactional-email.js.map +1 -0
- package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
- package/dist/server/lib/thread-debug-store.js +2 -1
- package/dist/server/lib/thread-debug-store.js.map +1 -1
- package/package.json +2 -2
- package/src/client/transactional-emails.spec.ts +91 -0
- package/src/client/transactional-emails.ts +201 -0
- package/src/components/layout/Layout.tsx +8 -0
- package/src/components/transactional-email-activity.tsx +102 -0
- package/src/components/transactional-email-metrics.tsx +127 -0
- package/src/components/transactional-email-preview.tsx +88 -0
- package/src/routes/index.ts +5 -0
- package/src/routes/pages/transactional-email.$appId.$id.tsx +362 -0
- package/src/routes/pages/transactional-email.tsx +571 -0
- package/src/server/lib/thread-debug-store.spec.ts +7 -3
- package/src/server/lib/thread-debug-store.ts +2 -1
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
IconKey,
|
|
29
29
|
IconChevronDown,
|
|
30
30
|
IconLayersSubtract,
|
|
31
|
+
IconMail,
|
|
31
32
|
IconMessageQuestion,
|
|
32
33
|
IconMessages,
|
|
33
34
|
IconPlugConnected,
|
|
@@ -161,6 +162,13 @@ const OPERATIONS_NAV_ITEMS = [
|
|
|
161
162
|
icon: IconPuzzle,
|
|
162
163
|
section: "operations",
|
|
163
164
|
},
|
|
165
|
+
{
|
|
166
|
+
id: "transactional-email",
|
|
167
|
+
to: "/transactional-email",
|
|
168
|
+
label: "Transactional email",
|
|
169
|
+
icon: IconMail,
|
|
170
|
+
section: "operations",
|
|
171
|
+
},
|
|
164
172
|
{
|
|
165
173
|
id: "vault",
|
|
166
174
|
to: "/vault",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
2
|
+
import { IconInfoCircle } from "@tabler/icons-react";
|
|
3
|
+
|
|
4
|
+
import { ActionQueryError } from "./action-query-error";
|
|
5
|
+
import { type ProviderMetricsResult } from "./transactional-email-metrics";
|
|
6
|
+
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|
7
|
+
import { Skeleton } from "./ui/skeleton";
|
|
8
|
+
import {
|
|
9
|
+
Table,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
} from "./ui/table";
|
|
16
|
+
|
|
17
|
+
export interface EmailActivityEntry {
|
|
18
|
+
msgId: string;
|
|
19
|
+
toEmail: string;
|
|
20
|
+
fromEmail: string;
|
|
21
|
+
subject: string;
|
|
22
|
+
status: string;
|
|
23
|
+
opensCount: number;
|
|
24
|
+
clicksCount: number;
|
|
25
|
+
lastEventTime: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Renders one email's activity feed. Used both inline on the detail page and
|
|
30
|
+
* inside the list page's activity dialog, so the "unavailable" and "empty"
|
|
31
|
+
* states only need to be written once.
|
|
32
|
+
*/
|
|
33
|
+
export function ActivityTable({
|
|
34
|
+
result,
|
|
35
|
+
isLoading,
|
|
36
|
+
isError,
|
|
37
|
+
error,
|
|
38
|
+
onRetry,
|
|
39
|
+
}: {
|
|
40
|
+
result: ProviderMetricsResult<EmailActivityEntry[]> | undefined;
|
|
41
|
+
isLoading: boolean;
|
|
42
|
+
isError: boolean;
|
|
43
|
+
error: unknown;
|
|
44
|
+
onRetry: () => void;
|
|
45
|
+
}) {
|
|
46
|
+
const t = useT();
|
|
47
|
+
|
|
48
|
+
if (isError) {
|
|
49
|
+
return <ActionQueryError error={error} onRetry={onRetry} />;
|
|
50
|
+
}
|
|
51
|
+
if (isLoading) {
|
|
52
|
+
return <Skeleton className="h-40 w-full" />;
|
|
53
|
+
}
|
|
54
|
+
if (result && !result.available) {
|
|
55
|
+
return (
|
|
56
|
+
<Alert>
|
|
57
|
+
<IconInfoCircle className="size-4" />
|
|
58
|
+
<AlertTitle>
|
|
59
|
+
{t("dispatch.transactionalEmail.activityUnavailable")}
|
|
60
|
+
</AlertTitle>
|
|
61
|
+
<AlertDescription>{result.reason}</AlertDescription>
|
|
62
|
+
</Alert>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (result && result.data.length === 0) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="rounded-xl border border-dashed px-4 py-8 text-sm text-muted-foreground">
|
|
68
|
+
{t("dispatch.transactionalEmail.activityEmpty")}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!result) return null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="max-h-96 overflow-auto">
|
|
76
|
+
<Table>
|
|
77
|
+
<TableHeader>
|
|
78
|
+
<TableRow>
|
|
79
|
+
<TableHead>{t("dispatch.transactionalEmail.recipient")}</TableHead>
|
|
80
|
+
<TableHead>{t("dispatch.transactionalEmail.subject")}</TableHead>
|
|
81
|
+
<TableHead>{t("dispatch.transactionalEmail.status")}</TableHead>
|
|
82
|
+
<TableHead>{t("dispatch.transactionalEmail.opens")}</TableHead>
|
|
83
|
+
<TableHead>{t("dispatch.transactionalEmail.lastEvent")}</TableHead>
|
|
84
|
+
</TableRow>
|
|
85
|
+
</TableHeader>
|
|
86
|
+
<TableBody>
|
|
87
|
+
{result.data.map((entry) => (
|
|
88
|
+
<TableRow key={entry.msgId}>
|
|
89
|
+
<TableCell className="text-xs">{entry.toEmail}</TableCell>
|
|
90
|
+
<TableCell className="text-xs">{entry.subject}</TableCell>
|
|
91
|
+
<TableCell className="text-xs">{entry.status}</TableCell>
|
|
92
|
+
<TableCell className="text-xs tabular-nums">
|
|
93
|
+
{entry.opensCount}
|
|
94
|
+
</TableCell>
|
|
95
|
+
<TableCell className="text-xs">{entry.lastEventTime}</TableCell>
|
|
96
|
+
</TableRow>
|
|
97
|
+
))}
|
|
98
|
+
</TableBody>
|
|
99
|
+
</Table>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
2
|
+
|
|
3
|
+
import { Skeleton } from "./ui/skeleton";
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared between the transactional email list and detail pages so both
|
|
8
|
+
* render the same "unknown vs. zero" and provider-availability rules.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface EmailEngagement {
|
|
12
|
+
templateId: string;
|
|
13
|
+
delivered: number;
|
|
14
|
+
uniqueOpens: number;
|
|
15
|
+
uniqueClicks: number;
|
|
16
|
+
/** null when nothing was delivered in the window, so there is no rate yet. */
|
|
17
|
+
openRate: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ProviderMetricsResult<T> =
|
|
21
|
+
| { available: true; data: T }
|
|
22
|
+
| { available: false; reason: string };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Renders a metric the backend could not read. "Unknown" and "zero" must stay
|
|
26
|
+
* visibly different — a dash with a reason is the only honest rendering.
|
|
27
|
+
*/
|
|
28
|
+
export function UnknownMetric({ reason }: { reason: string }) {
|
|
29
|
+
return (
|
|
30
|
+
<Tooltip>
|
|
31
|
+
<TooltipTrigger asChild>
|
|
32
|
+
<span className="cursor-help text-muted-foreground underline decoration-dotted underline-offset-2">
|
|
33
|
+
—
|
|
34
|
+
</span>
|
|
35
|
+
</TooltipTrigger>
|
|
36
|
+
<TooltipContent className="max-w-64">{reason}</TooltipContent>
|
|
37
|
+
</Tooltip>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function SendsCell({
|
|
42
|
+
sent,
|
|
43
|
+
failed,
|
|
44
|
+
}: {
|
|
45
|
+
sent: number | null;
|
|
46
|
+
failed: number | null;
|
|
47
|
+
}) {
|
|
48
|
+
const t = useT();
|
|
49
|
+
if (sent === null) {
|
|
50
|
+
return (
|
|
51
|
+
<UnknownMetric reason={t("dispatch.transactionalEmail.sendLogUnread")} />
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
<span className="tabular-nums">
|
|
56
|
+
{sent}
|
|
57
|
+
{failed !== null && failed > 0 ? (
|
|
58
|
+
<span className="ml-2 text-xs text-destructive">
|
|
59
|
+
{t("dispatch.transactionalEmail.failedCount", { count: failed })}
|
|
60
|
+
</span>
|
|
61
|
+
) : null}
|
|
62
|
+
{failed === null ? (
|
|
63
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
64
|
+
{t("dispatch.transactionalEmail.failuresUnknown")}
|
|
65
|
+
</span>
|
|
66
|
+
) : null}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function OpenRateCell({
|
|
72
|
+
engagement,
|
|
73
|
+
unavailableReason,
|
|
74
|
+
loading,
|
|
75
|
+
}: {
|
|
76
|
+
engagement: EmailEngagement | undefined;
|
|
77
|
+
unavailableReason: string | null;
|
|
78
|
+
loading: boolean;
|
|
79
|
+
}) {
|
|
80
|
+
const t = useT();
|
|
81
|
+
if (unavailableReason) {
|
|
82
|
+
return <UnknownMetric reason={unavailableReason} />;
|
|
83
|
+
}
|
|
84
|
+
if (loading) return <Skeleton className="h-4 w-10" />;
|
|
85
|
+
if (!engagement) {
|
|
86
|
+
return (
|
|
87
|
+
<UnknownMetric
|
|
88
|
+
reason={t("dispatch.transactionalEmail.noProviderRecord")}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (engagement.openRate === null) {
|
|
93
|
+
return (
|
|
94
|
+
<span className="text-xs text-muted-foreground">
|
|
95
|
+
{t("dispatch.transactionalEmail.noDeliveredMail")}
|
|
96
|
+
</span>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return (
|
|
100
|
+
<span className="tabular-nums">
|
|
101
|
+
{`${(engagement.openRate * 100).toFixed(1)}%`}
|
|
102
|
+
</span>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function LastSentCell({
|
|
107
|
+
lastSentAt,
|
|
108
|
+
sent,
|
|
109
|
+
}: {
|
|
110
|
+
lastSentAt: number | null;
|
|
111
|
+
sent: number | null;
|
|
112
|
+
}) {
|
|
113
|
+
const t = useT();
|
|
114
|
+
if (lastSentAt !== null) {
|
|
115
|
+
return <>{new Date(lastSentAt).toLocaleString()}</>;
|
|
116
|
+
}
|
|
117
|
+
if (sent === 0) {
|
|
118
|
+
return (
|
|
119
|
+
<span className="text-xs text-muted-foreground">
|
|
120
|
+
{t("dispatch.transactionalEmail.neverSent")}
|
|
121
|
+
</span>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return (
|
|
125
|
+
<UnknownMetric reason={t("dispatch.transactionalEmail.lastSentUnknown")} />
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useActionQuery } from "@agent-native/core/client/hooks";
|
|
2
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
3
|
+
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
fetchEmailPreview,
|
|
8
|
+
type EmailPreview,
|
|
9
|
+
} from "../client/transactional-emails";
|
|
10
|
+
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|
11
|
+
import { Skeleton } from "./ui/skeleton";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The "core" app id means the email was read from Dispatch's own local
|
|
15
|
+
* catalog (see transactional-email.tsx), not a cross-app fetch, so its
|
|
16
|
+
* preview must render the same way — Dispatch always has the definition
|
|
17
|
+
* registered locally and a cross-app fetch would have no real appPath to hit.
|
|
18
|
+
*/
|
|
19
|
+
function useEmailPreviewQuery(appId: string, appPath: string, id: string) {
|
|
20
|
+
const isCore = appId === "core";
|
|
21
|
+
const local = useActionQuery<EmailPreview>(
|
|
22
|
+
"render-transactional-email-preview",
|
|
23
|
+
{ id },
|
|
24
|
+
{ enabled: isCore, retry: false },
|
|
25
|
+
);
|
|
26
|
+
const remote = useQuery({
|
|
27
|
+
queryKey: ["transactional-email-preview", appPath, id],
|
|
28
|
+
queryFn: () => fetchEmailPreview(appPath, id),
|
|
29
|
+
enabled: !isCore,
|
|
30
|
+
retry: false,
|
|
31
|
+
});
|
|
32
|
+
return isCore ? local : remote;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function EmailPreviewPane({
|
|
36
|
+
appId,
|
|
37
|
+
appPath,
|
|
38
|
+
id,
|
|
39
|
+
name,
|
|
40
|
+
}: {
|
|
41
|
+
appId: string;
|
|
42
|
+
appPath: string;
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
}) {
|
|
46
|
+
const t = useT();
|
|
47
|
+
const preview = useEmailPreviewQuery(appId, appPath, id);
|
|
48
|
+
|
|
49
|
+
if (preview.isError) {
|
|
50
|
+
return (
|
|
51
|
+
<Alert variant="destructive">
|
|
52
|
+
<IconAlertTriangle className="size-4" />
|
|
53
|
+
<AlertTitle>
|
|
54
|
+
{t("dispatch.transactionalEmail.previewFailed")}
|
|
55
|
+
</AlertTitle>
|
|
56
|
+
<AlertDescription>
|
|
57
|
+
{preview.error instanceof Error
|
|
58
|
+
? preview.error.message
|
|
59
|
+
: String(preview.error)}
|
|
60
|
+
</AlertDescription>
|
|
61
|
+
</Alert>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (preview.isLoading || !preview.data) {
|
|
65
|
+
return <Skeleton className="h-96 w-full" />;
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
<div className="space-y-3">
|
|
69
|
+
<div>
|
|
70
|
+
<div className="text-xs text-muted-foreground">
|
|
71
|
+
{t("dispatch.transactionalEmail.subject")}
|
|
72
|
+
</div>
|
|
73
|
+
<div className="text-sm font-medium text-foreground">
|
|
74
|
+
{preview.data.subject}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
{/* sandbox="" (no allow-scripts) keeps arbitrary email HTML from
|
|
78
|
+
running script in the Dispatch origin. */}
|
|
79
|
+
<iframe
|
|
80
|
+
title={t("dispatch.transactionalEmail.previewFrameTitle", { name })}
|
|
81
|
+
sandbox=""
|
|
82
|
+
srcDoc={preview.data.html}
|
|
83
|
+
// guard:allow-raw-color — the frame previews email HTML, which renders on white in mail clients regardless of app theme.
|
|
84
|
+
className="h-96 w-full rounded-xl border bg-white"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -47,6 +47,11 @@ export const dispatchRoutes: RouteConfig = [
|
|
|
47
47
|
route("workspace", "./pages/workspace.js"),
|
|
48
48
|
route("messaging", "./pages/messaging.js"),
|
|
49
49
|
route("destinations", "./pages/destinations.js"),
|
|
50
|
+
route("transactional-email", "./pages/transactional-email.js"),
|
|
51
|
+
route(
|
|
52
|
+
"transactional-email/:appId/:id",
|
|
53
|
+
"./pages/transactional-email.$appId.$id.js",
|
|
54
|
+
),
|
|
50
55
|
route("identities", "./pages/identities.js"),
|
|
51
56
|
route("approval", "./pages/approval.js"),
|
|
52
57
|
route("approvals", "./pages/approvals.js"),
|