@agent-native/core 0.141.2 → 0.141.4

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 (157) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +1 -1
  3. package/corpus/templates/analytics/actions/gong-calls.ts +56 -18
  4. package/corpus/templates/analytics/changelog/2026-08-05-analytics-queries-keep-every-table-tenant-scoped.md +6 -0
  5. package/corpus/templates/analytics/changelog/2026-08-05-durable-a2a-workers-avoid-startup-migration-contention.md +6 -0
  6. package/corpus/templates/analytics/changelog/2026-08-05-fixed-sign-in-and-dashboard-requests-failing-when-background.md +6 -0
  7. package/corpus/templates/analytics/changelog/2026-08-05-historical-rollups-stay-complete-while-live-events-arrive.md +6 -0
  8. package/corpus/templates/analytics/server/db/schema.ts +1 -0
  9. package/corpus/templates/analytics/server/lib/dashboard-report.ts +2 -0
  10. package/corpus/templates/analytics/server/lib/emails.ts +51 -0
  11. package/corpus/templates/analytics/server/lib/first-party-analytics-health.ts +8 -12
  12. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -0
  13. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +262 -13
  14. package/corpus/templates/analytics/server/lib/gong.ts +132 -33
  15. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
  16. package/corpus/templates/analytics/server/plugins/auth.ts +21 -2
  17. package/corpus/templates/analytics/server/plugins/db.ts +35 -10
  18. package/corpus/templates/analytics/server/plugins/transactional-emails.ts +5 -0
  19. package/corpus/templates/calendar/app/components/calendar/InlineEventPickers.tsx +17 -7
  20. package/corpus/templates/calendar/server/lib/booking-emails.ts +154 -70
  21. package/corpus/templates/calendar/server/lib/emails.ts +167 -0
  22. package/corpus/templates/calendar/server/lib/event-guest-notifications.ts +64 -28
  23. package/corpus/templates/calendar/server/plugins/transactional-emails.ts +5 -0
  24. package/corpus/templates/clips/actions/invite-member.ts +39 -16
  25. package/corpus/templates/clips/app/components/player/share-dialog.tsx +73 -1
  26. package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
  27. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +10 -25
  28. package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
  29. package/corpus/templates/clips/changelog/2026-08-05-activity-is-now-the-default-clips-recording-tab-and-ai-setup.md +6 -0
  30. package/corpus/templates/clips/changelog/2026-08-05-clips-can-copy-a-linked-email-preview-for-public-recordings.md +6 -0
  31. package/corpus/templates/clips/changelog/2026-08-05-preparing-recording-now-uses-a-clean-shadow-free-status-card.md +6 -0
  32. package/corpus/templates/clips/desktop/src/styles.css +0 -3
  33. package/corpus/templates/clips/server/lib/emails.ts +256 -0
  34. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +24 -0
  35. package/corpus/templates/clips/server/plugins/transactional-email-catalog.ts +5 -0
  36. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +4 -1
  37. package/corpus/templates/clips/shared/email-preview.ts +61 -0
  38. package/corpus/templates/content/server/lib/comment-notifications.ts +57 -26
  39. package/corpus/templates/content/server/lib/emails.ts +70 -0
  40. package/corpus/templates/content/server/plugins/transactional-emails.ts +5 -0
  41. package/corpus/templates/design/actions/import-design-tokens.ts +11 -8
  42. package/corpus/templates/design/actions/index-design-tokens.ts +13 -6
  43. package/corpus/templates/design/changelog/2026-08-05-design-token-imports-classify-typography-dimensions-correctly.md +6 -0
  44. package/corpus/templates/dispatch/app/routes/transactional-email.$appId.$id.tsx +4 -0
  45. package/corpus/templates/dispatch/app/routes/transactional-email.tsx +4 -0
  46. package/corpus/templates/dispatch/changelog/2026-08-05-transactional-email-catalogs-and-delivery-activity-are-available-again.md +6 -0
  47. package/corpus/templates/factory/AGENTS.md +18 -17
  48. package/corpus/templates/factory/actions/get-factory-automation-health.ts +50 -0
  49. package/corpus/templates/factory/actions/get-triage-config.ts +8 -0
  50. package/corpus/templates/factory/actions/save-triage-config.ts +26 -0
  51. package/corpus/templates/factory/app/i18n/en-US.ts +27 -0
  52. package/corpus/templates/factory/app/routes/factory.tsx +376 -206
  53. package/corpus/templates/factory/changelog/2026-08-05-factory-now-keeps-production-automations-running-shows-sched.md +6 -0
  54. package/corpus/templates/factory/server/db/schema.ts +6 -0
  55. package/corpus/templates/factory/server/plugins/factory-migrations.ts +10 -0
  56. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +141 -2
  57. package/corpus/templates/forms/app/pages/FormsListPage.tsx +41 -21
  58. package/corpus/templates/forms/changelog/2026-08-05-form-links-can-now-be-cmd-clicked-to-open-in-a-new-tab.md +6 -0
  59. package/corpus/templates/forms/server/lib/emails.ts +52 -0
  60. package/corpus/templates/forms/server/lib/response-email.ts +2 -0
  61. package/corpus/templates/forms/server/plugins/transactional-emails.ts +5 -0
  62. package/corpus/templates/plan/actions/request-plan-access.ts +33 -12
  63. package/corpus/templates/plan/server/lib/comment-notifications.ts +53 -27
  64. package/corpus/templates/plan/server/lib/emails.ts +67 -0
  65. package/corpus/templates/plan/server/plugins/transactional-emails.ts +5 -0
  66. package/corpus/templates/slides/actions/import-from-url.ts +1 -0
  67. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +492 -381
  68. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +8 -7
  69. package/corpus/templates/slides/app/components/layout/Layout.tsx +27 -23
  70. package/corpus/templates/slides/app/components/onboarding/FirstDeckOnboardingFlow.tsx +313 -0
  71. package/corpus/templates/slides/app/components/ui/command.tsx +1 -0
  72. package/corpus/templates/slides/app/i18n/en-US.ts +8 -2
  73. package/corpus/templates/slides/app/lib/create-deck-generation.ts +305 -0
  74. package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -12
  75. package/corpus/templates/slides/app/pages/Index.tsx +127 -137
  76. package/corpus/templates/slides/app/root.tsx +8 -0
  77. package/corpus/templates/slides/changelog/2026-08-05-deck-browsing-keeps-all-and-mine-in-the-header-with-a-tighte.md +6 -0
  78. package/corpus/templates/slides/changelog/2026-08-05-new-deck-creation-now-flows-through-a-clean-full-screen-refe.md +6 -0
  79. package/corpus/templates/slides/changelog/2026-08-05-website-imports-now-include-linked-stylesheets-for-more-accu.md +6 -0
  80. package/corpus/templates/slides/server/lib/comment-notifications.ts +44 -21
  81. package/corpus/templates/slides/server/lib/emails.ts +40 -0
  82. package/corpus/templates/slides/server/plugins/transactional-emails.ts +5 -0
  83. package/dist/brand-kit/index.d.ts +1 -0
  84. package/dist/brand-kit/index.js +1 -0
  85. package/dist/brand-kit/tokens.d.ts +91 -0
  86. package/dist/brand-kit/tokens.js +279 -0
  87. package/dist/brand-kit/types.d.ts +25 -0
  88. package/dist/client/AssistantChat.js +7 -7
  89. package/dist/client/onboarding/FirstRunOnboarding.js +26 -2
  90. package/dist/client/onboarding/first-run-registry.d.ts +25 -0
  91. package/dist/client/onboarding/first-run-registry.js +18 -0
  92. package/dist/client/onboarding/index.d.ts +2 -0
  93. package/dist/client/onboarding/index.js +1 -0
  94. package/dist/collab/awareness.d.ts +2 -2
  95. package/dist/collab/routes.d.ts +1 -1
  96. package/dist/collab/struct-routes.d.ts +1 -1
  97. package/dist/db/index.d.ts +1 -1
  98. package/dist/db/index.js +1 -1
  99. package/dist/db/migrations.d.ts +13 -3
  100. package/dist/db/migrations.js +19 -4
  101. package/dist/deploy/build.d.ts +8 -0
  102. package/dist/deploy/build.js +92 -3
  103. package/dist/deploy/workspace-deploy.js +86 -3
  104. package/dist/email-catalog/actions/list-email-activity.d.ts +5 -0
  105. package/dist/email-catalog/actions/list-email-activity.js +19 -0
  106. package/dist/email-catalog/actions/list-email-engagement.d.ts +5 -0
  107. package/dist/email-catalog/actions/list-email-engagement.js +15 -0
  108. package/dist/email-catalog/actions/list-email-log.d.ts +7 -0
  109. package/dist/email-catalog/actions/list-email-log.js +23 -0
  110. package/dist/email-catalog/actions/list-transactional-emails.d.ts +22 -0
  111. package/dist/email-catalog/actions/list-transactional-emails.js +67 -0
  112. package/dist/email-catalog/actions/render-transactional-email-preview.d.ts +9 -0
  113. package/dist/email-catalog/actions/render-transactional-email-preview.js +25 -0
  114. package/dist/email-catalog/authorize.d.ts +1 -0
  115. package/dist/email-catalog/authorize.js +9 -0
  116. package/dist/email-catalog/log.d.ts +60 -0
  117. package/dist/email-catalog/log.js +142 -0
  118. package/dist/email-catalog/provider-metrics.d.ts +34 -0
  119. package/dist/email-catalog/provider-metrics.js +182 -0
  120. package/dist/email-catalog/registry.d.ts +78 -0
  121. package/dist/email-catalog/registry.js +70 -0
  122. package/dist/email-catalog/schema.d.ts +231 -0
  123. package/dist/email-catalog/schema.js +53 -0
  124. package/dist/email-catalog/system-emails.d.ts +13 -0
  125. package/dist/email-catalog/system-emails.js +67 -0
  126. package/dist/jobs/index.d.ts +2 -0
  127. package/dist/jobs/index.js +2 -0
  128. package/dist/jobs/run-history.js +48 -1
  129. package/dist/jobs/scheduler-dispatch.d.ts +7 -0
  130. package/dist/jobs/scheduler-dispatch.js +7 -0
  131. package/dist/jobs/scheduler-health.d.ts +22 -0
  132. package/dist/jobs/scheduler-health.js +172 -0
  133. package/dist/jobs/scheduler.js +67 -2
  134. package/dist/localization/default-messages.d.ts +45 -0
  135. package/dist/localization/default-messages.js +45 -0
  136. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  137. package/dist/notifications/routes.d.ts +3 -3
  138. package/dist/observability/routes.d.ts +3 -3
  139. package/dist/org/handlers.js +9 -1
  140. package/dist/progress/routes.d.ts +1 -1
  141. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  142. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  143. package/dist/server/action-discovery.js +22 -0
  144. package/dist/server/agent-chat/recurring-jobs-runtime.d.ts +7 -1
  145. package/dist/server/agent-chat/recurring-jobs-runtime.js +12 -0
  146. package/dist/server/agent-chat-plugin.d.ts +2 -1
  147. package/dist/server/agent-chat-plugin.js +69 -22
  148. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  149. package/dist/server/better-auth-instance.js +17 -2
  150. package/dist/server/design-token-utils.d.ts +6 -1
  151. package/dist/server/design-token-utils.js +252 -59
  152. package/dist/server/email.d.ts +11 -0
  153. package/dist/server/email.js +54 -3
  154. package/dist/server/index.d.ts +3 -2
  155. package/dist/server/index.js +3 -2
  156. package/dist/vite/client.js +20 -14
  157. package/package.json +2 -1
