Skip to content

Commit bcd8dad

Browse files
feat: ICS events include attendee names and guest notes
SUMMARY now shows "{title} — {guest_first} & {host_first}" instead of just the event type title, making calendar entries identifiable at a glance (e.g. "30min call — John & Olivier"). Guest notes are included as DESCRIPTION field in the ICS when present. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 19fb66a commit bcd8dad

1 file changed

Lines changed: 75 additions & 6 deletions

File tree

src/email.rs

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,18 @@ fn convert_to_utc(
229229
}
230230

231231
/// Generate an .ics VCALENDAR string for a booking
232+
/// Extract first name (first word) from a full name.
233+
fn first_name(full_name: &str) -> &str {
234+
full_name.split_whitespace().next().unwrap_or(full_name)
235+
}
236+
232237
pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
233-
let summary = sanitize_ics(&details.event_title);
238+
let guest_first = first_name(&details.guest_name);
239+
let host_first = first_name(&details.host_name);
240+
let summary = sanitize_ics(&format!(
241+
"{} \u{2014} {} & {}",
242+
details.event_title, guest_first, host_first
243+
));
234244
let host_name = sanitize_ics(&details.host_name);
235245
let guest_name = sanitize_ics(&details.guest_name);
236246
let host_email = sanitize_ics(&details.host_email);
@@ -240,6 +250,12 @@ pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
240250
.as_ref()
241251
.map(|l| format!("LOCATION:{}\r\n", sanitize_ics(l)))
242252
.unwrap_or_default();
253+
let description_line = details
254+
.notes
255+
.as_ref()
256+
.filter(|n| !n.trim().is_empty())
257+
.map(|n| format!("DESCRIPTION:{}\r\n", sanitize_ics(n)))
258+
.unwrap_or_default();
243259
let valarm = details
244260
.reminder_minutes
245261
.filter(|&m| m > 0)
@@ -271,6 +287,7 @@ pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
271287
DTSTART:{dtstart}\r\n\
272288
DTEND:{dtend}\r\n\
273289
SUMMARY:{summary}\r\n\
290+
{description_line}\
274291
{location_line}\
275292
ORGANIZER;CN={host_name}:mailto:{host_email}\r\n\
276293
ATTENDEE;CN={guest_name};RSVP=TRUE:mailto:{guest_email}\r\n\
@@ -283,6 +300,8 @@ pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
283300
dtstart = dtstart,
284301
dtend = dtend,
285302
summary = summary,
303+
description_line = description_line,
304+
location_line = location_line,
286305
host_name = host_name,
287306
host_email = host_email,
288307
guest_name = guest_name,
@@ -292,7 +311,12 @@ pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
292311

