Skip to content

Commit fab0961

Browse files
release: v0.18.2 — fix ICS location/timezone bugs, show version in sidebar
- Fix LOCATION field in ICS events leaking ORGANIZER info (RFC 5545 line folding caused by trailing spaces after CRLF) - Fix wrong event times in CalDAV write-back: convert guest-timezone times to UTC with Z suffix instead of floating time - Fix hardcoded "UTC" guest_timezone in approve/confirm handlers, now fetched from booking record - Fix broken "Add a calendar source" link on dashboard overview (/dashboard/sources/add → /dashboard/sources/new) (fixes PR olivierlambert#8) - Show calrs version in dashboard sidebar (closes olivierlambert#7) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cce69aa commit fab0961

7 files changed

Lines changed: 115 additions & 43 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "calrs"
3-
version = "0.18.1"
3+
version = "0.18.2"
44
edition = "2021"
55
description = "A fast, self-hostable scheduling platform. Like Cal.com, but written in Rust."
66
license = "AGPL-3.0"

src/commands/booking.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,8 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: BookingCommands) -> Res
331331
println!("{}", Table::new(rows));
332332
}
333333
BookingCommands::Cancel { id } => {
334-
let booking: Option<(String, String, String, String, String, String, String)> = sqlx::query_as(
335-
"SELECT b.id, b.uid, b.guest_name, b.guest_email, b.start_at, b.end_at, et.title
334+
let booking: Option<(String, String, String, String, String, String, String, String)> = sqlx::query_as(
335+
"SELECT b.id, b.uid, b.guest_name, b.guest_email, b.start_at, b.end_at, et.title, COALESCE(b.guest_timezone, 'UTC')
336336
FROM bookings b
337337
JOIN event_types et ON et.id = b.event_type_id
338338
WHERE b.id LIKE ? || '%' AND b.status = 'confirmed'",
@@ -342,7 +342,7 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: BookingCommands) -> Res
342342
.await?;
343343

344344
match booking {
345-
Some((full_id, uid, guest_name, guest_email, start_at, end_at, event_title)) => {
345+
Some((full_id, uid, guest_name, guest_email, start_at, end_at, event_title, guest_timezone)) => {
346346
let reason_input =
347347
prompt("Reason for cancellation (optional, press Enter to skip)");
348348
let reason = if reason_input.is_empty() {
@@ -394,6 +394,7 @@ pub async fn run(pool: &SqlitePool, key: &[u8; 32], cmd: BookingCommands) -> Res
394394
end_time: end_time.to_string(),
395395
guest_name: guest_name.clone(),
396396
guest_email: guest_email.clone(),
397+
guest_timezone: guest_timezone.clone(),
397398
host_name,
398399
host_email,
399400
uid,

src/email.rs

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use anyhow::Result;
2+
use chrono::NaiveDateTime;
3+
use chrono_tz::Tz;
24
use lettre::message::header::ContentType;
35
use lettre::message::{Attachment, MultiPart, SinglePart};
46
use lettre::transport::smtp::authentication::Credentials;
@@ -37,6 +39,7 @@ pub struct CancellationDetails {
3739
pub end_time: String,
3840
pub guest_name: String,
3941
pub guest_email: String,
42+
pub guest_timezone: String,
4043
pub host_name: String,
4144
pub host_email: String,
4245
pub uid: String,
@@ -174,6 +177,56 @@ fn sanitize_ics(value: &str) -> String {
174177
.replace(',', "\\,")
175178
}
176179

180+
/// Convert date + start/end times from a guest timezone to UTC ICS format (YYYYMMDDTHHMMSSZ).
181+
/// Falls back to floating time (no Z) if timezone parsing fails.
182+
fn convert_to_utc(date: &str, start_time: &str, end_time: &str, timezone: &str) -> (String, String) {
183+
let fallback_start = format!(
184+
"{}T{}00",
185+
date.replace('-', ""),
186+
start_time.replace(':', "")
187+
);
188+
let fallback_end = format!(
189+
"{}T{}00",
190+
date.replace('-', ""),
191+
end_time.replace(':', "")
192+
);
193+
194+
let tz: Tz = match timezone.parse() {
195+
Ok(t) => t,
196+
Err(_) => return (fallback_start, fallback_end),
197+
};
198+
199+
let start_naive = match NaiveDateTime::parse_from_str(
200+
&format!("{} {}:00", date, start_time),
201+
"%Y-%m-%d %H:%M:%S",
202+
) {
203+
Ok(dt) => dt,
204+
Err(_) => return (fallback_start, fallback_end),
205+
};
206+
let end_naive = match NaiveDateTime::parse_from_str(
207+
&format!("{} {}:00", date, end_time),
208+
"%Y-%m-%d %H:%M:%S",
209+
) {
210+
Ok(dt) => dt,
211+
Err(_) => return (fallback_start, fallback_end),
212+
};
213+
214+
use chrono::TimeZone;
215+
let start_utc = match tz.from_local_datetime(&start_naive).earliest() {
216+
Some(dt) => dt.with_timezone(&chrono::Utc),
217+
None => return (fallback_start, fallback_end),
218+
};
219+
let end_utc = match tz.from_local_datetime(&end_naive).earliest() {
220+
Some(dt) => dt.with_timezone(&chrono::Utc),
221+
None => return (fallback_start, fallback_end),
222+
};
223+
224+
(
225+
start_utc.format("%Y%m%dT%H%M%SZ").to_string(),
226+
end_utc.format("%Y%m%dT%H%M%SZ").to_string(),
227+
)
228+
}
229+
177230
/// Generate an .ics VCALENDAR string for a booking
178231
pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
179232
let summary = sanitize_ics(&details.event_title);
@@ -184,7 +237,7 @@ pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
184237
let location_line = details
185238
.location
186239
.as_ref()
187-
.map(|l| format!("LOCATION:{}\r\n ", sanitize_ics(l)))
240+
.map(|l| format!("LOCATION:{}\r\n", sanitize_ics(l)))
188241
.unwrap_or_default();
189242
let valarm = details
190243
.reminder_minutes
@@ -200,6 +253,13 @@ pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
200253
)
201254
})
202255
.unwrap_or_default();
256+
// Convert guest-timezone times to UTC for the ICS
257+
let (dtstart, dtend) = convert_to_utc(
258+
&details.date,
259+
&details.start_time,
260+
&details.end_time,
261+
&details.guest_timezone,
262+
);
203263
format!(
204264
"BEGIN:VCALENDAR\r\n\
205265
VERSION:2.0\r\n\
@@ -219,14 +279,8 @@ pub fn generate_ics(details: &BookingDetails, method: &str) -> String {
219279
END:VCALENDAR\r\n",
220280
method = method,
221281
uid = details.uid,
222-
dtstart = details.date.replace('-', "").to_string()
223-
+ "T"
224-
+ &details.start_time.replace(':', "")
225-
+ "00",
226-
dtend = details.date.replace('-', "").to_string()
227-
+ "T"
228-
+ &details.end_time.replace(':', "")
229-
+ "00",
282+
dtstart = dtstart,
283+
dtend = dtend,
230284
summary = summary,
231285
host_name = host_name,
232286
host_email = host_email,
@@ -242,6 +296,12 @@ fn generate_cancel_ics(details: &CancellationDetails) -> String {
242296
let guest_name = sanitize_ics(&details.guest_name);
243297
let host_email = sanitize_ics(&details.host_email);
244298
let guest_email = sanitize_ics(&details.guest_email);
299+
let (dtstart, dtend) = convert_to_utc(
300+
&details.date,
301+
&details.start_time,
302+
&details.end_time,
303+
&details.guest_timezone,
304+
);
245305
format!(
246306
"BEGIN:VCALENDAR\r\n\
247307
VERSION:2.0\r\n\
@@ -258,14 +318,8 @@ fn generate_cancel_ics(details: &CancellationDetails) -> String {
258318
END:VEVENT\r\n\
259319
END:VCALENDAR\r\n",
260320
uid = details.uid,
261-
dtstart = details.date.replace('-', "").to_string()
262-
+ "T"
263-
+ &details.start_time.replace(':', "")
264-
+ "00",
265-
dtend = details.date.replace('-', "").to_string()
266-
+ "T"
267-
+ &details.end_time.replace(':', "")
268-
+ "00",
321+
dtstart = dtstart,
322+
dtend = dtend,
269323
summary = summary,
270324
host_name = host_name,
271325
host_email = host_email,
@@ -1323,8 +1377,9 @@ mod tests {
13231377
assert!(ics.contains("BEGIN:VEVENT"));
13241378
assert!(ics.contains("END:VEVENT"));
13251379
assert!(ics.contains("UID:test-uid-123"));
1326-
assert!(ics.contains("DTSTART:20260310T140000"));
1327-
assert!(ics.contains("DTEND:20260310T143000"));
1380+
// Europe/Paris is UTC+1 in March (CET), so 14:00 Paris = 13:00 UTC
1381+
assert!(ics.contains("DTSTART:20260310T130000Z"));
1382+
assert!(ics.contains("DTEND:20260310T133000Z"));
13281383
assert!(ics.contains("SUMMARY:Intro Call"));
13291384
assert!(ics.contains("ORGANIZER;CN=Alice:mailto:alice@cal.rs"));
13301385
assert!(ics.contains("ATTENDEE;CN=Jane Doe;RSVP=TRUE:mailto:jane@example.com"));
@@ -1351,7 +1406,9 @@ mod tests {
13511406

13521407
let ics = generate_ics(&details, "REQUEST");
13531408
assert!(ics.contains("METHOD:REQUEST"));
1354-
assert!(ics.contains("LOCATION:https://meet.example.com/room"));
1409+
assert!(ics.contains("LOCATION:https://meet.example.com/room\r\n"));
1410+
// ORGANIZER must be its own line, not folded into LOCATION
1411+
assert!(ics.contains("\r\nORGANIZER;"));
13551412
}
13561413

13571414
#[test]
@@ -1461,6 +1518,7 @@ mod tests {
14611518
end_time: "14:30".to_string(),
14621519
guest_name: "Jane Doe".to_string(),
14631520
guest_email: "jane@example.com".to_string(),
1521+
guest_timezone: "UTC".to_string(),
14641522
host_name: "Alice".to_string(),
14651523
host_email: "alice@cal.rs".to_string(),
14661524
uid: "cancel-uid-123".to_string(),
@@ -1472,8 +1530,8 @@ mod tests {
14721530
assert!(ics.contains("METHOD:CANCEL"));
14731531
assert!(ics.contains("STATUS:CANCELLED"));
14741532
assert!(ics.contains("UID:cancel-uid-123"));
1475-
assert!(ics.contains("DTSTART:20260310T140000"));
1476-
assert!(ics.contains("DTEND:20260310T143000"));
1533+
assert!(ics.contains("DTSTART:20260310T140000Z"));
1534+
assert!(ics.contains("DTEND:20260310T143000Z"));
14771535
assert!(ics.contains("SUMMARY:Intro Call"));
14781536
}
14791537

@@ -1486,6 +1544,7 @@ mod tests {
14861544
end_time: "10:00".to_string(),
14871545
guest_name: "Bob".to_string(),
14881546
guest_email: "bob@test.com".to_string(),
1547+
guest_timezone: "UTC".to_string(),
14891548
host_name: "Alice".to_string(),
14901549
host_email: "alice@test.com".to_string(),
14911550
uid: "uid-1".to_string(),
@@ -1529,6 +1588,7 @@ mod tests {
15291588
end_time: "10:00".to_string(),
15301589
guest_name: "Bob".to_string(),
15311590
guest_email: "bob@test.com".to_string(),
1591+
guest_timezone: "UTC".to_string(),
15321592
host_name: "Alice".to_string(),
15331593
host_email: "alice@test.com".to_string(),
15341594
uid: "uid-2".to_string(),
@@ -1717,6 +1777,7 @@ mod tests {
17171777
end_time: "16:45".to_string(),
17181778
guest_name: "Bob".to_string(),
17191779
guest_email: "bob@test.com".to_string(),
1780+
guest_timezone: "UTC".to_string(),
17201781
host_name: "Alice".to_string(),
17211782
host_email: "alice@test.com".to_string(),
17221783
uid: "cancel-special".to_string(),
@@ -1727,8 +1788,8 @@ mod tests {
17271788
assert!(ics.contains("SUMMARY:Team sync\\; weekly\\, recurring"));
17281789
assert!(ics.contains("METHOD:CANCEL"));
17291790
assert!(ics.contains("STATUS:CANCELLED"));
1730-
assert!(ics.contains("DTSTART:20260520T160000"));
1731-
assert!(ics.contains("DTEND:20260520T164500"));
1791+
assert!(ics.contains("DTSTART:20260520T160000Z"));
1792+
assert!(ics.contains("DTEND:20260520T164500Z"));
17321793
}
17331794

17341795
// --- render_html_email edge cases ---

0 commit comments

Comments
 (0)