@@ -1,15 +1,18 @@
1
1
  import {
2
+ deferMigration,
2
3
  ensureAdditiveColumns,
3
4
  getDialect,
4
5
  getDbExec,
5
6
  isPostgres,
6
7
  runMigrations,
7
8
  } from "@agent-native/core/db";
9
+ import { isInBackgroundFunctionRuntime } from "@agent-native/core/server";
8
10
 
9
11
  // Side-effect import: ensures registerShareableResource runs on server
10
12
  // startup so the dashboard / analysis share actions know where to dispatch.
11
13
  import "../db/index.js";
12
14
  import * as schema from "../db/schema.js";
15
+ import { FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY } from "../lib/first-party-analytics-rollups.js";
13
16
  import { repairPersistedFirstPartyDashboardQueries } from "../lib/first-party-dashboard-repair.js";
14
17
 
15
18
  /**
@@ -141,7 +144,17 @@ const ANALYTICS_ROLLUP_BACKFILL_STATEMENTS = {
141
144
  ],
142
145
  } as const;
143
146
 
144
- async function runHistoricalAnalyticsRollupBackfill(): Promise<void> {
147
+ async function runHistoricalAnalyticsRollupBackfill() {
148
+ // Durable agent workers share this plugin bundle with the regular server,
149
+ // but they must not become a second migration runner for an expensive
150
+ // historical scan. The keep-warm/regular server path owns this work.
151
+ if (isInBackgroundFunctionRuntime()) {
152
+ console.info(
153
+ "[db] Deferring historical Analytics rollup backfill from durable background runtime",
154
+ );
155
+ return deferMigration();
156
+ }
157
+
145
158
  const db = getDbExec();
146
159
 
147
160
  if (isPostgres()) {
@@ -151,27 +164,30 @@ async function runHistoricalAnalyticsRollupBackfill(): Promise<void> {
151
164
  );
152
165
  }
153
166
 
154
- await db.transaction(async (tx) => {
167
+ const result = await db.transaction(async (tx) => {
155
168
  // Only one serverless instance should perform the expensive scan. A
156
- // try-lock is intentional: competing cold starts fail fast instead of
157
- // holding pooled Neon connections while waiting for the winner. The
158
- // monotonic rollup upsert below keeps a live increment from being
159
- // overwritten if ingest commits during the historical snapshot.
169
+ // A try-lock keeps competing cold starts from holding pooled Neon
170
+ // connections while waiting for the winner. A lock loser defers without
171
+ // recording the migration so a failed winner can be retried safely.
172
+ // The monotonic rollup upsert below keeps a live increment from being
173
+ // overwritten if ingest commits during the historical snapshot. Live
174
+ // ingest takes this same lock before inserting its raw event.
160
175
  const lockResult = await tx.execute({
161
176
  sql: "SELECT pg_try_advisory_xact_lock(hashtextextended(?, 0::bigint)) AS acquired",
162
- args: ["agent-native:analytics-rollup-backfill"],
177
+ args: [FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY],
163
178
  });
164
179
  const acquired = lockResult.rows[0]?.acquired;
165
180
  if (acquired !== true && acquired !== "t") {
166
- throw new Error(
167
- "Analytics rollup backfill is already running on another instance",
181
+ console.info(
182
+ "[db] Deferring historical Analytics rollup backfill; another instance owns the advisory lock",
168
183
  );
184
+ return deferMigration();
169
185
  }
170
186
  for (const statement of ANALYTICS_ROLLUP_BACKFILL_STATEMENTS.postgres) {
171
187
  await tx.execute(statement);
172
188
  }
173
189
  });
174
- return;
190
+ return result;
175
191
  }
176
192
 
177
193
  const statements = ANALYTICS_ROLLUP_BACKFILL_STATEMENTS.sqlite;
@@ -1556,6 +1572,15 @@ const runAnalyticsMigrations = runMigrations(
1556
1572
  * swallowed so it can never fail boot.
1557
1573
  */
