@agent-native/dispatch 0.17.2 → 0.18.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 (48) hide show
  1. package/dist/actions/provider-api-catalog.d.ts +1 -1
  2. package/dist/actions/provider-api-register.d.ts +11 -11
  3. package/dist/client/transactional-emails.d.ts +57 -0
  4. package/dist/client/transactional-emails.d.ts.map +1 -0
  5. package/dist/client/transactional-emails.js +116 -0
  6. package/dist/client/transactional-emails.js.map +1 -0
  7. package/dist/components/layout/Layout.d.ts.map +1 -1
  8. package/dist/components/layout/Layout.js +8 -1
  9. package/dist/components/layout/Layout.js.map +1 -1
  10. package/dist/components/transactional-email-activity.d.ts +24 -0
  11. package/dist/components/transactional-email-activity.d.ts.map +1 -0
  12. package/dist/components/transactional-email-activity.js +31 -0
  13. package/dist/components/transactional-email-activity.js.map +1 -0
  14. package/dist/components/transactional-email-metrics.d.ts +40 -0
  15. package/dist/components/transactional-email-metrics.d.ts.map +1 -0
  16. package/dist/components/transactional-email-metrics.js +44 -0
  17. package/dist/components/transactional-email-metrics.js.map +1 -0
  18. package/dist/components/transactional-email-preview.d.ts +7 -0
  19. package/dist/components/transactional-email-preview.d.ts.map +1 -0
  20. package/dist/components/transactional-email-preview.js +41 -0
  21. package/dist/components/transactional-email-preview.js.map +1 -0
  22. package/dist/routes/index.d.ts.map +1 -1
  23. package/dist/routes/index.js +2 -0
  24. package/dist/routes/index.js.map +1 -1
  25. package/dist/routes/pages/transactional-email.$appId.$id.d.ts +5 -0
  26. package/dist/routes/pages/transactional-email.$appId.$id.d.ts.map +1 -0
  27. package/dist/routes/pages/transactional-email.$appId.$id.js +144 -0
  28. package/dist/routes/pages/transactional-email.$appId.$id.js.map +1 -0
  29. package/dist/routes/pages/transactional-email.d.ts +5 -0
  30. package/dist/routes/pages/transactional-email.d.ts.map +1 -0
  31. package/dist/routes/pages/transactional-email.js +139 -0
  32. package/dist/routes/pages/transactional-email.js.map +1 -0
  33. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  34. package/dist/server/lib/app-creation-store.js +1 -1
  35. package/dist/server/lib/app-creation-store.js.map +1 -1
  36. package/dist/server/lib/provider-api.d.ts +2 -2
  37. package/dist/server/lib/provider-api.d.ts.map +1 -1
  38. package/package.json +3 -3
  39. package/src/client/transactional-emails.spec.ts +91 -0
  40. package/src/client/transactional-emails.ts +201 -0
  41. package/src/components/layout/Layout.tsx +8 -0
  42. package/src/components/transactional-email-activity.tsx +102 -0
  43. package/src/components/transactional-email-metrics.tsx +127 -0
  44. package/src/components/transactional-email-preview.tsx +88 -0
  45. package/src/routes/index.ts +5 -0
  46. package/src/routes/pages/transactional-email.$appId.$id.tsx +362 -0
  47. package/src/routes/pages/transactional-email.tsx +571 -0
  48. package/src/server/lib/app-creation-store.ts +2 -1
@@ -1,10 +1,10 @@
1
1
  import { type ProviderApiDocsOptions, type ProviderApiId, type ProviderApiMethod, type ProviderApiRequestArgs } from "@agent-native/core/provider-api";
2
2
  export declare const DISPATCH_APP_ID = "dispatch";
3
- export declare const DISPATCH_PROVIDER_API_IDS: readonly ["amplitude", "apollo", "bigquery", "clay", "commonroom", "dataforseo", "ga4", "gcloud", "github", "figma", "gmail", "gong", "google_calendar", "google_drive", "google_slides", "granola", "grafana", "hubspot", "salesforce", "jira", "mixpanel", "notion", "posthog", "prometheus", "pylon", "sentry", "slack", "stripe", "twitter"];
3
+ export declare const DISPATCH_PROVIDER_API_IDS: readonly ["amplitude", "apollo", "bigquery", "clay", "commonroom", "dataforseo", "ga4", "gcloud", "github", "figma", "fullstory", "gmail", "gong", "google_calendar", "google_drive", "google_slides", "granola", "grafana", "hubspot", "salesforce", "jira", "mixpanel", "notion", "posthog", "prometheus", "pylon", "sentry", "slack", "stripe", "twitter"];
4
4
  export type DispatchProviderApiId = ProviderApiId;
