11use anyhow:: Result ;
2+ use chrono:: NaiveDateTime ;
3+ use chrono_tz:: Tz ;
24use lettre:: message:: header:: ContentType ;
35use lettre:: message:: { Attachment , MultiPart , SinglePart } ;
46use 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
178231pub 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 \n ORGANIZER;" ) ) ;
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