@agent-native/core 0.138.0 → 0.139.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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +2 -0
- package/corpus/templates/analytics/server/lib/emails.ts +51 -0
- package/corpus/templates/analytics/server/plugins/transactional-emails.ts +5 -0
- package/corpus/templates/calendar/server/lib/booking-emails.ts +154 -70
- package/corpus/templates/calendar/server/lib/emails.ts +167 -0
- package/corpus/templates/calendar/server/lib/event-guest-notifications.ts +64 -28
- package/corpus/templates/calendar/server/plugins/transactional-emails.ts +5 -0
- package/corpus/templates/clips/actions/invite-member.ts +39 -15
- package/corpus/templates/clips/server/lib/emails.ts +256 -0
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +24 -0
- package/corpus/templates/clips/server/plugins/transactional-email-catalog.ts +5 -0
- package/corpus/templates/content/server/lib/comment-notifications.ts +57 -26
- package/corpus/templates/content/server/lib/emails.ts +70 -0
- package/corpus/templates/content/server/plugins/transactional-emails.ts +5 -0
- package/corpus/templates/dispatch/app/routes/transactional-email.$appId.$id.tsx +4 -0
- package/corpus/templates/dispatch/app/routes/transactional-email.tsx +4 -0
- package/corpus/templates/forms/server/lib/emails.ts +52 -0
- package/corpus/templates/forms/server/lib/response-email.ts +2 -0
- package/corpus/templates/forms/server/plugins/transactional-emails.ts +5 -0
- package/corpus/templates/plan/actions/request-plan-access.ts +33 -12
- package/corpus/templates/plan/server/lib/comment-notifications.ts +53 -27
- package/corpus/templates/plan/server/lib/emails.ts +67 -0
- package/corpus/templates/plan/server/plugins/transactional-emails.ts +5 -0
- package/corpus/templates/slides/server/lib/comment-notifications.ts +44 -21
- package/corpus/templates/slides/server/lib/emails.ts +40 -0
- package/corpus/templates/slides/server/plugins/transactional-emails.ts +5 -0
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/email-catalog/actions/list-email-activity.d.ts +6 -0
- package/dist/email-catalog/actions/list-email-activity.js +15 -0
- package/dist/email-catalog/actions/list-email-engagement.d.ts +6 -0
- package/dist/email-catalog/actions/list-email-engagement.js +15 -0
- package/dist/email-catalog/actions/list-email-log.d.ts +8 -0
- package/dist/email-catalog/actions/list-email-log.js +22 -0
- package/dist/email-catalog/actions/list-transactional-emails.d.ts +23 -0
- package/dist/email-catalog/actions/list-transactional-emails.js +61 -0
- package/dist/email-catalog/actions/render-transactional-email-preview.d.ts +10 -0
- package/dist/email-catalog/actions/render-transactional-email-preview.js +24 -0
- package/dist/email-catalog/authorize.d.ts +2 -0
- package/dist/email-catalog/authorize.js +10 -0
- package/dist/email-catalog/log.d.ts +57 -0
- package/dist/email-catalog/log.js +117 -0
- package/dist/email-catalog/provider-metrics.d.ts +34 -0
- package/dist/email-catalog/provider-metrics.js +165 -0
- package/dist/email-catalog/registry.d.ts +79 -0
- package/dist/email-catalog/registry.js +71 -0
- package/dist/email-catalog/schema.d.ts +212 -0
- package/dist/email-catalog/schema.js +49 -0
- package/dist/email-catalog/system-emails.d.ts +14 -0
- package/dist/email-catalog/system-emails.js +68 -0
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +45 -0
- package/dist/localization/default-messages.js +45 -0
- package/dist/notifications/routes.d.ts +5 -5
- package/dist/org/handlers.js +8 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +8 -8
- package/dist/provider-api/actions/provider-api.d.ts +12 -12
- package/dist/server/action-discovery.js +22 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/better-auth-instance.js +17 -2
- package/dist/server/email.d.ts +9 -0
- package/dist/server/email.js +44 -3
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/package.json +2 -1
package/corpus/README.md
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type DashboardReportCaptureOutcome,
|
|
19
19
|
type DashboardReportSubscription,
|
|
20
20
|
} from "./dashboard-report-subscriptions";
|
|
21
|
+
import { ANALYTICS_DASHBOARD_REPORT_EMAIL_ID } from "./emails";
|
|
21
22
|
|
|
22
23
|
const DATE_FILTER_TYPES: ReadonlySet<FilterType> = new Set([
|
|
23
24
|
"date",
|
|
@@ -326,6 +327,7 @@ export async function sendDashboardReportSubscription(
|
|
|
326
327
|
? { attachments: rendered.attachments }
|
|
327
328
|
: {}),
|
|
328
329
|
timeoutMs: emailTimeoutMs,
|
|
330
|
+
templateId: ANALYTICS_DASHBOARD_REPORT_EMAIL_ID,
|
|
329
331
|
});
|
|
330
332
|
}
|
|
331
333
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog entries for the transactional emails Analytics 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
|
+
import { emailStrong, renderEmail } from "@agent-native/core/server";
|
|
10
|
+
|
|
11
|
+
export const ANALYTICS_DASHBOARD_REPORT_EMAIL_ID = "analytics.dashboard-report";
|
|
12
|
+
|
|
13
|
+
let registered = false;
|
|
14
|
+
|
|
15
|
+
export function registerAnalyticsEmails(): void {
|
|
16
|
+
if (registered) return;
|
|
17
|
+
registered = true;
|
|
18
|
+
|
|
19
|
+
defineTransactionalEmail({
|
|
20
|
+
id: ANALYTICS_DASHBOARD_REPORT_EMAIL_ID,
|
|
21
|
+
name: "Scheduled dashboard report",
|
|
22
|
+
trigger:
|
|
23
|
+
"A dashboard report subscription comes due and at least one panel produced usable data. A run where every queried panel failed throws instead of mailing, and a degraded run is skipped when the subscription asks for complete reports only.",
|
|
24
|
+
recipientLabel: "Subscription recipients",
|
|
25
|
+
recipient:
|
|
26
|
+
"The normalized recipient list stored on the subscription. One email per address, all carrying the same rendered snapshot.",
|
|
27
|
+
senderLabel: "Default sender",
|
|
28
|
+
sender:
|
|
29
|
+
"The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`.",
|
|
30
|
+
// The real renderer runs every panel query and rasterizes charts, so it
|
|
31
|
+
// cannot back a preview that must stay offline. This shows the frame a
|
|
32
|
+
// recipient sees; the panel body is whatever that run produced.
|
|
33
|
+
preview: () => ({
|
|
34
|
+
subject: "Daily dashboard: Growth overview — 3/4/2025",
|
|
35
|
+
...renderEmail({
|
|
36
|
+
preheader: "Your daily Growth overview report is ready.",
|
|
37
|
+
heading: "Daily dashboard: Growth overview",
|
|
38
|
+
paragraphs: [
|
|
39
|
+
`This report renders each panel of ${emailStrong("Growth overview")} as of the run time, with charts drawn server-side and attached inline.`,
|
|
40
|
+
"Panels that could not be queried are called out in place rather than dropped, so a partial report never reads as a complete one.",
|
|
41
|
+
],
|
|
42
|
+
cta: {
|
|
43
|
+
label: "Open dashboard",
|
|
44
|
+
url: "https://example.com/dashboards/dash_sample",
|
|
45
|
+
},
|
|
46
|
+
footer:
|
|
47
|
+
"You received this because you are on the recipient list for this dashboard report.",
|
|
48
|
+
}),
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
kind
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
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) {
|