5
5
  export type { ProviderApiMethod, ProviderApiRequestArgs };
6
6
  export declare function listProviderApiCatalog(provider?: string): {
7
- id: "amplitude" | "apollo" | "bigquery" | "clay" | "commonroom" | "dataforseo" | "figma" | "ga4" | "gcloud" | "github" | "gmail" | "gong" | "google_calendar" | "google_drive" | "google_slides" | "grafana" | "granola" | "hubspot" | "jira" | "mixpanel" | "notion" | "posthog" | "prometheus" | "pylon" | "salesforce" | "sentry" | "slack" | "stripe" | "twitter";
7
+ id: "amplitude" | "apollo" | "bigquery" | "clay" | "commonroom" | "dataforseo" | "figma" | "fullstory" | "ga4" | "gcloud" | "github" | "gmail" | "gong" | "google_calendar" | "google_drive" | "google_slides" | "grafana" | "granola" | "hubspot" | "jira" | "mixpanel" | "notion" | "posthog" | "prometheus" | "pylon" | "salesforce" | "sentry" | "slack" | "stripe" | "twitter";
8
8
  label: string;
9
9
  defaultBaseUrl: string;
10
10
  requiresConnectionId: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"provider-api.d.ts","sourceRoot":"","sources":["../../../src/server/lib/provider-api.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,sBAAsB,EAE3B,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,sBAAsB,EAC5B,MAAM,iCAAiC,CAAC;AAGzC,eAAO,MAAM,eAAe,aAAa,CAAC;AAC1C,eAAO,MAAM,yBAAyB,kVAAmB,CAAC;AAC1D,MAAM,MAAM,qBAAqB,GAAG,aAAa,CAAC;AAClD,YAAY,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,CAAC;AA0C1D,wBAAgB,sBAAsB,CAAC,QAAQ,CAAC,EAAE,MAAM;;;;;;;;;;;;;;;;;;yBAEvD;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,sBAAsB,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,oBAGvD;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,sBAAsB,oBAErE"}
1
+ {"version":3,"file":"provider-api.d.ts","sourceRoot":"","sources":["../../../src/server/lib/provider-api.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,sBAAsB,EAE3B,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,sBAAsB,EAC5B,MAAM,iCAAiC,CAAC;AAGzC,eAAO,MAAM,eAAe,aAAa,CAAC;AAC1C,eAAO,MAAM,yBAAyB,+VAAmB,CAAC;AAC1D,MAAM,MAAM,qBAAqB,GAAG,aAAa,CAAC;AAClD,YAAY,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,CAAC;AA0C1D,wBAAgB,sBAAsB,CAAC,QAAQ,CAAC,EAAE,MAAM;;;;;;;;;;;;;;;;;;yBAEvD;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,sBAAsB,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,oBAGvD;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,sBAAsB,oBAErE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -84,7 +84,7 @@
84
84
  "tailwind-merge": "^3.5.0",
85
85
  "vaul": "^1.1.2",
86
86
  "zod": "^4.3.6",
87
- "@agent-native/toolkit": "0.13.1"
87
+ "@agent-native/toolkit": "0.13.2"
88
88
  },
89
89
  "devDependencies": {
90
90
  "@react-router/dev": "^8.1.0",
@@ -98,7 +98,7 @@
98
98
  "typescript-7": "npm:typescript@^7.0.2",
99
99
  "vite": "8.1.0",
100
100
  "vitest": "^4.1.5",
101
- "@agent-native/core": "0.137.4"
101
+ "@agent-native/core": "0.139.0"
102
102
  },
103
103
  "peerDependencies": {
104
104
  "@agent-native/core": ">=0.8.0",
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ aggregateSharedEmails,
5
+ type AppEmailCatalog,
6
+ type AppTransactionalEmail,
7
+ type LocalTransactionalEmailCatalog,
8
+ } from "./transactional-emails";
9
+
10
+ const coreEmail: AppTransactionalEmail = {
11
+ id: "core.reset-password",
12
+ app: "core",
13
+ name: "Reset password",
14
+ trigger: "A password reset is requested.",
15
+ recipient: "The account address.",
16
+ recipientLabel: "Account address",
17
+ sender: "The configured sender.",
18
+ senderLabel: "Configured sender",
19
+ sent: 2,
20
+ failed: 1,
21
+ lastSentAt: 100,
22
+ };
23
+
24
+ const local: LocalTransactionalEmailCatalog = {
25
+ app: "dispatch",
26
+ statsAvailable: true,
27
+ statsError: null,
28
+ emails: [coreEmail],
29
+ };
30
+
31
+ function appCatalog(
32
+ appId: string,
33
+ email: AppTransactionalEmail,
34
+ ): AppEmailCatalog {
35
+ return {
36
+ appId,
37
+ appName: appId,
38
+ appPath: `/${appId}`,
39
+ emails: [email],
40
+ error: null,
41
+ statsError: null,
42
+ };
43
+ }
44
+
45
+ describe("aggregateSharedEmails", () => {
46
+ it("sums shared email counts across sending apps", () => {
47
+ const result = aggregateSharedEmails(local, [
48
+ appCatalog("calendar", {
49
+ ...coreEmail,
50
+ sent: 3,
51
+ failed: 0,
52
+ lastSentAt: 200,
53
+ }),
54
+ appCatalog("forms", {
55
+ ...coreEmail,
56
+ sent: 1,
57
+ failed: 2,
58
+ lastSentAt: 150,
59
+ }),
60
+ ]);
61
+
62
+ expect(result).toEqual({
63
+ statsError: null,
64
+ emails: [
65
+ {
66
+ ...coreEmail,
67
+ sent: 6,
68
+ failed: 3,
69
+ lastSentAt: 200,
70
+ },
71
+ ],
72
+ });
73
+ });
74
+
75
+ it("does not present partial totals when an app log is unreadable", () => {
76
+ const unreadable = appCatalog("calendar", coreEmail);
77
+ unreadable.statsError = "database unavailable";
78
+
79
+ expect(aggregateSharedEmails(local, [unreadable])).toEqual({
80
+ statsError: "database unavailable",
81
+ emails: [
82
+ {
83
+ ...coreEmail,
84
+ sent: null,
85
+ failed: null,
86
+ lastSentAt: null,
87
+ },
88
+ ],
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Reads each mounted app's transactional email catalog from the browser.
3
+ *
4
+ * Workspace apps are path-mounted on a single origin, so the browser can call
5
+ * another app's action endpoint directly and its own session cookie carries the
6
+ * caller's identity. Fanning out here rather than server-side keeps every app's
7
+ * existing access checks in force — Dispatch never sees a catalog the signed-in
8
+ * user could not have loaded themselves.
9
+ */
10
+
11
+ export interface AppTransactionalEmail {
12
+ id: string;
13
+ app: string;
14
+ name: string;
15
+ trigger: string;
16
+ recipient: string;
17
+ recipientLabel: string;
18
+ sender: string;
19
+ senderLabel: string;
20
+ /** null when the send log could not be read — not the same as zero sends. */
21
+ sent: number | null;
22
+ failed: number | null;
23
+ lastSentAt: number | null;
24
+ }
25
+
26
+ export interface AppEmailCatalog {
27
+ appId: string;
28
+ appName: string;
29
+ appPath: string;
30
+ emails: AppTransactionalEmail[];
31
+ /** Set when this app's catalog could not be read at all. */
32
+ error: string | null;
33
+ /** Set when the catalog loaded but its send counts did not. */
34
+ statsError: string | null;
35
+ }
36
+
37
+ export interface LocalTransactionalEmailCatalog {
38
+ app: string;
39
+ statsAvailable: boolean;
40
+ statsError: string | null;
41
+ emails: AppTransactionalEmail[];
42
+ }
43
+
44
+ export function aggregateSharedEmails(
45
+ local: LocalTransactionalEmailCatalog,
46
+ appCatalogs: AppEmailCatalog[],
47
+ ): { emails: AppTransactionalEmail[]; statsError: string | null } {
48
+ const catalogs = [
49
+ {
50
+ appId: local.app,
51
+ appName: local.app,
52
+ appPath: "",
53
+ emails: local.emails,
54
+ error: null,
55
+ statsError: local.statsAvailable ? null : local.statsError,
56
+ },
57
+ ...appCatalogs.filter((catalog) => catalog.appId !== local.app),
58
+ ];
59
+ const unreadable = catalogs.find(
60
+ (catalog) => catalog.error || catalog.statsError,
61
+ );
62
+ const statsAvailable = !unreadable;
63
+
64
+ return {
65
+ statsError: unreadable?.error ?? unreadable?.statsError ?? null,
66
+ emails: local.emails
67
+ .filter((email) => email.app === "core")
68
+ .map((definition) => {
69
+ if (!statsAvailable) {
70
+ return { ...definition, sent: null, failed: null, lastSentAt: null };
71
+ }
72
+ let sent = 0;
73
+ let failed = 0;
74
+ let lastSentAt: number | null = null;
75
+ for (const catalog of catalogs) {
76
+ const email = catalog.emails.find(
77
+ (candidate) => candidate.id === definition.id,
78
+ );
79
+ sent += email?.sent ?? 0;
80
+ failed += email?.failed ?? 0;
81
+ if (
82
+ email?.lastSentAt != null &&
83
+ (lastSentAt === null || email.lastSentAt > lastSentAt)
84
+ ) {
85
+ lastSentAt = email.lastSentAt;
86
+ }
87
+ }
88
+ return { ...definition, sent, failed, lastSentAt };
89
+ }),
90
+ };
91
+ }
92
+
93
+ function actionUrl(appPath: string, action: string, query = ""): string {
94
+ const base = appPath.replace(/\/$/, "");
95
+ return `${base}/_agent-native/actions/${action}${query}`;
96
+ }
97
+
98
+ export async function callAppAction<T>(
99
+ appPath: string,
100
+ action: string,
101
+ params: Record<string, unknown>,
102
+ method: "GET" | "POST",
103
+ ): Promise<T> {
104
+ const query =
105
+ method === "GET"
106
+ ? `?${new URLSearchParams(
107
+ Object.entries(params).map(([key, value]) => [key, String(value)]),
108
+ )}`
109
+ : "";
110
+ const res = await fetch(actionUrl(appPath, action, query), {
111
+ method,
112
+ credentials: "include",
113
+ headers: {
114
+ Accept: "application/json",
115
+ "Content-Type": "application/json",
116
+ "X-Agent-Native-Frontend": "1",
117
+ },
118
+ ...(method === "POST" ? { body: JSON.stringify(params) } : {}),
119
+ });
120
+ if (!res.ok) throw new Error(`${action} failed: HTTP ${res.status}`);
121
+ return (await res.json()) as T;
122
+ }
123
+
124
+ /**
125
+ * Load one app's catalog. Never throws — a single unreachable app must not
126
+ * blank the whole screen, but its failure is returned rather than swallowed so
127
+ * the UI can say "couldn't read" instead of showing it as having no emails.
128
+ */
129
+ const CATALOG_TIMEOUT_MS = 10_000;
130
+
131
+ export async function fetchAppEmailCatalog(
132
+ app: { id: string; name: string; path: string },
133
+ windowDays: number,
134
+ ): Promise<AppEmailCatalog> {
135
+ const base: AppEmailCatalog = {
136
+ appId: app.id,
137
+ appName: app.name,
138
+ appPath: app.path,
139
+ emails: [],
140
+ error: null,
141
+ statsError: null,
142
+ };
143
+ try {
144
+ const res = await fetch(
145
+ actionUrl(
146
+ app.path,
147
+ "list-transactional-emails",
148
+ `?windowDays=${windowDays}`,
149
+ ),
150
+ {
151
+ credentials: "include",
152
+ headers: { Accept: "application/json" },
153
+ signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS),
154
+ },
155
+ );
156
+ if (!res.ok) {
157
+ return { ...base, error: `HTTP ${res.status}` };
158
+ }
159
+ const body = (await res.json()) as {
160
+ emails?: AppTransactionalEmail[];
161
+ statsAvailable?: boolean;
162
+ statsError?: string | null;
163
+ };
164
+ return {
165
+ ...base,
166
+ emails: body.emails ?? [],
167
+ statsError:
168
+ body.statsAvailable === false ? (body.statsError ?? "unknown") : null,
169
+ };
170
+ } catch (error) {
171
+ return {
172
+ ...base,
173
+ error: error instanceof Error ? error.message : String(error),
174
+ };
175
+ }
176
+ }
177
+
178
+ export interface EmailPreview {
179
+ subject: string;
180
+ html: string;
181
+ text: string;
182
+ }
183
+
184
+ /** Render one email's preview with dummy data, from the owning app. */
185
+ export async function fetchEmailPreview(
186
+ appPath: string,
187
+ id: string,
188
+ ): Promise<EmailPreview> {
189
+ const res = await fetch(
190
+ actionUrl(
191
+ appPath,
192
+ "render-transactional-email-preview",
193
+ `?id=${encodeURIComponent(id)}`,
194
+ ),
195
+ { credentials: "include", headers: { Accept: "application/json" } },
196
+ );
197
+ if (!res.ok) {
198
+ throw new Error(`Preview failed: HTTP ${res.status}`);
199
+ }
200
+ return (await res.json()) as EmailPreview;
201
+ }
@@ -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
+ }
@@ -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"),