293312
/// Generate an .ics VCALENDAR for cancellation (METHOD:CANCEL)
294313
fn generate_cancel_ics(details: &CancellationDetails) -> String {
295-
let summary = sanitize_ics(&details.event_title);
314+
let guest_first = first_name(&details.guest_name);
315+
let host_first = first_name(&details.host_name);
316+
let summary = sanitize_ics(&format!(
317+
"{} \u{2014} {} & {}",
318+
details.event_title, guest_first, host_first
319+
));
296320
let host_name = sanitize_ics(&details.host_name);
297321
let guest_name = sanitize_ics(&details.guest_name);
298322
let host_email = sanitize_ics(&details.host_email);
@@ -1479,7 +1503,7 @@ mod tests {
14791503
// Europe/Paris is UTC+1 in March (CET), so 14:00 Paris = 13:00 UTC
14801504
assert!(ics.contains("DTSTART:20260310T130000Z"));
14811505
assert!(ics.contains("DTEND:20260310T133000Z"));
1482-
assert!(ics.contains("SUMMARY:Intro Call"));
1506+
assert!(ics.contains("SUMMARY:Intro Call \u{2014} Jane & Alice"));
14831507
assert!(ics.contains("ORGANIZER;CN=Alice:mailto:alice@cal.rs"));
14841508
assert!(ics.contains("ATTENDEE;CN=Jane Doe;RSVP=TRUE:mailto:jane@example.com"));
14851509
assert!(ics.contains("STATUS:CONFIRMED"));
@@ -1506,10 +1530,55 @@ mod tests {
15061530
let ics = generate_ics(&details, "REQUEST");
15071531
assert!(ics.contains("METHOD:REQUEST"));
15081532
assert!(ics.contains("LOCATION:https://meet.example.com/room\r\n"));
1533+
assert!(ics.contains("DESCRIPTION:Discuss roadmap\r\n"));
15091534
// ORGANIZER must be its own line, not folded into LOCATION
15101535
assert!(ics.contains("\r\nORGANIZER;"));
15111536
}
15121537

1538+
#[test]
1539+
fn generate_ics_no_description_when_no_notes() {
1540+
let details = BookingDetails {
1541+
event_title: "Call".to_string(),
1542+
date: "2026-03-10".to_string(),
1543+
start_time: "09:00".to_string(),
1544+
end_time: "10:00".to_string(),
1545+
guest_name: "Bob".to_string(),
1546+
guest_email: "bob@test.com".to_string(),
1547+
guest_timezone: "UTC".to_string(),
1548+
host_name: "Alice".to_string(),
1549+
host_email: "alice@test.com".to_string(),
1550+
uid: "uid-no-notes".to_string(),
1551+
notes: None,
1552+
location: None,
1553+
reminder_minutes: None,
1554+
};
1555+
1556+
let ics = generate_ics(&details, "PUBLISH");
1557+
assert!(!ics.contains("DESCRIPTION:"));
1558+
}
1559+
1560+
#[test]
1561+
fn generate_ics_summary_includes_first_names() {
1562+
let details = BookingDetails {
1563+
event_title: "30min call".to_string(),
1564+
date: "2026-03-10".to_string(),
1565+
start_time: "09:00".to_string(),
1566+
end_time: "09:30".to_string(),
1567+
guest_name: "Jean-Baptiste Piacentino".to_string(),
1568+
guest_email: "jb@test.com".to_string(),
1569+
guest_timezone: "UTC".to_string(),
1570+
host_name: "Olivier Lambert".to_string(),
1571+
host_email: "olivier@test.com".to_string(),
1572+
uid: "uid-names".to_string(),
1573+
notes: None,
1574+
location: None,
1575+
reminder_minutes: None,
1576+
};
1577+
1578+
let ics = generate_ics(&details, "PUBLISH");
1579+
assert!(ics.contains("SUMMARY:30min call \u{2014} Jean-Baptiste & Olivier"));
1580+
}
1581+
15131582
#[test]
15141583
fn generate_ics_escapes_special_chars() {
15151584
let details = BookingDetails {
@@ -1529,7 +1598,7 @@ mod tests {
15291598
};
15301599

15311600
let ics = generate_ics(&details, "PUBLISH");
1532-
assert!(ics.contains("SUMMARY:Meet\\; discuss\\, plan"));
1601+
assert!(ics.contains("SUMMARY:Meet\\; discuss\\, plan \u{2014} O'Brien & Host"));
15331602
}
15341603

15351604
// --- h (HTML escaping) ---
@@ -1631,7 +1700,7 @@ mod tests {
16311700
assert!(ics.contains("UID:cancel-uid-123"));
16321701
assert!(ics.contains("DTSTART:20260310T140000Z"));
16331702
assert!(ics.contains("DTEND:20260310T143000Z"));
1634-
assert!(ics.contains("SUMMARY:Intro Call"));
1703+
assert!(ics.contains("SUMMARY:Intro Call \u{2014} Jane & Alice"));
16351704
}
16361705

16371706
#[test]
@@ -1884,7 +1953,7 @@ mod tests {
18841953
cancelled_by_host: true,
18851954
};
18861955
let ics = generate_cancel_ics(&details);
1887-
assert!(ics.contains("SUMMARY:Team sync\\; weekly\\, recurring"));
1956+
assert!(ics.contains("SUMMARY:Team sync\\; weekly\\, recurring \u{2014} Bob & Alice"));
18881957
assert!(ics.contains("METHOD:CANCEL"));
18891958
assert!(ics.contains("STATUS:CANCELLED"));
18901959
assert!(ics.contains("DTSTART:20260520T160000Z"));

0 commit comments

Comments
 (0)