@agent-native/dispatch 0.17.3 → 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 (41) hide show
  1. package/dist/actions/provider-api-register.d.ts +8 -8
  2. package/dist/client/transactional-emails.d.ts +57 -0
  3. package/dist/client/transactional-emails.d.ts.map +1 -0
  4. package/dist/client/transactional-emails.js +116 -0
  5. package/dist/client/transactional-emails.js.map +1 -0
  6. package/dist/components/layout/Layout.d.ts.map +1 -1
  7. package/dist/components/layout/Layout.js +8 -1
  8. package/dist/components/layout/Layout.js.map +1 -1
  9. package/dist/components/transactional-email-activity.d.ts +24 -0
  10. package/dist/components/transactional-email-activity.d.ts.map +1 -0
  11. package/dist/components/transactional-email-activity.js +31 -0
  12. package/dist/components/transactional-email-activity.js.map +1 -0
  13. package/dist/components/transactional-email-metrics.d.ts +40 -0
  14. package/dist/components/transactional-email-metrics.d.ts.map +1 -0
  15. package/dist/components/transactional-email-metrics.js +44 -0
  16. package/dist/components/transactional-email-metrics.js.map +1 -0
  17. package/dist/components/transactional-email-preview.d.ts +7 -0
  18. package/dist/components/transactional-email-preview.d.ts.map +1 -0
  19. package/dist/components/transactional-email-preview.js +41 -0
  20. package/dist/components/transactional-email-preview.js.map +1 -0
  21. package/dist/routes/index.d.ts.map +1 -1
  22. package/dist/routes/index.js +2 -0
  23. package/dist/routes/index.js.map +1 -1
  24. package/dist/routes/pages/transactional-email.$appId.$id.d.ts +5 -0
  25. package/dist/routes/pages/transactional-email.$appId.$id.d.ts.map +1 -0
  26. package/dist/routes/pages/transactional-email.$appId.$id.js +144 -0
  27. package/dist/routes/pages/transactional-email.$appId.$id.js.map +1 -0
  28. package/dist/routes/pages/transactional-email.d.ts +5 -0
  29. package/dist/routes/pages/transactional-email.d.ts.map +1 -0
  30. package/dist/routes/pages/transactional-email.js +139 -0
  31. package/dist/routes/pages/transactional-email.js.map +1 -0
  32. package/package.json +2 -2
  33. package/src/client/transactional-emails.spec.ts +91 -0
  34. package/src/client/transactional-emails.ts +201 -0
  35. package/src/components/layout/Layout.tsx +8 -0
  36. package/src/components/transactional-email-activity.tsx +102 -0
  37. package/src/components/transactional-email-metrics.tsx +127 -0
  38. package/src/components/transactional-email-preview.tsx +88 -0
  39. package/src/routes/index.ts +5 -0
  40. package/src/routes/pages/transactional-email.$appId.$id.tsx +362 -0
  41. package/src/routes/pages/transactional-email.tsx +571 -0
