@agent-native/core 0.141.3 → 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 (152) 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-fixed-sign-in-and-dashboard-requests-failing-when-background.md +6 -0
  6. package/corpus/templates/analytics/changelog/2026-08-05-historical-rollups-stay-complete-while-live-events-arrive.md +6 -0
  7. package/corpus/templates/analytics/server/db/schema.ts +1 -0
  8. package/corpus/templates/analytics/server/lib/dashboard-report.ts +2 -0
  9. package/corpus/templates/analytics/server/lib/emails.ts +51 -0
  10. package/corpus/templates/analytics/server/lib/first-party-analytics-health.ts +8 -12
  11. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -0
  12. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +262 -13
  13. package/corpus/templates/analytics/server/lib/gong.ts +132 -33
  14. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
  15. package/corpus/templates/analytics/server/plugins/auth.ts +21 -2
  16. package/corpus/templates/analytics/server/plugins/db.ts +11 -3
  17. package/corpus/templates/analytics/server/plugins/transactional-emails.ts +5 -0
  18. package/corpus/templates/calendar/app/components/calendar/InlineEventPickers.tsx +17 -7
  19. package/corpus/templates/calendar/server/lib/booking-emails.ts +154 -70
  20. package/corpus/templates/calendar/server/lib/emails.ts +167 -0
  21. package/corpus/templates/calendar/server/lib/event-guest-notifications.ts +64 -28
  22. package/corpus/templates/calendar/server/plugins/transactional-emails.ts +5 -0
  23. package/corpus/templates/clips/actions/invite-member.ts +39 -16
  24. package/corpus/templates/clips/app/components/player/share-dialog.tsx +73 -1
  25. package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
  26. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +10 -25
  27. package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
  28. package/corpus/templates/clips/changelog/2026-08-05-activity-is-now-the-default-clips-recording-tab-and-ai-setup.md +6 -0
  29. package/corpus/templates/clips/changelog/2026-08-05-clips-can-copy-a-linked-email-preview-for-public-recordings.md +6 -0
  30. package/corpus/templates/clips/changelog/2026-08-05-preparing-recording-now-uses-a-clean-shadow-free-status-card.md +6 -0
  31. package/corpus/templates/clips/desktop/src/styles.css +0 -3
  32. package/corpus/templates/clips/server/lib/emails.ts +256 -0
  33. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +24 -0
  34. package/corpus/templates/clips/server/plugins/transactional-email-catalog.ts +5 -0
  35. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +4 -1
  36. package/corpus/templates/clips/shared/email-preview.ts +61 -0
  37. package/corpus/templates/content/server/lib/comment-notifications.ts +57 -26
  38. package/corpus/templates/content/server/lib/emails.ts +70 -0
  39. package/corpus/templates/content/server/plugins/transactional-emails.ts +5 -0
  40. package/corpus/templates/design/actions/import-design-tokens.ts +11 -8
  41. package/corpus/templates/design/actions/index-design-tokens.ts +13 -6
  42. package/corpus/templates/design/changelog/2026-08-05-design-token-imports-classify-typography-dimensions-correctly.md +6 -0
  43. package/corpus/templates/dispatch/app/routes/transactional-email.$appId.$id.tsx +4 -0
  44. package/corpus/templates/dispatch/app/routes/transactional-email.tsx +4 -0
  45. package/corpus/templates/dispatch/changelog/2026-08-05-transactional-email-catalogs-and-delivery-activity-are-available-again.md +6 -0
  46. package/corpus/templates/factory/AGENTS.md +18 -17
  47. package/corpus/templates/factory/actions/get-factory-automation-health.ts +50 -0
  48. package/corpus/templates/factory/actions/get-triage-config.ts +8 -0
  49. package/corpus/templates/factory/actions/save-triage-config.ts +26 -0
  50. package/corpus/templates/factory/app/i18n/en-US.ts +27 -0
  51. package/corpus/templates/factory/app/routes/factory.tsx +376 -206
  52. package/corpus/templates/factory/changelog/2026-08-05-factory-now-keeps-production-automations-running-shows-sched.md +6 -0
  53. package/corpus/templates/factory/server/db/schema.ts +6 -0
  54. package/corpus/templates/factory/server/plugins/factory-migrations.ts +10 -0
  55. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +141 -2
  56. package/corpus/templates/forms/app/pages/FormsListPage.tsx +41 -21
  57. package/corpus/templates/forms/changelog/2026-08-05-form-links-can-now-be-cmd-clicked-to-open-in-a-new-tab.md +6 -0
  58. package/corpus/templates/forms/server/lib/emails.ts +52 -0
  59. package/corpus/templates/forms/server/lib/response-email.ts +2 -0
  60. package/corpus/templates/forms/server/plugins/transactional-emails.ts +5 -0
  61. package/corpus/templates/plan/actions/request-plan-access.ts +33 -12
  62. package/corpus/templates/plan/server/lib/comment-notifications.ts +53 -27
  63. package/corpus/templates/plan/server/lib/emails.ts +67 -0
  64. package/corpus/templates/plan/server/plugins/transactional-emails.ts +5 -0
  65. package/corpus/templates/slides/actions/import-from-url.ts +1 -0
  66. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +492 -381
  67. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +8 -7
  68. package/corpus/templates/slides/app/components/layout/Layout.tsx +27 -23
  69. package/corpus/templates/slides/app/components/onboarding/FirstDeckOnboardingFlow.tsx +313 -0
  70. package/corpus/templates/slides/app/components/ui/command.tsx +1 -0
  71. package/corpus/templates/slides/app/i18n/en-US.ts +8 -2
  72. package/corpus/templates/slides/app/lib/create-deck-generation.ts +305 -0
  73. package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -12
  74. package/corpus/templates/slides/app/pages/Index.tsx +127 -137
  75. package/corpus/templates/slides/app/root.tsx +8 -0
  76. package/corpus/templates/slides/changelog/2026-08-05-deck-browsing-keeps-all-and-mine-in-the-header-with-a-tighte.md +6 -0
  77. package/corpus/templates/slides/changelog/2026-08-05-new-deck-creation-now-flows-through-a-clean-full-screen-refe.md +6 -0
  78. package/corpus/templates/slides/changelog/2026-08-05-website-imports-now-include-linked-stylesheets-for-more-accu.md +6 -0
  79. package/corpus/templates/slides/server/lib/comment-notifications.ts +44 -21
  80. package/corpus/templates/slides/server/lib/emails.ts +40 -0
  81. package/corpus/templates/slides/server/plugins/transactional-emails.ts +5 -0
  82. package/dist/brand-kit/index.d.ts +1 -0
  83. package/dist/brand-kit/index.js +1 -0
  84. package/dist/brand-kit/tokens.d.ts +91 -0
  85. package/dist/brand-kit/tokens.js +279 -0
  86. package/dist/brand-kit/types.d.ts +25 -0
  87. package/dist/client/AssistantChat.js +7 -7
  88. package/dist/client/onboarding/FirstRunOnboarding.js +26 -2
  89. package/dist/client/onboarding/first-run-registry.d.ts +25 -0
  90. package/dist/client/onboarding/first-run-registry.js +18 -0
  91. package/dist/client/onboarding/index.d.ts +2 -0
  92. package/dist/client/onboarding/index.js +1 -0
  93. package/dist/collab/routes.d.ts +1 -1
  94. package/dist/deploy/build.d.ts +8 -0
  95. package/dist/deploy/build.js +92 -3
  96. package/dist/deploy/workspace-deploy.js +86 -3
  97. package/dist/email-catalog/actions/list-email-activity.d.ts +5 -0
  98. package/dist/email-catalog/actions/list-email-activity.js +19 -0
  99. package/dist/email-catalog/actions/list-email-engagement.d.ts +5 -0
  100. package/dist/email-catalog/actions/list-email-engagement.js +15 -0
  101. package/dist/email-catalog/actions/list-email-log.d.ts +7 -0
  102. package/dist/email-catalog/actions/list-email-log.js +23 -0
  103. package/dist/email-catalog/actions/list-transactional-emails.d.ts +22 -0
  104. package/dist/email-catalog/actions/list-transactional-emails.js +67 -0
  105. package/dist/email-catalog/actions/render-transactional-email-preview.d.ts +9 -0
  106. package/dist/email-catalog/actions/render-transactional-email-preview.js +25 -0
  107. package/dist/email-catalog/authorize.d.ts +1 -0
  108. package/dist/email-catalog/authorize.js +9 -0
  109. package/dist/email-catalog/log.d.ts +60 -0
  110. package/dist/email-catalog/log.js +142 -0
  111. package/dist/email-catalog/provider-metrics.d.ts +34 -0
  112. package/dist/email-catalog/provider-metrics.js +182 -0
  113. package/dist/email-catalog/registry.d.ts +78 -0
  114. package/dist/email-catalog/registry.js +70 -0
  115. package/dist/email-catalog/schema.d.ts +231 -0
  116. package/dist/email-catalog/schema.js +53 -0
  117. package/dist/email-catalog/system-emails.d.ts +13 -0
  118. package/dist/email-catalog/system-emails.js +67 -0
  119. package/dist/jobs/index.d.ts +2 -0
  120. package/dist/jobs/index.js +2 -0
  121. package/dist/jobs/run-history.js +48 -1
  122. package/dist/jobs/scheduler-dispatch.d.ts +7 -0
  123. package/dist/jobs/scheduler-dispatch.js +7 -0
  124. package/dist/jobs/scheduler-health.d.ts +22 -0
  125. package/dist/jobs/scheduler-health.js +172 -0
  126. package/dist/jobs/scheduler.js +67 -2
  127. package/dist/localization/default-messages.d.ts +45 -0
  128. package/dist/localization/default-messages.js +45 -0
  129. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  130. package/dist/notifications/routes.d.ts +3 -3
  131. package/dist/observability/routes.d.ts +5 -5
  132. package/dist/org/handlers.js +9 -1
  133. package/dist/progress/routes.d.ts +1 -1
  134. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  135. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  136. package/dist/secrets/routes.d.ts +9 -9
  137. package/dist/server/action-discovery.js +22 -0
  138. package/dist/server/agent-chat/recurring-jobs-runtime.d.ts +7 -1
  139. package/dist/server/agent-chat/recurring-jobs-runtime.js +12 -0
  140. package/dist/server/agent-chat-plugin.d.ts +2 -1
  141. package/dist/server/agent-chat-plugin.js +39 -1
  142. package/dist/server/better-auth-instance.js +17 -2
  143. package/dist/server/design-token-utils.d.ts +6 -1
  144. package/dist/server/design-token-utils.js +252 -59
  145. package/dist/server/email.d.ts +11 -0
  146. package/dist/server/email.js +54 -3
  147. package/dist/server/index.d.ts +1 -0
  148. package/dist/server/index.js +1 -0
  149. package/dist/server/realtime-token.d.ts +1 -1
  150. package/dist/server/transcribe-voice.d.ts +1 -1
  151. package/dist/vite/client.js +20 -14
  152. package/package.json +2 -1
