diff --git a/Wino.Core.Domain/Entities/Mail/MailInvitationCalendarMapping.cs b/Wino.Core.Domain/Entities/Mail/MailInvitationCalendarMapping.cs
new file mode 100644
index 00000000..d1290062
--- /dev/null
+++ b/Wino.Core.Domain/Entities/Mail/MailInvitationCalendarMapping.cs
@@ -0,0 +1,31 @@
+using System;
+using SQLite;
+
+namespace Wino.Core.Domain.Entities.Mail;
+
+///
+/// Maps a calendar invitation mail item to a persisted calendar event.
+///
+public class MailInvitationCalendarMapping
+{
+ [PrimaryKey]
+ public Guid Id { get; set; }
+
+ public Guid AccountId { get; set; }
+
+ ///
+ /// MailCopy.Id value of the invitation mail.
+ ///
+ public string MailCopyId { get; set; }
+
+ ///
+ /// iCalendar UID extracted from invitation MIME/ICS content.
+ ///
+ public string InvitationUid { get; set; }
+
+ public Guid CalendarId { get; set; }
+ public Guid CalendarItemId { get; set; }
+ public string CalendarRemoteEventId { get; set; }
+
+ public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs b/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs
index c62c9a6f..4a8481a4 100644
--- a/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs
+++ b/Wino.Core.Domain/Models/MailItem/HtmlPreviewVisitor.cs
@@ -48,9 +48,22 @@ public class HtmlPreviewVisitor : MimeVisitor
protected override void VisitMultipartAlternative(MultipartAlternative alternative)
{
- // walk the multipart/alternative children backwards from greatest level of faithfulness to the least faithful
+ // Prefer rich body alternatives first, and only fall back to calendar text if nothing else exists.
for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
+ {
+ if (IsCalendarText(alternative[i]))
+ continue;
+
alternative[i].Accept(this);
+ }
+
+ for (int i = alternative.Count - 1; i >= 0 && Body == null; i--)
+ {
+ if (!IsCalendarText(alternative[i]))
+ continue;
+
+ alternative[i].Accept(this);
+ }
}
protected override void VisitMultipartRelated(MultipartRelated related)
@@ -241,6 +254,10 @@ public class HtmlPreviewVisitor : MimeVisitor
Body = converter.Convert(entity.Text);
}
+ private static bool IsCalendarText(MimeEntity entity)
+ => entity is TextPart textPart &&
+ textPart.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true;
+
protected override void VisitTnefPart(TnefPart entity)
{
// extract any attachments in the MS-TNEF part
diff --git a/Wino.Core/Extensions/CalendarInvitationExtensions.cs b/Wino.Core/Extensions/CalendarInvitationExtensions.cs
new file mode 100644
index 00000000..36219272
--- /dev/null
+++ b/Wino.Core/Extensions/CalendarInvitationExtensions.cs
@@ -0,0 +1,121 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using MimeKit;
+
+namespace Wino.Core.Extensions;
+
+public static class CalendarInvitationExtensions
+{
+ public static string ExtractInvitationUid(this MimeMessage message)
+ {
+ if (message == null)
+ {
+ return null;
+ }
+
+ var icsContent = GetCalendarContent(message);
+ if (string.IsNullOrWhiteSpace(icsContent))
+ {
+ return null;
+ }
+
+ var unfolded = UnfoldIcs(icsContent);
+ var veventSection = ExtractFirstVEventSection(unfolded);
+ if (string.IsNullOrWhiteSpace(veventSection))
+ {
+ return null;
+ }
+
+ return TryReadIcsProperty(veventSection, "UID", out var uid)
+ ? uid
+ : null;
+ }
+
+ private static string GetCalendarContent(MimeMessage message)
+ {
+ var textPart = message.BodyParts
+ .OfType()
+ .FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
+
+ if (textPart != null)
+ {
+ return textPart.Text;
+ }
+
+ var mimePart = message.BodyParts
+ .OfType()
+ .FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
+
+ if (mimePart == null)
+ {
+ return null;
+ }
+
+ using var stream = new MemoryStream();
+ mimePart.Content.DecodeTo(stream);
+ var bytes = stream.ToArray();
+ if (bytes.Length == 0)
+ {
+ return null;
+ }
+
+ var charset = mimePart.ContentType?.Charset;
+ var encoding = string.IsNullOrWhiteSpace(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset);
+ return encoding.GetString(bytes);
+ }
+
+ private static string UnfoldIcs(string content)
+ => content
+ .Replace("\r\n ", string.Empty, StringComparison.Ordinal)
+ .Replace("\r\n\t", string.Empty, StringComparison.Ordinal)
+ .Replace("\n ", string.Empty, StringComparison.Ordinal)
+ .Replace("\n\t", string.Empty, StringComparison.Ordinal);
+
+ private static string ExtractFirstVEventSection(string ics)
+ {
+ const string beginVevent = "BEGIN:VEVENT";
+ const string endVevent = "END:VEVENT";
+
+ var beginIndex = ics.IndexOf(beginVevent, StringComparison.OrdinalIgnoreCase);
+ if (beginIndex < 0)
+ {
+ return string.Empty;
+ }
+
+ var endIndex = ics.IndexOf(endVevent, beginIndex, StringComparison.OrdinalIgnoreCase);
+ if (endIndex < 0)
+ {
+ return ics[beginIndex..];
+ }
+
+ return ics.Substring(beginIndex, endIndex - beginIndex + endVevent.Length);
+ }
+
+ private static bool TryReadIcsProperty(string icsSection, string propertyName, out string value)
+ {
+ value = string.Empty;
+ var lines = icsSection.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+ if (!line.StartsWith(propertyName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var colonIndex = line.IndexOf(':');
+ if (colonIndex <= 0 || colonIndex >= line.Length - 1)
+ {
+ continue;
+ }
+
+ value = line[(colonIndex + 1)..].Trim();
+ return !string.IsNullOrWhiteSpace(value);
+ }
+
+ return false;
+ }
+}
diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
index abf6c21a..d8f0d9d3 100644
--- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
+++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
@@ -45,6 +45,7 @@ public interface IDefaultChangeProcessor
Task DeleteCalendarItemAsync(Guid calendarItemId);
Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId);
+ Task GetCalendarItemAsync(Guid calendarId, string remoteEventId);
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
@@ -54,6 +55,8 @@ public interface IDefaultChangeProcessor
Task> GetMailCopiesAsync(IEnumerable mailCopyIds);
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
Task DeleteUserMailCacheAsync(Guid accountId);
+ Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping);
+ Task GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId);
///
/// Checks whether the mail exists in the folder.
@@ -199,6 +202,9 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
public Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
=> CalendarService.DeleteCalendarItemAsync(calendarRemoteEventId, calendarId);
+ public Task GetCalendarItemAsync(Guid calendarId, string remoteEventId)
+ => CalendarService.GetCalendarItemAsync(calendarId, remoteEventId);
+
public Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
=> CalendarService.DeleteAccountCalendarAsync(accountCalendar);
@@ -217,6 +223,43 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
await AccountService.DeleteAccountMailCacheAsync(accountId, AccountCacheResetReason.ExpiredCache).ConfigureAwait(false);
}
+ public async Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping)
+ {
+ if (mapping == null || mapping.AccountId == Guid.Empty || string.IsNullOrWhiteSpace(mapping.MailCopyId))
+ return;
+
+ var existing = await Connection.Table()
+ .FirstOrDefaultAsync(x => x.AccountId == mapping.AccountId && x.MailCopyId == mapping.MailCopyId)
+ .ConfigureAwait(false);
+
+ if (existing == null)
+ {
+ if (mapping.Id == Guid.Empty)
+ mapping.Id = Guid.NewGuid();
+
+ mapping.UpdatedAtUtc = DateTime.UtcNow;
+ await Connection.InsertAsync(mapping, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false);
+ return;
+ }
+
+ existing.InvitationUid = mapping.InvitationUid;
+ existing.CalendarId = mapping.CalendarId;
+ existing.CalendarItemId = mapping.CalendarItemId;
+ existing.CalendarRemoteEventId = mapping.CalendarRemoteEventId;
+ existing.UpdatedAtUtc = DateTime.UtcNow;
+
+ await Connection.UpdateAsync(existing, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false);
+ }
+
+ public Task GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId)
+ {
+ if (accountId == Guid.Empty || string.IsNullOrWhiteSpace(mailCopyId))
+ return Task.FromResult(null);
+
+ return Connection.Table()
+ .FirstOrDefaultAsync(x => x.AccountId == accountId && x.MailCopyId == mailCopyId);
+ }
+
public Task IsMailExistsInFolderAsync(string messageId, Guid folderId)
=> MailService.IsMailExistsAsync(messageId, folderId);
}
diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs
index 7afdfc14..c5a615c0 100644
--- a/Wino.Core/Synchronizers/GmailSynchronizer.cs
+++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs
@@ -1965,12 +1965,35 @@ public class GmailSynchronizer : WinoSynchronizer
+ {
+ requestConfiguration.QueryParameters.Filter = $"iCalUId eq '{escapedUid}'";
+ requestConfiguration.QueryParameters.Select = ["id"];
+ requestConfiguration.QueryParameters.Top = 1;
+ }, cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+
+ var matchedEvent = eventsResponse?.Value?.FirstOrDefault();
+ if (matchedEvent == null || string.IsNullOrWhiteSpace(matchedEvent.Id))
+ continue;
+
+ var fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[matchedEvent.Id]
+ .GetAsync(requestConfiguration =>
+ {
+ requestConfiguration.QueryParameters.Expand = ["attachments($select=id,name,contentType,size,isInline)"];
+ }, cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+
+ if (fullEvent == null)
+ continue;
+
+ await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false);
+
+ var localCalendarItem = await _outlookChangeProcessor.GetCalendarItemAsync(calendar.Id, fullEvent.Id).ConfigureAwait(false);
+ if (localCalendarItem == null)
+ return;
+
+ await _outlookChangeProcessor.UpsertMailInvitationCalendarMappingAsync(new MailInvitationCalendarMapping()
+ {
+ Id = Guid.NewGuid(),
+ AccountId = Account.Id,
+ MailCopyId = mailCopy.Id,
+ InvitationUid = invitationUid,
+ CalendarId = calendar.Id,
+ CalendarItemId = localCalendarItem.Id,
+ CalendarRemoteEventId = fullEvent.Id
+ }).ConfigureAwait(false);
+
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "Failed to map Outlook calendar invitation mail {MailCopyId} for calendar {CalendarId}", mailCopy.Id, calendar.Id);
+ }
+ }
+ }
+
protected override async Task SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
{
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
diff --git a/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml
new file mode 100644
index 00000000..3fc1132a
--- /dev/null
+++ b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs
new file mode 100644
index 00000000..1d7f8eb3
--- /dev/null
+++ b/Wino.Mail.WinUI/Controls/CalendarMailItemDisplayInformationControl.xaml.cs
@@ -0,0 +1,382 @@
+using System;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.WinUI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using MimeKit;
+using Wino.Core.Domain;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.MailItem;
+using Wino.Helpers;
+using Wino.Mail.ViewModels.Data;
+using Wino.Mail.WinUI;
+
+namespace Wino.Controls;
+
+public sealed partial class CalendarMailItemDisplayInformationControl : UserControl
+{
+ private static readonly ConcurrentDictionary EventDateRangeCache = [];
+
+ private readonly IMimeFileService _mimeFileService;
+ private readonly IPreferencesService _preferencesService;
+ private CancellationTokenSource? _loadingCts;
+
+ [GeneratedDependencyProperty]
+ public partial MailItemViewModel? MailItem { get; set; }
+
+ [GeneratedDependencyProperty(DefaultValue = MailListDisplayMode.Spacious)]
+ public partial MailListDisplayMode DisplayMode { get; set; }
+
+ [GeneratedDependencyProperty(DefaultValue = false)]
+ public partial bool Prefer24HourTimeFormat { get; set; }
+
+ [GeneratedDependencyProperty(DefaultValue = "")]
+ public partial string EventDateRangeText { get; set; }
+
+ public event EventHandler? HoverActionExecuted;
+
+ public CalendarMailItemDisplayInformationControl()
+ {
+ InitializeComponent();
+
+ _mimeFileService = App.Current.Services.GetRequiredService();
+ _preferencesService = App.Current.Services.GetRequiredService();
+
+ DisplayMode = _preferencesService.MailItemDisplayMode;
+ Prefer24HourTimeFormat = _preferencesService.Prefer24HourTimeFormat;
+
+ Unloaded += OnControlUnloaded;
+ }
+
+ partial void OnMailItemPropertyChanged(DependencyPropertyChangedEventArgs e)
+ {
+ _ = LoadEventDateRangeAsync();
+ }
+
+ private async Task LoadEventDateRangeAsync()
+ {
+ _loadingCts?.Cancel();
+ _loadingCts?.Dispose();
+ _loadingCts = new CancellationTokenSource();
+ var token = _loadingCts.Token;
+
+ if (MailItem?.MailCopy == null)
+ {
+ EventDateRangeText = string.Empty;
+ return;
+ }
+
+ if (EventDateRangeCache.TryGetValue(MailItem.MailCopy.FileId, out var cachedValue))
+ {
+ EventDateRangeText = cachedValue;
+ return;
+ }
+
+ try
+ {
+ var accountId = MailItem.MailCopy.AssignedAccount?.Id;
+ if (accountId == null || accountId == Guid.Empty)
+ {
+ EventDateRangeText = Translator.UnknownDateHeader;
+ return;
+ }
+
+ var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId.Value, MailItem.MailCopy.FileId);
+ if (!isMimeExists)
+ {
+ EventDateRangeText = Translator.UnknownDateHeader;
+ return;
+ }
+
+ var mimeInfo = await _mimeFileService.GetMimeMessageInformationAsync(MailItem.MailCopy.FileId, accountId.Value, token);
+ if (mimeInfo == null)
+ {
+ EventDateRangeText = Translator.UnknownDateHeader;
+ return;
+ }
+
+ var renderedDateRange = ExtractCalendarDateRange(mimeInfo.MimeMessage);
+
+ EventDateRangeText = string.IsNullOrWhiteSpace(renderedDateRange) ? Translator.UnknownDateHeader : renderedDateRange;
+ EventDateRangeCache.TryAdd(MailItem.MailCopy.FileId, EventDateRangeText);
+ }
+ catch (OperationCanceledException)
+ {
+ // Ignore; newer bind request superseded this one.
+ }
+ catch
+ {
+ EventDateRangeText = Translator.UnknownDateHeader;
+ }
+ }
+
+ private void BaseMailControlHoverActionExecuted(object sender, MailOperationPreperationRequest e)
+ => HoverActionExecuted?.Invoke(this, e);
+
+ private string ExtractCalendarDateRange(MimeMessage message)
+ {
+ var calendarContent = GetCalendarContent(message);
+ if (string.IsNullOrWhiteSpace(calendarContent))
+ {
+ return string.Empty;
+ }
+
+ var unfoldedIcs = UnfoldIcs(calendarContent);
+ var eventSection = ExtractFirstVEventSection(unfoldedIcs);
+
+ if (!TryReadIcsDateValue(eventSection, "DTSTART", out var dtStartValue, out var dtStartTzId))
+ {
+ return string.Empty;
+ }
+
+ if (!TryParseIcsDateTime(dtStartValue, dtStartTzId, out var startLocal, out var isAllDay))
+ {
+ return string.Empty;
+ }
+
+ DateTime endLocal = startLocal;
+ if (TryReadIcsDateValue(eventSection, "DTEND", out var dtEndValue, out var dtEndTzId))
+ {
+ if (!TryParseIcsDateTime(dtEndValue, dtEndTzId, out endLocal, out var endIsAllDay))
+ {
+ endLocal = startLocal;
+ }
+
+ isAllDay = isAllDay && endIsAllDay;
+ }
+
+ return FormatDisplayDateRange(startLocal, endLocal, isAllDay);
+ }
+
+ private static string GetCalendarContent(MimeMessage message)
+ {
+ var calendarTextPart = message.BodyParts
+ .OfType()
+ .FirstOrDefault(x => x.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
+
+ if (calendarTextPart != null)
+ {
+ return calendarTextPart.Text ?? string.Empty;
+ }
+
+ var calendarMimePart = message.BodyParts
+ .OfType()
+ .FirstOrDefault(x => x.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
+
+ if (calendarMimePart == null)
+ {
+ return string.Empty;
+ }
+
+ using var stream = new MemoryStream();
+ calendarMimePart.Content.DecodeTo(stream);
+ var contentBytes = stream.ToArray();
+
+ if (contentBytes.Length == 0)
+ {
+ return string.Empty;
+ }
+
+ var charset = calendarMimePart.ContentType?.Charset;
+ var encoding = string.IsNullOrWhiteSpace(charset) ? System.Text.Encoding.UTF8 : System.Text.Encoding.GetEncoding(charset);
+ return encoding.GetString(contentBytes);
+ }
+
+ private static string UnfoldIcs(string content)
+ => content
+ .Replace("\r\n ", string.Empty, StringComparison.Ordinal)
+ .Replace("\r\n\t", string.Empty, StringComparison.Ordinal)
+ .Replace("\n ", string.Empty, StringComparison.Ordinal)
+ .Replace("\n\t", string.Empty, StringComparison.Ordinal);
+
+ private static string ExtractFirstVEventSection(string ics)
+ {
+ if (string.IsNullOrWhiteSpace(ics))
+ {
+ return string.Empty;
+ }
+
+ const string beginVevent = "BEGIN:VEVENT";
+ const string endVevent = "END:VEVENT";
+
+ var beginIndex = ics.IndexOf(beginVevent, StringComparison.OrdinalIgnoreCase);
+ if (beginIndex < 0)
+ {
+ return ics;
+ }
+
+ var endIndex = ics.IndexOf(endVevent, beginIndex, StringComparison.OrdinalIgnoreCase);
+ if (endIndex < 0)
+ {
+ return ics[beginIndex..];
+ }
+
+ return ics.Substring(beginIndex, endIndex - beginIndex + endVevent.Length);
+ }
+
+ private static bool TryReadIcsDateValue(string ics, string propertyName, out string value, out string timeZoneId)
+ {
+ value = string.Empty;
+ timeZoneId = string.Empty;
+
+ var lines = ics.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+ if (!line.StartsWith(propertyName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var colonIndex = line.IndexOf(':');
+ if (colonIndex <= 0 || colonIndex == line.Length - 1)
+ {
+ continue;
+ }
+
+ var parameterSection = line[..colonIndex];
+ value = line[(colonIndex + 1)..].Trim();
+
+ var paramsSplit = parameterSection.Split(';', StringSplitOptions.RemoveEmptyEntries);
+ foreach (var parameter in paramsSplit.Skip(1))
+ {
+ var eqIndex = parameter.IndexOf('=');
+ if (eqIndex <= 0 || eqIndex == parameter.Length - 1)
+ {
+ continue;
+ }
+
+ var name = parameter[..eqIndex].Trim();
+ if (!name.Equals("TZID", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ timeZoneId = parameter[(eqIndex + 1)..].Trim().Trim('"');
+ break;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryParseIcsDateTime(string rawValue, string timeZoneId, out DateTime localDateTime, out bool isAllDay)
+ {
+ localDateTime = default;
+ isAllDay = false;
+
+ if (string.IsNullOrWhiteSpace(rawValue))
+ {
+ return false;
+ }
+
+ var value = rawValue.Trim();
+
+ if (value.Length == 8 && DateTime.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
+ {
+ localDateTime = dateOnly.Date;
+ isAllDay = true;
+ return true;
+ }
+
+ var isUtc = value.EndsWith("Z", StringComparison.OrdinalIgnoreCase);
+ if (isUtc)
+ {
+ value = value[..^1];
+ }
+
+ var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" };
+ if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate))
+ {
+ return false;
+ }
+
+ if (isUtc)
+ {
+ localDateTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Utc).ToLocalTime();
+ return true;
+ }
+
+ if (!string.IsNullOrWhiteSpace(timeZoneId) && TryFindTimeZone(timeZoneId, out var sourceZone))
+ {
+ var unspecifiedTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Unspecified);
+ localDateTime = TimeZoneInfo.ConvertTime(unspecifiedTime, sourceZone, TimeZoneInfo.Local);
+ return true;
+ }
+
+ localDateTime = DateTime.SpecifyKind(parsedDate, DateTimeKind.Local);
+ return true;
+ }
+
+ private static bool TryFindTimeZone(string timeZoneId, out TimeZoneInfo timeZone)
+ {
+ timeZone = null!;
+
+ try
+ {
+ timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
+ return true;
+ }
+ catch
+ {
+ if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZoneId, out var windowsId))
+ {
+ try
+ {
+ timeZone = TimeZoneInfo.FindSystemTimeZoneById(windowsId);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private string FormatDisplayDateRange(DateTime startLocal, DateTime endLocal, bool isAllDay)
+ {
+ if (endLocal < startLocal)
+ {
+ endLocal = startLocal;
+ }
+
+ var culture = CultureInfo.DefaultThreadCurrentUICulture;
+ if (isAllDay)
+ {
+ var adjustedEnd = endLocal.Date > startLocal.Date ? endLocal.Date.AddDays(-1) : startLocal.Date;
+
+ if (adjustedEnd.Date > startLocal.Date)
+ {
+ return $"{startLocal.ToString("d", culture)} - {adjustedEnd.ToString("d", culture)} ({Translator.CalendarItemAllDay})";
+ }
+
+ return $"{startLocal.ToString("d", culture)} ({Translator.CalendarItemAllDay})";
+ }
+
+ var timeFormat = Prefer24HourTimeFormat ? "HH:mm" : "h:mm tt";
+
+ if (startLocal.Date == endLocal.Date)
+ {
+ return $"{startLocal.ToString($"ddd, MMM d {timeFormat}", culture)} - {endLocal.ToString(timeFormat, culture)}";
+ }
+
+ return $"{startLocal.ToString($"ddd, MMM d {timeFormat}", culture)} - {endLocal.ToString($"ddd, MMM d {timeFormat}", culture)}";
+ }
+
+ private void OnControlUnloaded(object sender, RoutedEventArgs e)
+ {
+ _loadingCts?.Cancel();
+ }
+}
diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
index ddb22069..4594e38d 100644
--- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
+++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml
@@ -100,27 +100,13 @@
-
+
-
-
-
-
+ MailItem="{x:Bind}" />
(),
Connection.CreateTableAsync(),
Connection.CreateTableAsync(),
- Connection.CreateTableAsync()
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync()
);
}
}