1558
1574
  export default async (nitroApp: any): Promise<void> => {
1575
+ if (isInBackgroundFunctionRuntime()) {
1576
+ // Durable workers execute signed internal routes against a schema owned by
1577
+ // the regular server. A second migration runner only adds a Neon pool
1578
+ // probe to every worker cold start.
1579
+ console.info(
1580
+ "[db] Skipping Analytics migrations in durable background runtime",
1581
+ );
1582
+ return;
1583
+ }
1559
1584
  await runAnalyticsMigrations(nitroApp);
1560
1585
  try {
1561
1586
  if (await repairPersistedFirstPartyDashboardQueries()) {
@@ -0,0 +1,5 @@
1
+ import { registerAnalyticsEmails } from "../lib/emails";
2
+
3
+ export default function registerTransactionalEmails(): void {
4
+ registerAnalyticsEmails();
5
+ }
@@ -36,7 +36,7 @@ function formatTimeValue(value: string) {
36
36
  const period = hourValue >= 12 ? "PM" : "AM";
37
37
  const hour = hourValue % 12 || 12;
38
38
  return minuteValue === 0
39
- ? `${hour} ${period}`
39
+ ? `${hour}:00 ${period}`
40
40
  : `${hour}:${String(minuteValue).padStart(2, "0")} ${period}`;
41
41
  }
42
42
 
@@ -118,12 +118,17 @@ export function TimePickerPopover({
118
118
  }}
119
119
  >
120
120
  <span>{formatTimeValue(option)}</span>
121
- {getOptionMeta?.(option) && (
122
- <span className="text-muted-foreground">
123
- {getOptionMeta(option)}
121
+ <span className="ml-3 flex min-w-0 items-center gap-2 text-muted-foreground">
122
+ {getOptionMeta?.(option) && (
123
+ <span className="truncate">{getOptionMeta(option)}</span>
124
+ )}
125
+ <span
126
+ aria-hidden="true"
127
+ className="flex size-3.5 shrink-0 items-center justify-center"
128
+ >
129
+ {selected && <IconCheck className="size-3.5" />}
124
130
  </span>
125
- )}
126
- {selected && <IconCheck className="ml-2 size-3.5 shrink-0" />}
131
+ </span>
127
132
  </button>
128
133
  );
129
134
  })}
