Fixing timezone issue with caldav.

This commit is contained in:
Burak Kaan Köse
2026-02-19 02:09:36 +01:00
parent 564cb0b16f
commit 317113a1b3
4 changed files with 199 additions and 11 deletions
@@ -0,0 +1,111 @@
using System.Reflection;
using FluentAssertions;
using Moq;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Integration.Processors;
using Wino.Services;
using Xunit;
namespace Wino.Core.Tests.Synchronizers;
public class CalDavEventTimeMappingTests
{
[Fact]
public void ParseCalendarData_UtcEvent_AssignsUtcTimeZone()
{
const string ics = """
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Wino Mail//Tests//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:utc-event
DTSTAMP:20260201T000000Z
DTSTART:20260219T010000Z
DTEND:20260219T020000Z
SUMMARY:UTC Event
END:VEVENT
END:VCALENDAR
""";
var events = ParseEvents(ics);
events.Should().ContainSingle();
events[0].StartTimeZone.Should().Be(TimeZoneInfo.Utc.Id);
events[0].EndTimeZone.Should().Be(TimeZoneInfo.Utc.Id);
}
[Fact]
public async Task ManageCalendarEventAsync_PersistsWallClockTimeForSourceTimeZone()
{
var calendar = new AccountCalendar
{
Id = Guid.NewGuid(),
Name = "Calendar"
};
var remoteEvent = new CalDavCalendarEvent
{
RemoteEventId = "event-1",
Title = "Wall Clock Event",
Start = new DateTimeOffset(2026, 2, 19, 1, 0, 0, TimeSpan.FromHours(1)),
End = new DateTimeOffset(2026, 2, 19, 2, 0, 0, TimeSpan.FromHours(1)),
StartTimeZone = "Europe/Berlin",
EndTimeZone = "Europe/Berlin"
};
CalendarItem? capturedItem = null;
var calendarService = new Mock<ICalendarService>();
calendarService
.Setup(x => x.GetCalendarItemAsync(calendar.Id, remoteEvent.RemoteEventId))
.ReturnsAsync((CalendarItem?)null);
calendarService
.Setup(x => x.CreateNewCalendarItemAsync(It.IsAny<CalendarItem>(), It.IsAny<List<CalendarEventAttendee>>()))
.Callback<CalendarItem, List<CalendarEventAttendee>>((item, _) => capturedItem = item)
.Returns(Task.CompletedTask);
calendarService
.Setup(x => x.SaveRemindersAsync(It.IsAny<Guid>(), It.IsAny<List<Reminder>>()))
.Returns(Task.CompletedTask);
var sut = new ImapChangeProcessor(
Mock.Of<IDatabaseService>(),
Mock.Of<IFolderService>(),
Mock.Of<IMailService>(),
Mock.Of<IAccountService>(),
calendarService.Object,
Mock.Of<IMimeFileService>(),
Mock.Of<ICalendarIcsFileService>());
await sut.ManageCalendarEventAsync(remoteEvent, calendar, organizerAccount: null);
capturedItem.Should().NotBeNull();
var savedItem = capturedItem!;
savedItem.StartDate.Should().Be(new DateTime(2026, 2, 19, 1, 0, 0));
savedItem.DurationInSeconds.Should().Be(3600);
savedItem.StartTimeZone.Should().Be("Europe/Berlin");
savedItem.EndTimeZone.Should().Be("Europe/Berlin");
}
private static List<CalDavCalendarEvent> ParseEvents(string icsContent)
{
var parseMethod = typeof(CalDavClient).GetMethod(
"ParseCalendarData",
BindingFlags.NonPublic | BindingFlags.Static);
parseMethod.Should().NotBeNull();
var result = parseMethod!.Invoke(
null,
[
icsContent,
"https://calendar.example.com/event.ics",
"\"etag\"",
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero)
]);
return result.Should().BeOfType<List<CalDavCalendarEvent>>().Subject;
}
}
@@ -40,18 +40,29 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
var savingItemId = existingItem?.Id ?? Guid.NewGuid();
var savingItem = existingItem ?? new CalendarItem { Id = savingItemId };
var start = calendarEvent.Start.UtcDateTime;
var end = calendarEvent.End.UtcDateTime;
var startTimeZone = NormalizeTimeZoneId(calendarEvent.StartTimeZone, calendarEvent.Start);
var endTimeZone = NormalizeTimeZoneId(calendarEvent.EndTimeZone, calendarEvent.End);
if (string.IsNullOrWhiteSpace(endTimeZone))
endTimeZone = startTimeZone;
var start = ConvertToEventWallClock(calendarEvent.Start, startTimeZone);
var end = ConvertToEventWallClock(calendarEvent.End, endTimeZone);
var durationInSeconds = (calendarEvent.End - calendarEvent.Start).TotalSeconds;
if (durationInSeconds <= 0)
{
if (end <= start)
end = start.AddHours(1);
durationInSeconds = (end - start).TotalSeconds;
}
savingItem.RemoteEventId = calendarEvent.RemoteEventId;
savingItem.CalendarId = assignedCalendar.Id;
savingItem.StartDate = start;
savingItem.DurationInSeconds = (end - start).TotalSeconds;
savingItem.StartTimeZone = calendarEvent.StartTimeZone;
savingItem.EndTimeZone = calendarEvent.EndTimeZone;
savingItem.DurationInSeconds = durationInSeconds;
savingItem.StartTimeZone = startTimeZone;
savingItem.EndTimeZone = endTimeZone;
savingItem.Title = calendarEvent.Title;
savingItem.Description = calendarEvent.Description;
savingItem.Location = calendarEvent.Location;
@@ -158,4 +169,35 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
await DeleteCalendarItemAsync(item.Id).ConfigureAwait(false);
}
private static string NormalizeTimeZoneId(string timeZoneId, DateTimeOffset value)
{
if (!string.IsNullOrWhiteSpace(timeZoneId))
return timeZoneId;
if (value != default && value.Offset == TimeSpan.Zero)
return TimeZoneInfo.Utc.Id;
return string.Empty;
}
private static DateTime ConvertToEventWallClock(DateTimeOffset value, string eventTimeZoneId)
{
if (value == default)
return default;
if (string.IsNullOrWhiteSpace(eventTimeZoneId))
return DateTime.SpecifyKind(value.DateTime, DateTimeKind.Unspecified);
try
{
var eventTimeZone = TimeZoneInfo.FindSystemTimeZoneById(eventTimeZoneId);
var inEventTimeZone = TimeZoneInfo.ConvertTime(value, eventTimeZone);
return DateTime.SpecifyKind(inEventTimeZone.DateTime, DateTimeKind.Unspecified);
}
catch
{
return DateTime.SpecifyKind(value.DateTime, DateTimeKind.Unspecified);
}
}
}
+22 -2
View File
@@ -1768,8 +1768,11 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
}
else
{
lines.Add($"DTSTART:{item.StartDate.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}");
lines.Add($"DTEND:{item.EndDate.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}");
var startUtc = ConvertEventTimeToUtc(item.StartDate, item.StartTimeZone);
var endUtc = ConvertEventTimeToUtc(item.EndDate, item.EndTimeZone ?? item.StartTimeZone);
lines.Add($"DTSTART:{startUtc:yyyyMMdd'T'HHmmss'Z'}");
lines.Add($"DTEND:{endUtc:yyyyMMdd'T'HHmmss'Z'}");
}
if (!string.IsNullOrWhiteSpace(item.Title))
@@ -1827,6 +1830,23 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
return string.Join(Environment.NewLine, lines);
}
private static DateTime ConvertEventTimeToUtc(DateTime eventDateTime, string eventTimeZoneId)
{
if (string.IsNullOrWhiteSpace(eventTimeZoneId))
return eventDateTime.ToUniversalTime();
try
{
var eventTimeZone = TimeZoneInfo.FindSystemTimeZoneById(eventTimeZoneId);
var unspecifiedDateTime = DateTime.SpecifyKind(eventDateTime, DateTimeKind.Unspecified);
return TimeZoneInfo.ConvertTimeToUtc(unspecifiedDateTime, eventTimeZone);
}
catch
{
return eventDateTime.ToUniversalTime();
}
}
private static string EscapeIcs(string value)
{
if (string.IsNullOrEmpty(value))
+17 -2
View File
@@ -624,8 +624,8 @@ public sealed class CalDavClient : ICalDavClient
Location = sourceEvent.Location ?? string.Empty,
Start = start,
End = end,
StartTimeZone = sourceEvent.Start?.TzId ?? string.Empty,
EndTimeZone = sourceEvent.End?.TzId ?? string.Empty,
StartTimeZone = ResolveTimeZoneId(sourceEvent.Start, start),
EndTimeZone = ResolveTimeZoneId(sourceEvent.End, end),
Recurrence = recurrence,
OrganizerDisplayName = organizerDisplayName,
OrganizerEmail = organizerEmail,
@@ -638,6 +638,21 @@ public sealed class CalDavClient : ICalDavClient
};
}
private static string ResolveTimeZoneId(IDateTime sourceDateTime, DateTimeOffset parsedDateTime)
{
var explicitTimeZoneId = sourceDateTime?.TzId;
if (!string.IsNullOrWhiteSpace(explicitTimeZoneId))
return explicitTimeZoneId;
// Explicit UTC values usually don't carry TZID in CalDAV payloads.
// Preserve UTC so downstream local-time conversion stays correct.
if (parsedDateTime != default && parsedDateTime.Offset == TimeSpan.Zero)
return TimeZoneInfo.Utc.Id;
// Floating times without TZID should remain floating.
return string.Empty;
}
private static string BuildRecurrenceString(CalendarEvent sourceEvent)
{
var recurrenceLines = new List<string>();