@@ -0,0 +1,571 @@
1
+ import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
2
+ import { useT } from "@agent-native/core/client/i18n";
3
+ import {
4
+ IconAlertTriangle,
5
+ IconChevronRight,
6
+ IconEye,
7
+ IconInfoCircle,
8
+ IconList,
9
+ IconMail,
10
+ } from "@tabler/icons-react";
11
+ import { useQueries, useQuery } from "@tanstack/react-query";
12
+ import { useMemo, useState } from "react";
13
+ import { Link } from "react-router";
14
+
15
+ import {
16
+ aggregateSharedEmails,
17
+ callAppAction,
18
+ fetchAppEmailCatalog,
19
+ type AppEmailCatalog,
20
+ type AppTransactionalEmail,
21
+ type LocalTransactionalEmailCatalog,
22
+ } from "../../client/transactional-emails";
23
+ import { ActionQueryError } from "../../components/action-query-error";
24
+ import { DispatchShell } from "../../components/dispatch-shell";
25
+ import {
26
+ ActivityTable,
27
+ type EmailActivityEntry,
28
+ } from "../../components/transactional-email-activity";
29
+ import {
30
+ OpenRateCell,
31
+ SendsCell,
32
+ LastSentCell,
33
+ type EmailEngagement,
34
+ type ProviderMetricsResult,
35
+ } from "../../components/transactional-email-metrics";
36
+ import { EmailPreviewPane } from "../../components/transactional-email-preview";
37
+ import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
38
+ import { Button } from "../../components/ui/button";
39
+ import {
40
+ Collapsible,
41
+ CollapsibleContent,
42
+ CollapsibleTrigger,
43
+ } from "../../components/ui/collapsible";
44
+ import {
45
+ Dialog,
46
+ DialogContent,
47
+ DialogDescription,
48
+ DialogHeader,
49
+ DialogTitle,
50
+ } from "../../components/ui/dialog";
51
+ import { Skeleton } from "../../components/ui/skeleton";
52
+ import {
53
+ Table,
54
+ TableBody,
55
+ TableCell,
56
+ TableHead,
57
+ TableHeader,
58
+ TableRow,
59
+ } from "../../components/ui/table";
60
+
61
+ export function meta() {
62
+ return [{ title: "Transactional email — Dispatch" }];
63
+ }
64
+
65
+ const WINDOW_DAYS = 30;
66
+ const ACTIVITY_LIMIT = 50;
67
+
68
+ interface WorkspaceAppRef {
69
+ id: string;
70
+ name: string;
71
+ path: string;
72
+ status?: "ready" | "pending";
73
+ }
74
+
75
+ /** Shape of the local `list-transactional-emails` action response. */
76
+ function PreviewDialog({
77
+ email,
78
+ appId,
79
+ appPath,
80
+ open,
81
+ onOpenChange,
82
+ }: {
83
+ email: AppTransactionalEmail;
84
+ appId: string;
85
+ appPath: string;
86
+ open: boolean;
87
+ onOpenChange: (next: boolean) => void;
88
+ }) {
89
+ const t = useT();
90
+ return (
91
+ <Dialog open={open} onOpenChange={onOpenChange}>
92
+ <DialogContent className="max-w-3xl">
93
+ <DialogHeader>
94
+ <DialogTitle>{email.name}</DialogTitle>
95
+ <DialogDescription>
96
+ {t("dispatch.transactionalEmail.previewDescription")}
97
+ </DialogDescription>
98
+ </DialogHeader>
99
+ {/* Dialog content only mounts while open, so this fetches on open and
100
+ drops the request when closed rather than needing an enabled flag. */}
101
+ <EmailPreviewPane
102
+ appId={appId}
103
+ appPath={appPath}
104
+ id={email.id}
105
+ name={email.name}
106
+ />
107
+ </DialogContent>
108
+ </Dialog>
109
+ );
110
+ }
111
+
112
+ function ActivityDialog({
113
+ email,
114
+ appPath,
115
+ unavailableReason,
116
+ open,
117
+ onOpenChange,
118
+ }: {
119
+ email: AppTransactionalEmail;
120
+ appPath: string;
121
+ unavailableReason: string | null;
122
+ open: boolean;
123
+ onOpenChange: (next: boolean) => void;
124
+ }) {
125
+ const t = useT();
126
+ const activityQuery = useQuery({
127
+ queryKey: ["list-email-activity", appPath, email.id, ACTIVITY_LIMIT],
128
+ queryFn: () =>
129
+ callAppAction<ProviderMetricsResult<EmailActivityEntry[]>>(
130
+ appPath,
131
+ "list-email-activity",
132
+ { templateId: email.id, limit: ACTIVITY_LIMIT },
133
+ "GET",
134
+ ),
135
+ enabled: open && !unavailableReason,
136
+ });
137
+
138
+ return (
139
+ <Dialog open={open} onOpenChange={onOpenChange}>
140
+ <DialogContent className="max-w-4xl">
141
+ <DialogHeader>
142
+ <DialogTitle>
143
+ {t("dispatch.transactionalEmail.activityTitle", {
144
+ name: email.name,
145
+ })}
146
+ </DialogTitle>
147
+ <DialogDescription>
148
+ {t("dispatch.transactionalEmail.retentionNote")}
149
+ </DialogDescription>
150
+ </DialogHeader>
151
+ <ActivityTable
152
+ result={
153
+ unavailableReason
154
+ ? { available: false, reason: unavailableReason }
155
+ : activityQuery.data
156
+ }
157
+ isLoading={activityQuery.isLoading}
158
+ isError={activityQuery.isError}
159
+ error={activityQuery.error}
160
+ onRetry={() => void activityQuery.refetch()}
161
+ />
162
+ </DialogContent>
163
+ </Dialog>
164
+ );
165
+ }
166
+
167
+ function EmailRow({
168
+ email,
169
+ appId,
170
+ appPath,
171
+ engagement,
172
+ engagementUnavailable,
173
+ engagementLoading,
174
+ }: {
175
+ email: AppTransactionalEmail;
176
+ appId: string;
177
+ appPath: string;
178
+ engagement: EmailEngagement | undefined;
179
+ engagementUnavailable: string | null;
180
+ engagementLoading: boolean;
181
+ }) {
182
+ const t = useT();
183
+ const [previewOpen, setPreviewOpen] = useState(false);
184
+ const [activityOpen, setActivityOpen] = useState(false);
185
+
186
+ return (
187
+ <TableRow>
188
+ <TableCell className="align-top">
189
+ <Link
190
+ to={`/transactional-email/${appId}/${email.id}`}
191
+ className="text-sm font-medium text-foreground hover:underline"
192
+ >
193
+ {email.name}
194
+ </Link>
195
+ <div className="font-mono text-xs text-muted-foreground">
196
+ {email.id}
197
+ </div>
198
+ </TableCell>
199
+ <TableCell className="max-w-64 align-top text-xs text-muted-foreground">
200
+ {email.trigger}
201
+ </TableCell>
202
+ <TableCell className="max-w-56 align-top text-xs text-muted-foreground">
203
+ {email.recipientLabel}
204
+ </TableCell>
205
+ <TableCell className="max-w-56 align-top text-xs text-muted-foreground">
206
+ {email.senderLabel}
207
+ </TableCell>
208
+ <TableCell className="align-top text-sm">
209
+ <SendsCell sent={email.sent} failed={email.failed} />
210
+ </TableCell>
211
+ <TableCell className="align-top text-sm">
212
+ <OpenRateCell
213
+ engagement={engagement}
214
+ unavailableReason={engagementUnavailable}
215
+ loading={engagementLoading}
216
+ />
217
+ </TableCell>
218
+ <TableCell className="align-top text-xs text-muted-foreground">
219
+ <LastSentCell lastSentAt={email.lastSentAt} sent={email.sent} />
220
+ </TableCell>
221
+ <TableCell className="align-top">
222
+ <div className="flex justify-end gap-2">
223
+ <Button
224
+ variant="outline"
225
+ size="sm"
226
+ onClick={() => setPreviewOpen(true)}
227
+ >
228
+ <IconEye className="size-4" />
229
+ {t("dispatch.transactionalEmail.preview")}
230
+ </Button>
231
+ <Button
232
+ variant="ghost"
233
+ size="sm"
234
+ onClick={() => setActivityOpen(true)}
235
+ >
236
+ <IconList className="size-4" />
237
+ {t("dispatch.transactionalEmail.activityLink")}
238
+ </Button>
239
+ </div>
240
+ <PreviewDialog
241
+ email={email}
242
+ appId={appId}
243
+ appPath={appPath}
244
+ open={previewOpen}
245
+ onOpenChange={setPreviewOpen}
246
+ />
247
+ <ActivityDialog
248
+ email={email}
249
+ appPath={appPath}
250
+ unavailableReason={engagementUnavailable}
251
+ open={activityOpen}
252
+ onOpenChange={setActivityOpen}
253
+ />
254
+ </TableCell>
255
+ </TableRow>
256
+ );
257
+ }
258
+
259
+ function AppEmailTable({
260
+ catalog,
261
+ engagementByTemplate,
262
+ engagementUnavailable,
263
+ engagementLoading,
264
+ }: {
265
+ catalog: AppEmailCatalog;
266
+ engagementByTemplate: Map<string, EmailEngagement>;
267
+ engagementUnavailable: string | null;
268
+ engagementLoading: boolean;
269
+ }) {
270
+ const t = useT();
271
+
272
+ if (catalog.error) {
273
+ return (
274
+ <Alert variant="destructive">
275
+ <IconAlertTriangle className="size-4" />
276
+ <AlertTitle>
277
+ {t("dispatch.transactionalEmail.catalogUnreadable")}
278
+ </AlertTitle>
279
+ <AlertDescription>{catalog.error}</AlertDescription>
280
+ </Alert>
281
+ );
282
+ }
283
+
284
+ return (
285
+ <>
286
+ {catalog.statsError ? (
287
+ <Alert className="mb-4">
288
+ <IconInfoCircle className="size-4" />
289
+ <AlertTitle>
290
+ {t("dispatch.transactionalEmail.countsUnreadable")}
291
+ </AlertTitle>
292
+ <AlertDescription>{catalog.statsError}</AlertDescription>
293
+ </Alert>
294
+ ) : null}
295
+ {catalog.emails.length === 0 ? (
296
+ <div className="rounded-xl border border-dashed px-4 py-6 text-sm text-muted-foreground">
297
+ {t("dispatch.transactionalEmail.appSendsNoEmail")}
298
+ </div>
299
+ ) : (
300
+ <Table>
301
+ <TableHeader>
302
+ <TableRow>
303
+ <TableHead>{t("dispatch.transactionalEmail.email")}</TableHead>
304
+ <TableHead>{t("dispatch.transactionalEmail.trigger")}</TableHead>
305
+ <TableHead>
306
+ {t("dispatch.transactionalEmail.recipient")}
307
+ </TableHead>
308
+ <TableHead>{t("dispatch.transactionalEmail.sender")}</TableHead>
309
+ <TableHead>{t("dispatch.transactionalEmail.sends")}</TableHead>
310
+ <TableHead>{t("dispatch.transactionalEmail.openRate")}</TableHead>
311
+ <TableHead>{t("dispatch.transactionalEmail.lastSent")}</TableHead>
312
+ <TableHead />
313
+ </TableRow>
314
+ </TableHeader>
315
+ <TableBody>
316
+ {catalog.emails.map((email) => (
317
+ <EmailRow
318
+ key={email.id}
319
+ email={email}
320
+ appId={catalog.appId}
321
+ appPath={catalog.appPath}
322
+ engagement={engagementByTemplate.get(email.id)}
323
+ engagementUnavailable={engagementUnavailable}
324
+ engagementLoading={engagementLoading}
325
+ />
326
+ ))}
327
+ </TableBody>
328
+ </Table>
329
+ )}
330
+ </>
331
+ );
332
+ }
333
+
334
+ function AppEmailCard({
335
+ name,
336
+ path,
337
+ open,
338
+ onOpenChange,
339
+ catalog,
340
+ isLoading,
341
+ isError,
342
+ error,
343
+ onRetry,
344
+ engagementByTemplate,
345
+ engagementUnavailable,
346
+ engagementLoading,
347
+ }: {
348
+ name: string;
349
+ path: string;
350
+ open: boolean;
351
+ onOpenChange: (next: boolean) => void;
352
+ catalog: AppEmailCatalog | undefined;
353
+ isLoading: boolean;
354
+ isError: boolean;
355
+ error: unknown;
356
+ onRetry: () => void;
357
+ engagementByTemplate: Map<string, EmailEngagement>;
358
+ engagementUnavailable: string | null;
359
+ engagementLoading: boolean;
360
+ }) {
361
+ return (
362
+ <Collapsible
363
+ open={open}
364
+ onOpenChange={onOpenChange}
365
+ className="rounded-2xl bg-card"
366
+ >
367
+ <CollapsibleTrigger className="group flex w-full cursor-pointer items-center justify-between gap-3 p-5 text-left hover:bg-muted/20">
368
+ <span className="flex items-center gap-2">
369
+ <IconChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
370
+ <span className="text-sm font-medium text-foreground">{name}</span>
371
+ </span>
372
+ <span className="font-mono text-xs text-muted-foreground">{path}</span>
373
+ </CollapsibleTrigger>
374
+ <CollapsibleContent className="border-t px-5 pb-5 pt-4">
375
+ {isError ? (
376
+ <ActionQueryError error={error} onRetry={onRetry} />
377
+ ) : isLoading || !catalog ? (
378
+ <Skeleton className="h-24 w-full" />
379
+ ) : (
380
+ <AppEmailTable
381
+ catalog={catalog}
382
+ engagementByTemplate={engagementByTemplate}
383
+ engagementUnavailable={engagementUnavailable}
384
+ engagementLoading={engagementLoading}
385
+ />
386
+ )}
387
+ </CollapsibleContent>
388
+ </Collapsible>
389
+ );
390
+ }
391
+
392
+ export default function TransactionalEmailRoute() {
393
+ const t = useT();
394
+ const appsQuery = useActionQuery<WorkspaceAppRef[]>("list-workspace-apps", {
395
+ includeAgentCards: false,
396
+ });
397
+
398
+ const apps = useMemo(
399
+ () =>
400
+ (appsQuery.data ?? [])
401
+ .filter((app) => app.status !== "pending")
402
+ .map((app) => ({ id: app.id, name: app.name, path: app.path })),
403
+ [appsQuery.data],
404
+ );
405
+
406
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
407
+ const [sharedOpen, setSharedOpen] = useState(true);
408
+
409
+ const sharedQuery = useActionQuery<LocalTransactionalEmailCatalog>(
410
+ "list-transactional-emails",
411
+ { windowDays: WINDOW_DAYS },
412
+ );
413
+
414
+ const catalogQueries = useQueries({
415
+ queries: apps.map((app) => ({
416
+ queryKey: ["transactional-email-catalog", app.id, WINDOW_DAYS],
417
+ queryFn: () => fetchAppEmailCatalog(app, WINDOW_DAYS),
418
+ enabled: sharedOpen || expanded[app.id] === true,
419
+ staleTime: Infinity,
420
+ })),
421
+ });
422
+
423
+ const sharedCatalog = useMemo<AppEmailCatalog | undefined>(() => {
424
+ if (!sharedQuery.data) return undefined;
425
+ const aggregate = aggregateSharedEmails(
426
+ sharedQuery.data,
427
+ catalogQueries
428
+ .map((query) => query.data)
429
+ .filter((catalog): catalog is AppEmailCatalog => Boolean(catalog)),
430
+ );
431
+ return {
432
+ appId: "core",
433
+ appName: t("dispatch.transactionalEmail.sharedTitle"),
434
+ appPath: t("dispatch.transactionalEmail.sharedSubtitle"),
435
+ emails: aggregate.emails,
436
+ error: null,
437
+ statsError: aggregate.statsError,
438
+ };
439
+ }, [sharedQuery.data, catalogQueries, t]);
440
+
441
+ const engagementQueries = useQueries({
442
+ queries: apps.map((app, index) => {
443
+ const catalog = catalogQueries[index]?.data;
444
+ const templateIds =
445
+ catalog && !catalog.error
446
+ ? catalog.emails.map((email) => email.id)
447
+ : [];
448
+ return {
449
+ queryKey: [
450
+ "list-email-engagement",
451
+ app.id,
452
+ templateIds.join(","),
453
+ WINDOW_DAYS,
454
+ ],
455
+ queryFn: () =>
456
+ callAppAction<ProviderMetricsResult<EmailEngagement[]>>(
457
+ app.path,
458
+ "list-email-engagement",
459
+ { templateIds, windowDays: WINDOW_DAYS },
460
+ "POST",
461
+ ),
462
+ enabled: expanded[app.id] === true && templateIds.length > 0,
463
+ };
464
+ }),
465
+ });
466
+
467
+ const sharedLoading =
468
+ sharedQuery.isLoading ||
469
+ (sharedOpen && catalogQueries.some((query) => query.isLoading));
470
+ const sharedError = catalogQueries.find((query) => query.isError)?.error;
471
+ const sharedProviderReason = t(
472
+ "dispatch.transactionalEmail.sharedProviderMetricsUnavailable",
473
+ );
474
+
475
+ return (
476
+ <DispatchShell
477
+ title={t("dispatch.transactionalEmail.title")}
478
+ description={t("dispatch.transactionalEmail.description")}
479
+ >
480
+ <div className="flex flex-col gap-4">
481
+ <Alert>
482
+ <IconInfoCircle className="size-4" />
483
+ <AlertTitle>
484
+ {t("dispatch.transactionalEmail.retentionTitle")}
485
+ </AlertTitle>
486
+ <AlertDescription>
487
+ {t("dispatch.transactionalEmail.retentionNote")}
488
+ </AlertDescription>
489
+ </Alert>
490
+
491
+ {appsQuery.isError ? (
492
+ <ActionQueryError
493
+ error={appsQuery.error}
494
+ onRetry={() => void appsQuery.refetch()}
495
+ />
496
+ ) : null}
497
+
498
+ {appsQuery.isLoading ? (
499
+ <div className="space-y-3">
500
+ <Skeleton className="h-16 w-full rounded-2xl" />
501
+ <Skeleton className="h-16 w-full rounded-2xl" />
502
+ </div>
503
+ ) : null}
504
+
505
+ {!appsQuery.isLoading && apps.length === 0 ? (
506
+ <div className="rounded-2xl border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
507
+ <IconMail className="mx-auto mb-2 size-5" />
508
+ {t("dispatch.transactionalEmail.noApps")}
509
+ </div>
510
+ ) : null}
511
+
512
+ <AppEmailCard
513
+ name={t("dispatch.transactionalEmail.sharedTitle")}
514
+ path={t("dispatch.transactionalEmail.sharedSubtitle")}
515
+ open={sharedOpen}
516
+ onOpenChange={setSharedOpen}
517
+ catalog={sharedCatalog}
518
+ isLoading={sharedLoading}
519
+ isError={sharedQuery.isError || Boolean(sharedError)}
520
+ error={sharedQuery.error ?? sharedError}
521
+ onRetry={() => {
522
+ void sharedQuery.refetch();
523
+ for (const query of catalogQueries) void query.refetch();
524
+ }}
525
+ engagementByTemplate={new Map()}
526
+ engagementUnavailable={sharedProviderReason}
527
+ engagementLoading={false}
528
+ />
529
+
530
+ {apps.map((app, index) => {
531
+ const query = catalogQueries[index];
532
+ const engagementQuery = engagementQueries[index];
533
+ const engagementResult = engagementQuery?.data;
534
+ const engagementUnavailable =
535
+ engagementResult && !engagementResult.available
536
+ ? engagementResult.reason
537
+ : engagementQuery?.isError
538
+ ? engagementQuery.error instanceof Error
539
+ ? engagementQuery.error.message
540
+ : String(engagementQuery.error)
541
+ : null;
542
+ const engagementByTemplate = new Map<string, EmailEngagement>();
543
+ if (engagementResult?.available) {
544
+ for (const entry of engagementResult.data) {
545
+ engagementByTemplate.set(entry.templateId, entry);
546
+ }
547
+ }
548
+ return (
549
+ <AppEmailCard
550
+ key={app.id}
551
+ name={app.name}
552
+ path={app.path}
553
+ open={expanded[app.id] === true}
554
+ onOpenChange={(next) =>
555
+ setExpanded((current) => ({ ...current, [app.id]: next }))
556
+ }
557
+ catalog={query?.data}
558
+ isLoading={query?.isLoading ?? false}
559
+ isError={query?.isError ?? false}
560
+ error={query?.error}
561
+ onRetry={() => void query?.refetch()}
562
+ engagementByTemplate={engagementByTemplate}
563
+ engagementUnavailable={engagementUnavailable}
564
+ engagementLoading={engagementQuery?.isLoading ?? false}
565
+ />
566
+ );
567
+ })}
568
+ </div>
569
+ </DispatchShell>
570
+ );
571
+ }