@@ -12,6 +12,7 @@ import { isInBackgroundFunctionRuntime } from "@agent-native/core/server";
12
12
  // startup so the dashboard / analysis share actions know where to dispatch.
13
13
  import "../db/index.js";
14
14
  import * as schema from "../db/schema.js";
15
+ import { FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY } from "../lib/first-party-analytics-rollups.js";
15
16
  import { repairPersistedFirstPartyDashboardQueries } from "../lib/first-party-dashboard-repair.js";
16
17
 
17
18
  /**
@@ -169,10 +170,11 @@ async function runHistoricalAnalyticsRollupBackfill() {
169
170
  // connections while waiting for the winner. A lock loser defers without
170
171
  // recording the migration so a failed winner can be retried safely.
171
172
  // The monotonic rollup upsert below keeps a live increment from being
172
- // overwritten if ingest commits during the historical snapshot.
173
+ // overwritten if ingest commits during the historical snapshot. Live
174
+ // ingest takes this same lock before inserting its raw event.
173
175
  const lockResult = await tx.execute({
174
176
  sql: "SELECT pg_try_advisory_xact_lock(hashtextextended(?, 0::bigint)) AS acquired",
175
- args: ["agent-native:analytics-rollup-backfill"],
177
+ args: [FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY],
176
178
  });
177
179
  const acquired = lockResult.rows[0]?.acquired;
178
180
  if (acquired !== true && acquired !== "t") {
@@ -1570,10 +1572,16 @@ const runAnalyticsMigrations = runMigrations(
1570
1572
  * swallowed so it can never fail boot.
1571
1573
  */
