Skip to content

Commit b78bf6f

Browse files
ralyodioclaude
andauthored
Add calendar links and an .ics attachment to meeting reminder emails (#108)
The reminder email had a Join button and a join code but no way to put the meeting on a calendar. It now carries Google and Outlook links and attaches an .ics file for Apple Calendar and everything else. buildIcs is split out of downloadIcs so the same document serves the browser download and the email attachment. It now escapes text per RFC 5545, folds long lines, and carries UID, DTSTAMP and METHOD:PUBLISH so re-imports update the same entry. The runner reads the recurrence columns so a repeating meeting lands as a series. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent a99399d commit b78bf6f

4 files changed

Lines changed: 206 additions & 13 deletions

File tree

‎apps/web/src/lib/calendar.test.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { buildGoogleCalendarUrl, buildIcs, buildOutlookUrl, icsFilename } from './calendar';
4+
5+
const event = {
6+
title: 'Pairux 3+ particpant test mac/win',
7+
description: null,
8+
startIso: '2026-09-22T09:00:00.000Z',
9+
durationMinutes: 60,
10+
joinUrl: 'https://pairux.com/join/U1U2JJ',
11+
uid: 'abc-123@pairux.com',
12+
};
13+
const stamp = new Date('2026-09-22T08:00:00.000Z');
14+
15+
describe('buildIcs', () => {
16+
it('produces a single VEVENT with the meeting window, UID and join URL', () => {
17+
const ics = buildIcs(event, stamp);
18+
const lines = ics.split('\r\n');
19+
expect(lines[0]).toBe('BEGIN:VCALENDAR');
20+
expect(lines).toContain('UID:abc-123@pairux.com');
21+
expect(lines).toContain('DTSTAMP:20260922T080000Z');
22+
expect(lines).toContain('DTSTART:20260922T090000Z');
23+
expect(lines).toContain('DTEND:20260922T100000Z');
24+
expect(lines).toContain('URL:https://pairux.com/join/U1U2JJ');
25+
expect(lines).toContain('SUMMARY:Pairux 3+ particpant test mac/win');
26+
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
27+
expect(ics).not.toContain('RRULE');
28+
});
29+
30+
it('escapes commas, semicolons and newlines in text fields', () => {
31+
const ics = buildIcs(
32+
{ ...event, title: 'Sprint; review, part 2', description: 'line one\nline two' },
33+
stamp
34+
);
35+
expect(ics).toContain('SUMMARY:Sprint\\; review\\, part 2');
36+
expect(ics).toContain(
37+
'DESCRIPTION:line one\\nline two\\n\\nJoin at: https://pairux.com/join/U1U2JJ'
38+
);
39+
});
40+
41+
it('folds lines longer than 75 octets with a leading space', () => {
42+
const ics = buildIcs({ ...event, description: 'x'.repeat(200) }, stamp);
43+
for (const line of ics.split('\r\n')) {
44+
expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(75);
45+
}
46+
const unfolded = ics.replace(/\r\n /g, '');
47+
expect(unfolded).toContain(`DESCRIPTION:${'x'.repeat(200)}`);
48+
});
49+
50+
it('carries a recurrence as an RRULE so the event lands as a series', () => {
51+
const ics = buildIcs(
52+
{ ...event, recurrence: { freq: 'weekly', interval: 2, count: 5 } },
53+
stamp
54+
);
55+
expect(ics).toContain('RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=5');
56+
});
57+
58+
it('derives a UID from the join URL when none is given', () => {
59+
const { uid: _uid, ...bare } = event;
60+
expect(buildIcs(bare, stamp)).toContain('UID:https---pairux-com-join-U1U2JJ@pairux.com');
61+
});
62+
});
63+
64+
describe('calendar links', () => {
65+
it('Google link carries the UTC window and join URL', () => {
66+
const url = new URL(buildGoogleCalendarUrl(event));
67+
expect(url.hostname).toBe('calendar.google.com');
68+
expect(url.searchParams.get('dates')).toBe('20260922T090000Z/20260922T100000Z');
69+
expect(url.searchParams.get('location')).toBe(event.joinUrl);
70+
});
71+
72+
it('Outlook link carries ISO start and end', () => {
73+
const url = new URL(buildOutlookUrl(event));
74+
expect(url.hostname).toBe('outlook.live.com');
75+
expect(url.searchParams.get('startdt')).toBe('2026-09-22T09:00:00.000Z');
76+
expect(url.searchParams.get('enddt')).toBe('2026-09-22T10:00:00.000Z');
77+
});
78+
79+
it('filename keeps only safe characters', () => {
80+
expect(icsFilename(event.title)).toBe('Pairux-3--particpant-test-mac-win.ics');
81+
});
82+
});

‎apps/web/src/lib/calendar.ts‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface CalendarEvent {
88
joinUrl: string;
99
/** Set for a repeating meeting so the event lands as a series, not one date. */
1010
recurrence?: RecurrenceRule | undefined;
11+
/** Stable identifier so re-imports update the same calendar entry. */
12+
uid?: string | undefined;
1113
}
1214

1315
function fmtIcs(d: Date): string {
@@ -47,31 +49,73 @@ export function buildOutlookUrl(event: CalendarEvent): string {
4749
return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
4850
}
4951

50-
export function downloadIcs(event: CalendarEvent): void {
52+
/** RFC 5545 text escaping: backslash, semicolon, comma and newlines. */
53+
function escapeIcsText(value: string): string {
54+
return value
55+
.replace(/\\/g, '\\\\')
56+
.replace(/;/g, '\\;')
57+
.replace(/,/g, '\\,')
58+
.replace(/\r?\n/g, '\\n');
59+
}
60+
61+
/** Fold a content line at 75 octets, as RFC 5545 requires of long lines. */
62+
function foldIcsLine(line: string): string {
63+
const octets = (s: string): number => new TextEncoder().encode(s).length;
64+
const out: string[] = [];
65+
let rest = line;
66+
while (octets(rest) > 75) {
67+
let cut = 75;
68+
// Never split a multi-byte character.
69+
while (cut > 0 && octets(rest.slice(0, cut)) > 75) cut -= 1;
70+
out.push(rest.slice(0, cut));
71+
rest = ` ${rest.slice(cut)}`;
72+
}
73+
out.push(rest);
74+
return out.join('\r\n');
75+
}
76+
77+
/**
78+
* The iCalendar document for an event, usable as a download in the browser or
79+
* an attachment in an email. `now` stamps the file; pass it for deterministic
80+
* output.
81+
*/
82+
export function buildIcs(event: CalendarEvent, now: Date = new Date()): string {
5183
const start = new Date(event.startIso);
5284
const end = new Date(start.getTime() + event.durationMinutes * 60000);
53-
const desc = [event.description, `Join at: ${event.joinUrl}`].filter(Boolean).join('\\n\\n');
85+
const desc = [event.description, `Join at: ${event.joinUrl}`].filter(Boolean).join('\n\n');
5486
const rrule = event.recurrence ? buildRrule(event.recurrence) : null;
55-
const ics = [
87+
const uid = event.uid ?? `${event.joinUrl.replace(/[^a-z0-9]/gi, '-')}@pairux.com`;
88+
const lines = [
5689
'BEGIN:VCALENDAR',
5790
'VERSION:2.0',
5891
'PRODID:-//PairUX//EN',
92+
'METHOD:PUBLISH',
5993
'BEGIN:VEVENT',
94+
`UID:${uid}`,
95+
`DTSTAMP:${fmtIcs(now)}`,
6096
`DTSTART:${fmtIcs(start)}`,
6197
`DTEND:${fmtIcs(end)}`,
6298
...(rrule ? [`RRULE:${rrule}`] : []),
63-
`SUMMARY:${event.title}`,
64-
`DESCRIPTION:${desc}`,
65-
`LOCATION:${event.joinUrl}`,
99+
`SUMMARY:${escapeIcsText(event.title)}`,
100+
`DESCRIPTION:${escapeIcsText(desc)}`,
101+
`LOCATION:${escapeIcsText(event.joinUrl)}`,
66102
`URL:${event.joinUrl}`,
67103
'END:VEVENT',
68104
'END:VCALENDAR',
69-
].join('\r\n');
70-
const blob = new Blob([ics], { type: 'text/calendar' });
105+
];
106+
return `${lines.map(foldIcsLine).join('\r\n')}\r\n`;
107+
}
108+
109+
export function icsFilename(title: string): string {
110+
return `${title.replace(/[^a-z0-9]/gi, '-')}.ics`;
111+
}
112+
113+
export function downloadIcs(event: CalendarEvent): void {
114+
const blob = new Blob([buildIcs(event)], { type: 'text/calendar' });
71115
const url = URL.createObjectURL(blob);
72116
const a = document.createElement('a');
73117
a.href = url;
74-
a.download = `${event.title.replace(/[^a-z0-9]/gi, '-')}.ics`;
118+
a.download = icsFilename(event.title);
75119
a.click();
76120
URL.revokeObjectURL(url);
77121
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
3+
vi.mock('@supabase/supabase-js', () => ({ createClient: vi.fn() }));
4+
vi.mock('@profullstack/emailer', () => ({ createEmailer: vi.fn() }));
5+
vi.mock('./push', () => ({ sendPushToUser: vi.fn() }));
6+
7+
import { reminderEmailHtml } from './meeting-reminders-runner';
8+
9+
describe('reminderEmailHtml', () => {
10+
const html = reminderEmailHtml({
11+
title: 'Pairux 3+ particpant test mac/win',
12+
when: 'in about an hour',
13+
startsAtLabel: 'Tue, 22 Sep 2026 09:00:00 GMT',
14+
joinUrl: 'https://pairux.com/join/U1U2JJ',
15+
joinCode: 'U1U2JJ',
16+
recipientName: 'Anthony Ettinger',
17+
googleCalendarUrl: 'https://calendar.google.com/calendar/render?action=TEMPLATE&text=x',
18+
outlookUrl: 'https://outlook.live.com/calendar/0/deeplink/compose?subject=x',
19+
});
20+
21+
it('links to Google and Outlook and points at the attached .ics', () => {
22+
expect(html).toContain(
23+
'href="https://calendar.google.com/calendar/render?action=TEMPLATE&text=x"'
24+
);
25+
expect(html).toContain('href="https://outlook.live.com/calendar/0/deeplink/compose?subject=x"');
26+
expect(html).toContain('Add to calendar');
27+
expect(html).toContain('.ics');
28+
});
29+
30+
it('still carries the join button and code', () => {
31+
expect(html).toContain('href="https://pairux.com/join/U1U2JJ"');
32+
expect(html).toContain('Join code: <strong>U1U2JJ</strong>');
33+
expect(html).toContain('Hi Anthony Ettinger,');
34+
});
35+
});

‎apps/web/src/lib/meeting-reminders-runner.ts‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { createEmailer } from '@profullstack/emailer';
44
import { dueLead, timeUntil, wantsReminder, REMINDER_PREF_KEYS } from './meeting-reminders';
55
import { claimReminderSlot } from './meeting-reminder-claim';
66
import { sendPushToUser } from './push';
7+
import { buildGoogleCalendarUrl, buildIcs, buildOutlookUrl, icsFilename } from './calendar';
8+
import { ruleFromRow, type RecurrenceRow } from './recurrence';
79

810
/**
911
* Send whatever meeting reminders are due right now.
@@ -29,7 +31,7 @@ export interface ReminderSummary {
2931
errors: string[];
3032
}
3133

32-
interface MeetingRow {
34+
interface MeetingRow extends RecurrenceRow {
3335
id: string;
3436
host_user_id: string;
3537
title: string;
@@ -115,15 +117,20 @@ async function alreadySent(
115117
return byRecipient;
116118
}
117119

118-
function reminderEmailHtml(opts: {
120+
export interface ReminderEmailOptions {
119121
title: string;
120122
when: string;
121123
startsAtLabel: string;
122124
joinUrl: string;
123125
joinCode: string;
124126
recipientName: string | null;
125-
}): string {
127+
googleCalendarUrl: string;
128+
outlookUrl: string;
129+
}
130+
131+
export function reminderEmailHtml(opts: ReminderEmailOptions): string {
126132
const greeting = opts.recipientName ? `Hi ${opts.recipientName},` : 'Hi,';
133+
const calendarLink = 'color:#4f46e5;text-decoration:underline;';
127134
return `<!DOCTYPE html>
128135
<html lang="en">
129136
<head><meta charset="utf-8"><title>${opts.title}</title></head>
@@ -138,6 +145,7 @@ function reminderEmailHtml(opts: {
138145
<p style="color:#6b7280;font-size:14px;margin:0 0 24px;">${opts.startsAtLabel}</p>
139146
<a href="${opts.joinUrl}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 36px;border-radius:8px;font-weight:600;font-size:15px;">Join Meeting</a>
140147
<p style="color:#9ca3af;font-size:13px;margin-top:20px;">Join code: <strong>${opts.joinCode}</strong></p>
148+
<p style="color:#6b7280;font-size:13px;margin:16px 0 0;">Add to calendar: <a href="${opts.googleCalendarUrl}" style="${calendarLink}">Google</a> · <a href="${opts.outlookUrl}" style="${calendarLink}">Outlook</a> · open the attached <strong>.ics</strong> for Apple Calendar and others</p>
141149
</div>
142150
<div style="background:#f9fafb;padding:18px 32px;text-align:center;border-top:1px solid #e5e7eb;">
143151
<p style="color:#9ca3af;font-size:12px;margin:0;">Sent via <a href="https://pairux.com" style="color:#6366f1;text-decoration:none;">PairUX</a> · Manage reminders in <a href="https://pairux.com/settings#notifications" style="color:#6366f1;text-decoration:none;">settings</a></p>
@@ -162,7 +170,9 @@ export async function runMeetingReminders(now: Date = new Date()): Promise<Remin
162170
// instant rather than the row.
163171
const { data: meetings, error } = await db
164172
.from('scheduled_sessions')
165-
.select('id, host_user_id, title, description, scheduled_at, duration_minutes, join_code')
173+
.select(
174+
'id, host_user_id, title, description, scheduled_at, duration_minutes, join_code, recurrence_freq, recurrence_interval, recurrence_count'
175+
)
166176
.eq('status', 'pending')
167177
.gt('scheduled_at', now.toISOString())
168178
.lte('scheduled_at', horizon.toISOString())
@@ -190,6 +200,22 @@ export async function runMeetingReminders(now: Date = new Date()): Promise<Remin
190200
const sent = await alreadySent(db, meeting.id, occurrenceAt);
191201
const joinUrl = `${appUrl}/join/${meeting.join_code}`;
192202
const startsAtLabel = startsAt.toUTCString();
203+
const recurrence = ruleFromRow(meeting);
204+
const calendarEvent = {
205+
title: meeting.title,
206+
description: meeting.description,
207+
startIso: meeting.scheduled_at,
208+
durationMinutes: meeting.duration_minutes,
209+
joinUrl,
210+
recurrence: recurrence.freq ? recurrence : undefined,
211+
uid: `${meeting.id}@pairux.com`,
212+
};
213+
const googleCalendarUrl = buildGoogleCalendarUrl(calendarEvent);
214+
const outlookUrl = buildOutlookUrl(calendarEvent);
215+
const icsAttachment = {
216+
filename: icsFilename(meeting.title),
217+
content: Buffer.from(buildIcs(calendarEvent, now)).toString('base64'),
218+
};
193219

194220
// ---- the host: an account, so both channels are possible
195221
const hostSettings = await db
@@ -240,7 +266,10 @@ export async function runMeetingReminders(now: Date = new Date()): Promise<Remin
240266
joinUrl,
241267
joinCode: meeting.join_code,
242268
recipientName: hostDisplayName,
269+
googleCalendarUrl,
270+
outlookUrl,
243271
}),
272+
attachments: [icsAttachment],
244273
});
245274
summary.emails += 1;
246275
} else summary.skipped += 1;
@@ -315,7 +344,10 @@ export async function runMeetingReminders(now: Date = new Date()): Promise<Remin
315344
joinUrl,
316345
joinCode: meeting.join_code,
317346
recipientName: invitee.name,
347+
googleCalendarUrl,
348+
outlookUrl,
318349
}),
350+
attachments: [icsAttachment],
319351
});
320352
summary.emails += 1;
321353
}

0 commit comments

Comments
 (0)