@@ -338,7 +343,12 @@ export function RepeatPicker({
338
343
  <span>{option.label}</span>
339
344
  <span className="ml-3 flex items-center gap-2 text-muted-foreground">
340
345
  {option.meta}
341
- {selected && <IconCheck className="size-3.5 shrink-0" />}
346
+ <span
347
+ aria-hidden="true"
348
+ className="flex size-3.5 shrink-0 items-center justify-center"
349
+ >
350
+ {selected && <IconCheck className="size-3.5" />}
351
+ </span>
342
352
  </span>
343
353
  </button>
344
354
  );
@@ -11,6 +11,12 @@ import {
11
11
  DEFAULT_BOOKING_TIMEZONE,
12
12
  safeBookingTimeZone,
13
13
  } from "./booking-timezone.js";
14
+ import {
15
+ CALENDAR_BOOKING_CANCELLED_EMAIL_ID,
16
+ CALENDAR_BOOKING_CANCELLED_HOST_EMAIL_ID,
17
+ CALENDAR_BOOKING_CONFIRMED_EMAIL_ID,
18
+ CALENDAR_BOOKING_RECEIVED_EMAIL_ID,
19
+ } from "./emails.js";
14
20
 
15
21
  function stripCrlf(value: string | undefined): string {
16
22
  return (value ?? "").replace(/[\r\n]+/g, " ").trim();
@@ -57,6 +63,128 @@ async function sendBestEffort(
57
63
  }
58
64
  }
59
65
 
66
+ export function renderBookingConfirmedEmail({
67
+ title,
68
+ when,
69
+ host,
70
+ manageUrl,
71
+ meetingLink,
72
+ }: {
73
+ title: string;
74
+ when: string;
75
+ host: string;
76
+ manageUrl: string;
77
+ meetingLink?: string | null;
78
+ }) {
79
+ const paragraphs = [
80
+ `You're booked for ${emailStrong(title)} with ${emailStrong(host)}.`,
81
+ `Time: ${emailStrong(when)}.`,
82
+ ];
83
+ if (meetingLink) {
84
+ paragraphs.push(`Meeting link: ${emailLink("Join meeting", meetingLink)}.`);
85
+ }
86
+
87
+ return {
88
+ subject: `Confirmed: ${title}`,
89
+ ...renderEmail({
90
+ preheader: `You're booked for ${title} on ${when}.`,
91
+ heading: "Your meeting is booked",
92
+ paragraphs,
93
+ cta: { label: "Manage booking", url: manageUrl },
94
+ footer:
95
+ "Use the manage link if you need to cancel or reschedule this meeting.",
96
+ }),
97
+ };
98
+ }
99
+
100
+ export function renderBookingReceivedEmail({
101
+ title,
102
+ when,
103
+ attendeeName,
104
+ attendee,
105
+ manageUrl,
106
+ meetingLink,
107
+ }: {
108
+ title: string;
109
+ when: string;
110
+ attendeeName: string;
111
+ attendee: string;
112
+ manageUrl: string;
113
+ meetingLink?: string | null;
114
+ }) {
115
+ return {
116
+ subject: `New booking: ${title}`,
117
+ ...renderEmail({
118
+ preheader: `${attendeeName} booked ${title} on ${when}.`,
119
+ heading: "New booking",
120
+ paragraphs: [
121
+ `${emailStrong(attendeeName)} booked ${emailStrong(title)}.`,
122
+ `Time: ${emailStrong(when)}.`,
123
+ `Guest: ${emailStrong(attendee)}.`,
124
+ ...(meetingLink
125
+ ? [`Meeting link: ${emailLink("Join meeting", meetingLink)}.`]
126
+ : []),
127
+ ],
128
+ cta: { label: "View booking", url: manageUrl },
129
+ footer: "This booking was created from your calendar booking link.",
130
+ }),
131
+ };
132
+ }
133
+
134
+ export function renderBookingCancelledEmail({
135
+ title,
136
+ when,
137
+ host,
138
+ bookAgainUrl,
139
+ }: {
140
+ title: string;
141
+ when: string;
142
+ host: string;
143
+ bookAgainUrl?: string;
144
+ }) {
145
+ return {
146
+ subject: `Cancelled: ${title}`,
147
+ ...renderEmail({
148
+ preheader: `${title} on ${when} was cancelled.`,
149
+ heading: "Your meeting was cancelled",
150
+ paragraphs: [
151
+ `${emailStrong(title)} with ${emailStrong(host || "the host")} has been cancelled.`,
152
+ `Original time: ${emailStrong(when)}.`,
153
+ ],
154
+ cta: bookAgainUrl
155
+ ? { label: "Book another time", url: bookAgainUrl }
156
+ : undefined,
157
+ footer: "If this was unexpected, contact the meeting host.",
158
+ }),
159
+ };
160
+ }
161
+
162
+ export function renderBookingCancelledHostEmail({
163
+ title,
164
+ when,
165
+ attendeeName,
166
+ attendee,
167
+ }: {
168
+ title: string;
169
+ when: string;
170
+ attendeeName: string;
171
+ attendee: string;
172
+ }) {
173
+ return {
174
+ subject: `Cancelled booking: ${title}`,
175
+ ...renderEmail({
176
+ preheader: `${attendeeName}'s booking for ${title} was cancelled.`,
177
+ heading: "Booking cancelled",
178
+ paragraphs: [
179
+ `${emailStrong(attendeeName)}'s booking for ${emailStrong(title)} was cancelled.`,
180
+ `Original time: ${emailStrong(when)}.`,
181
+ `Guest: ${emailStrong(attendee)}.`,
182
+ ],
183
+ footer: "No further action is needed.",
184
+ }),
185
+ };
186
+ }
187
+
60
188
  export async function sendBookingConfirmationEmails({
61
189
  booking,
62
190
  hostEmail,
@@ -74,54 +202,31 @@ export async function sendBookingConfirmationEmails({
74
202
  const attendee = stripCrlf(booking.email);
75
203
  const attendeeName = stripCrlf(booking.name) || "there";
76
204
 
77
- const attendeeParagraphs = [
78
- `You're booked for ${emailStrong(title)} with ${emailStrong(host)}.`,
79
- `Time: ${emailStrong(when)}.`,
80
- ];
81
- if (booking.meetingLink) {
82
- attendeeParagraphs.push(
83
- `Meeting link: ${emailLink("Join meeting", booking.meetingLink)}.`,
84
- );
85
- }
86
-
87
- const attendeeEmail = renderEmail({
88
- preheader: `You're booked for ${title} on ${when}.`,
89
- heading: "Your meeting is booked",
90
- paragraphs: attendeeParagraphs,
91
- cta: { label: "Manage booking", url: manageUrl },
92
- footer:
93
- "Use the manage link if you need to cancel or reschedule this meeting.",
94
- });
95
-
96
205
  await sendBestEffort("attendee confirmation", {
97
206
  to: attendee,
98
- subject: `Confirmed: ${title}`,
99
- html: attendeeEmail.html,
100
- text: attendeeEmail.text,
207
+ ...renderBookingConfirmedEmail({
208
+ title,
209
+ when,
210
+ host,
211
+ manageUrl,
212
+ meetingLink: booking.meetingLink,
213
+ }),
101
214
  replyTo: host,
102
- });
103
-
104
- const hostEmailMessage = renderEmail({
105
- preheader: `${attendeeName} booked ${title} on ${when}.`,
106
- heading: "New booking",
107
- paragraphs: [
108
- `${emailStrong(attendeeName)} booked ${emailStrong(title)}.`,
109
- `Time: ${emailStrong(when)}.`,
110
- `Guest: ${emailStrong(attendee)}.`,
111
- ...(booking.meetingLink
112
- ? [`Meeting link: ${emailLink("Join meeting", booking.meetingLink)}.`]
113
- : []),
114
- ],
115
- cta: { label: "View booking", url: manageUrl },
116
- footer: "This booking was created from your calendar booking link.",
215
+ templateId: CALENDAR_BOOKING_CONFIRMED_EMAIL_ID,
117
216
  });
118
217
 
119
218
  await sendBestEffort("host notification", {
120
219
  to: host,
121
- subject: `New booking: ${title}`,
122
- html: hostEmailMessage.html,
123
- text: hostEmailMessage.text,
220
+ ...renderBookingReceivedEmail({
221
+ title,
222
+ when,
223
+ attendeeName,
224
+ attendee,
225
+ manageUrl,
226
+ meetingLink: booking.meetingLink,
227
+ }),
124
228
  replyTo: attendee,
229
+ templateId: CALENDAR_BOOKING_RECEIVED_EMAIL_ID,
125
230
  });
126
231
  }
127
232
 
@@ -142,45 +247,24 @@ export async function sendBookingCancellationEmails({
142
247
  const attendee = stripCrlf(booking.email);
143
248
  const attendeeName = stripCrlf(booking.name) || "The guest";
144
249
 
145
- const attendeeEmail = renderEmail({
146
- preheader: `${title} on ${when} was cancelled.`,
147
- heading: "Your meeting was cancelled",
148
- paragraphs: [
149
- `${emailStrong(title)} with ${emailStrong(host || "the host")} has been cancelled.`,
150
- `Original time: ${emailStrong(when)}.`,
151
- ],
152
- cta: bookAgainUrl
153
- ? { label: "Book another time", url: bookAgainUrl }
154
- : undefined,
155
- footer: "If this was unexpected, contact the meeting host.",
156
- });
157
-
158
250
  await sendBestEffort("attendee cancellation", {
159
251
  to: attendee,
160
- subject: `Cancelled: ${title}`,
161
- html: attendeeEmail.html,
162
- text: attendeeEmail.text,
252
+ ...renderBookingCancelledEmail({ title, when, host, bookAgainUrl }),
163
253
  replyTo: host || undefined,
254
+ templateId: CALENDAR_BOOKING_CANCELLED_EMAIL_ID,
164
255
  });
165
256
 
166
257
  if (!host) return;
167
258
 
168
- const hostEmailMessage = renderEmail({
169
- preheader: `${attendeeName}'s booking for ${title} was cancelled.`,
170
- heading: "Booking cancelled",
171
- paragraphs: [
172
- `${emailStrong(attendeeName)}'s booking for ${emailStrong(title)} was cancelled.`,
173
- `Original time: ${emailStrong(when)}.`,
174
- `Guest: ${emailStrong(attendee)}.`,
175
- ],
176
- footer: "No further action is needed.",
177
- });
178
-
179
259
  await sendBestEffort("host cancellation notification", {
180
260
  to: host,
181
- subject: `Cancelled booking: ${title}`,
182
- html: hostEmailMessage.html,
183
- text: hostEmailMessage.text,
261
+ ...renderBookingCancelledHostEmail({
262
+ title,
263
+ when,
264
+ attendeeName,
265
+ attendee,
266
+ }),
184
267
  replyTo: attendee,
268
+ templateId: CALENDAR_BOOKING_CANCELLED_HOST_EMAIL_ID,
185
269
  });
186
270
  }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Catalog entries for the transactional emails Calendar sends.
3
+ *
4
+ * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list
5
+ * and preview them without the app having sent anything yet.
6
+ */
7
+
8
+ import { defineTransactionalEmail } from "@agent-native/core/email-catalog";
9
+
10
+ import {
11
+ renderBookingCancelledEmail,
12
+ renderBookingCancelledHostEmail,
13
+ renderBookingConfirmedEmail,
14
+ renderBookingReceivedEmail,
15
+ } from "./booking-emails.js";
16
+ import { renderEventGuestNote } from "./event-guest-notifications.js";
17
+
18
+ /** Obviously-fake sample data — these render in a preview pane, never send. */
19
+ const SAMPLE_TITLE = "Intro call";
20
+ const SAMPLE_WHEN = "Tuesday, March 4, 2025, 10:00 AM - 10:30 AM PST";
21
+ const SAMPLE_HOST = "dana.hill@example.com";
22
+ const SAMPLE_GUEST = "sam.rivera@example.com";
23
+ const SAMPLE_GUEST_NAME = "Sam Rivera";
24
+ const SAMPLE_MANAGE_URL = "https://example.com/book/dana/manage/sample-token";
25
+ const SAMPLE_BOOK_AGAIN_URL = "https://example.com/book/dana/intro-call";
26
+ const SAMPLE_MEETING_LINK = "https://meet.example.com/sample-intro-call";
27
+
28
+ export const CALENDAR_BOOKING_CONFIRMED_EMAIL_ID = "calendar.booking-confirmed";
29
+ export const CALENDAR_BOOKING_RECEIVED_EMAIL_ID = "calendar.booking-received";
30
+ export const CALENDAR_BOOKING_CANCELLED_EMAIL_ID = "calendar.booking-cancelled";
31
+ export const CALENDAR_BOOKING_CANCELLED_HOST_EMAIL_ID =
32
+ "calendar.booking-cancelled-host";
33
+ export const CALENDAR_EVENT_UPDATE_NOTE_EMAIL_ID = "calendar.event-update-note";
34
+ export const CALENDAR_EVENT_CANCELLATION_NOTE_EMAIL_ID =
35
+ "calendar.event-cancellation-note";
36
+
37
+ let registered = false;
38
+
39
+ export function registerCalendarEmails(): void {
40
+ if (registered) return;
41
+ registered = true;
42
+
43
+ defineTransactionalEmail({
44
+ id: CALENDAR_BOOKING_CONFIRMED_EMAIL_ID,
45
+ name: "Booking confirmed (guest)",
46
+ trigger:
47
+ "A guest completes a booking on a public `/book/{username}/{slug}` page and the booking row is created.",
48
+ recipientLabel: "Booking guest",
49
+ recipient:
50
+ "The email address the guest typed into the booking form (`booking.email`).",
51
+ senderLabel: "Default, reply-to host",
52
+ sender:
53
+ "The configured EMAIL_FROM, with reply-to set to the booking link owner's address.",
54
+ preview: () =>
55
+ renderBookingConfirmedEmail({
56
+ title: SAMPLE_TITLE,
57
+ when: SAMPLE_WHEN,
58
+ host: SAMPLE_HOST,
59
+ manageUrl: SAMPLE_MANAGE_URL,
60
+ meetingLink: SAMPLE_MEETING_LINK,
61
+ }),
62
+ });
63
+
64
+ defineTransactionalEmail({
65
+ id: CALENDAR_BOOKING_RECEIVED_EMAIL_ID,
66
+ name: "New booking (host)",
67
+ trigger:
68
+ "Sent alongside the guest confirmation, immediately after a public booking is created.",
69
+ recipientLabel: "Booking link owner",
70
+ recipient:
71
+ "The owner of the booking link, looked up from the link's slug at send time.",
72
+ senderLabel: "Default, reply-to guest",
73
+ sender:
74
+ "The configured EMAIL_FROM, with reply-to set to the guest so the host can reply directly.",
75
+ preview: () =>
76
+ renderBookingReceivedEmail({
77
+ title: SAMPLE_TITLE,
78
+ when: SAMPLE_WHEN,
79
+ attendeeName: SAMPLE_GUEST_NAME,
80
+ attendee: SAMPLE_GUEST,
81
+ manageUrl: SAMPLE_MANAGE_URL,
82
+ meetingLink: SAMPLE_MEETING_LINK,
83
+ }),
84
+ });
85
+
86
+ defineTransactionalEmail({
87
+ id: CALENDAR_BOOKING_CANCELLED_EMAIL_ID,
88
+ name: "Booking cancelled (guest)",
89
+ trigger:
90
+ "A booking that was not already cancelled is cancelled, either by the guest through the manage/cancel token link or by someone with access to the booking link.",
91
+ recipientLabel: "Booking guest",
92
+ recipient: "The guest address stored on the booking row.",
93
+ senderLabel: "Default, reply-to host",
94
+ sender:
95
+ "The configured EMAIL_FROM, with reply-to set to the host when a host address could be resolved from the link slug.",
96
+ preview: () =>
97
+ renderBookingCancelledEmail({
98
+ title: SAMPLE_TITLE,
99
+ when: SAMPLE_WHEN,
100
+ host: SAMPLE_HOST,
101
+ bookAgainUrl: SAMPLE_BOOK_AGAIN_URL,
102
+ }),
103
+ });
104
+
105
+ defineTransactionalEmail({
106
+ id: CALENDAR_BOOKING_CANCELLED_HOST_EMAIL_ID,
107
+ name: "Booking cancelled (host)",
108
+ trigger:
109
+ "Sent after the guest cancellation notice, and only when a host address could be resolved from the booking link slug.",
110
+ recipientLabel: "Booking link owner",
111
+ recipient: "The booking link owner's address.",
112
+ senderLabel: "Default, reply-to guest",
113
+ sender:
114
+ "The configured EMAIL_FROM, with reply-to set to the guest who was booked.",
115
+ preview: () =>
116
+ renderBookingCancelledHostEmail({
117
+ title: SAMPLE_TITLE,
118
+ when: SAMPLE_WHEN,
119
+ attendeeName: SAMPLE_GUEST_NAME,
120
+ attendee: SAMPLE_GUEST,
121
+ }),
122
+ });
123
+
124
+ defineTransactionalEmail({
125
+ id: CALENDAR_EVENT_UPDATE_NOTE_EMAIL_ID,
126
+ name: "Event update note",
127
+ trigger:
128
+ "`update-event` runs with a non-empty guest notification message. Google Calendar still sends its own update invite; this carries only the organizer's note.",
129
+ recipientLabel: "Event attendees",
130
+ recipient:
131
+ "Every attendee on the event with a syntactically valid address, excluding the organizer's own `self` attendee row. One email per address.",
132
+ senderLabel: "Default, reply-to organizer",
133
+ sender:
134
+ "The configured EMAIL_FROM, with reply-to set to the organizer running the update.",
135
+ preview: () =>
136
+ renderEventGuestNote({
137
+ title: "Design review",
138
+ organizer: "Dana Hill",
139
+ message: "Moving this an hour later so the whole team can join.",
140
+ when: SAMPLE_WHEN,
141
+ kind: "update",
142
+ htmlLink: "https://calendar.example.com/event/sample",
143
+ }),
144
+ });
145
+
146
+ defineTransactionalEmail({
147
+ id: CALENDAR_EVENT_CANCELLATION_NOTE_EMAIL_ID,
148
+ name: "Event cancellation note",
149
+ trigger:
150
+ "`delete-event` runs with guest notification requested and a non-empty message. Google Calendar sends the cancellation itself; this carries only the organizer's note.",
151
+ recipientLabel: "Event attendees",
152
+ recipient:
153
+ "Every attendee on the deleted event with a syntactically valid address, excluding the organizer's own `self` attendee row. One email per address.",
154
+ senderLabel: "Default, reply-to organizer",
155
+ sender:
156
+ "The configured EMAIL_FROM, with reply-to set to the organizer running the deletion.",
157
+ preview: () =>
158
+ renderEventGuestNote({
159
+ title: "Design review",
160
+ organizer: "Dana Hill",
161
+ message: "Cancelling this week — we will pick it up after the launch.",
162
+ when: SAMPLE_WHEN,
163
+ kind: "cancellation",
164
+ appliesTo: "all events in the series",
165
+ }),
166
+ });
167
+ }