1572
1574
  export default async (nitroApp: any): Promise<void> => {
1573
- await runAnalyticsMigrations(nitroApp);
1574
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
+ );
1575
1582
  return;
1576
1583
  }
1584
+ await runAnalyticsMigrations(nitroApp);
1577
1585
  try {
1578
1586
  if (await repairPersistedFirstPartyDashboardQueries()) {
1579
1587
  console.info(
@@ -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
+ }
@@ -6,6 +6,10 @@ import {
6
6
  } from "@agent-native/core/server";
7
7
 
8
8
  import type { CalendarEvent, DeleteEventScope } from "../../shared/api.js";
9
+ import {
10
+ CALENDAR_EVENT_CANCELLATION_NOTE_EMAIL_ID,
11
+ CALENDAR_EVENT_UPDATE_NOTE_EMAIL_ID,
12
+ } from "./emails.js";
9
13
 
10
14
  export interface GuestNotificationResult {
11
15
  requested: boolean;
@@ -96,6 +100,53 @@ function scopeLabel(scope: DeleteEventScope | undefined): string | undefined {
96
100
  return "this and following events";
97
101
  }
98
102
 
103
+ export function renderEventGuestNote({
104
+ title,
105
+ organizer,
106
+ message,
107
+ when,
108
+ kind,
109
+ appliesTo,
110
+ htmlLink,
111
+ }: {
112
+ title: string;
113
+ organizer: string;
114
+ message: string;
115
+ when: string;
116
+ kind: GuestNotificationKind;
117
+ appliesTo?: string;
118
+ htmlLink?: string | null;
119
+ }) {
120
+ const heading =
121
+ kind === "cancellation" ? "Event cancellation note" : "Event update note";
122
+ const subjectPrefix =
123
+ kind === "cancellation" ? "Cancellation note" : "Update note";
124
+ const eventAction = kind === "cancellation" ? "cancelling" : "updating";
125
+ const paragraphs = [
126
+ `${emailStrong(organizer)} added this note while ${eventAction} ${emailStrong(title)}.`,
127
+ messageParagraph(message),
128
+ `When: ${emailStrong(when)}.`,
129
+ ];
130
+ if (appliesTo) {
131
+ paragraphs.push(`Applies to: ${emailStrong(appliesTo)}.`);
132
+ }
133
+
134
+ return {
135
+ subject: `${subjectPrefix}: ${title}`,
136
+ ...renderEmail({
137
+ preheader: `${subjectPrefix}: ${title}`,
138
+ heading,
139
+ paragraphs,
140
+ cta:
141
+ kind === "update" && htmlLink
142
+ ? { label: "Open in Google Calendar", url: htmlLink }
143
+ : undefined,
144
+ footer:
145
+ "Google Calendar sends the calendar update separately. This message carries the organizer note.",
146
+ }),
147
+ };
148
+ }
149
+
99
150
  export async function sendEventGuestNotificationNote({
100
151
  event,
101
152
  organizerEmail,
@@ -138,33 +189,14 @@ export async function sendEventGuestNotificationNote({
138
189
  };
139
190
  }
140
191
 
141
- const title = stripCrlf(event.title) || "Calendar event";
142
- const organizer = stripCrlf(event.organizer?.displayName) || organizerEmail;
143
- const heading =
144
- kind === "cancellation" ? "Event cancellation note" : "Event update note";
145
- const subjectPrefix =
146
- kind === "cancellation" ? "Cancellation note" : "Update note";
147
- const eventAction = kind === "cancellation" ? "cancelling" : "updating";
148
- const paragraphs = [
149
- `${emailStrong(organizer)} added this note while ${eventAction} ${emailStrong(title)}.`,
150
- messageParagraph(normalizedMessage),
151
- `When: ${emailStrong(formatWhen(event))}.`,
152
- ];
153
- const appliesTo = scopeLabel(scope);
154
- if (appliesTo) {
155
- paragraphs.push(`Applies to: ${emailStrong(appliesTo)}.`);
156
- }
157
-
158
- const rendered = renderEmail({
159
- preheader: `${subjectPrefix}: ${title}`,
160
- heading,
161
- paragraphs,
162
- cta:
163
- kind === "update" && event.htmlLink
164
- ? { label: "Open in Google Calendar", url: event.htmlLink }
165
- : undefined,
166
- footer:
167
- "Google Calendar sends the calendar update separately. This message carries the organizer note.",
192
+ const rendered = renderEventGuestNote({
193
+ title: stripCrlf(event.title) || "Calendar event",
194
+ organizer: stripCrlf(event.organizer?.displayName) || organizerEmail,
195
+ message: normalizedMessage,
196
+ when: formatWhen(event),
197
+ kind,
198
+ appliesTo: scopeLabel(scope),
199
+ htmlLink: event.htmlLink,
168
200
  });
169
201
 
170
202
  let sentCount = 0;
@@ -174,10 +206,14 @@ export async function sendEventGuestNotificationNote({
174
206
  try {
175
207
  await sendEmail({
176
208
  to,
177
- subject: `${subjectPrefix}: ${title}`,
209
+ subject: rendered.subject,
178
210
  html: rendered.html,
179
211
  text: rendered.text,
180
212
  replyTo: organizerEmail,
213
+ templateId:
214
+ kind === "cancellation"
215
+ ? CALENDAR_EVENT_CANCELLATION_NOTE_EMAIL_ID
216
+ : CALENDAR_EVENT_UPDATE_NOTE_EMAIL_ID,
181
217
  });
182
218
  sentCount += 1;
183
219
  } catch (error) {
@@ -0,0 +1,5 @@
1
+ import { registerCalendarEmails } from "../lib/emails.js";
2
+
3
+ export default function registerTransactionalEmails(): void {
4
+ registerCalendarEmails();
5
+ }