From 18dbbda29898e902615c1198e45fc60198175dbe Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:29:07 +0200 Subject: [PATCH] Fix Calendar: Uppercase `MAILTO` calendar addresses become invalid SMTP recipients --- CHANGELOG.md | 1 + crates/dav/src/calendar/scheduling.rs | 8 ++++---- crates/groupware/src/calendar/index.rs | 11 +++++++---- crates/groupware/src/lib.rs | 7 +++++++ crates/groupware/src/scheduling/mod.rs | 3 ++- crates/jmap/src/participant_identity/set.rs | 11 +++++------ crates/jmap/src/principal/availability.rs | 5 ++--- crates/services/src/task_manager/alarm.rs | 9 ++++++--- 8 files changed, 34 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ffec4c5..5711eced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If ## Fixed - IMAP: Mailbox object-quota only enforced in JMAP. - JMAP: Read-only sharee cannot set `isSubscribed` on a shared mailbox. +- Calendar: Uppercase `MAILTO` calendar addresses become invalid SMTP recipients. ## [0.16.13] - 2026-07-12 diff --git a/crates/dav/src/calendar/scheduling.rs b/crates/dav/src/calendar/scheduling.rs index 505df759..ffc950aa 100644 --- a/crates/dav/src/calendar/scheduling.rs +++ b/crates/dav/src/calendar/scheduling.rs @@ -29,7 +29,9 @@ use dav_proto::{ response::{CalCondition, Href, ScheduleResponse, ScheduleResponseItem}, }, }; -use groupware::{DestroyArchive, cache::GroupwareCache, calendar::CalendarEventNotification}; +use groupware::{ + DestroyArchive, cache::GroupwareCache, calendar::CalendarEventNotification, strip_mailto_scheme, +}; use http_proto::HttpResponse; use hyper::StatusCode; use store::{ @@ -325,9 +327,7 @@ impl CalendarEventNotificationHandler for Server { ICalendarValue::Text(value) | ICalendarValue::Uri(Uri::Location(value)), ), ) => { - if let Some(email) = - sanitize_email(value.strip_prefix("mailto:").unwrap_or(value.as_str())) - { + if let Some(email) = sanitize_email(strip_mailto_scheme(value.as_str())) { attendees.insert(email, entry); } } diff --git a/crates/groupware/src/calendar/index.rs b/crates/groupware/src/calendar/index.rs index 0651dd96..74a4d8fe 100644 --- a/crates/groupware/src/calendar/index.rs +++ b/crates/groupware/src/calendar/index.rs @@ -8,9 +8,12 @@ use super::{ ArchivedCalendar, ArchivedCalendarEvent, ArchivedCalendarPreferences, ArchivedDefaultAlert, ArchivedTimezone, Calendar, CalendarEvent, CalendarPreferences, DefaultAlert, Timezone, }; -use crate::calendar::{ - ArchivedCalendarEventNotification, ArchivedChangedBy, ArchivedEventPreferences, - CalendarEventNotification, ChangedBy, EventPreferences, +use crate::{ + calendar::{ + ArchivedCalendarEventNotification, ArchivedChangedBy, ArchivedEventPreferences, + CalendarEventNotification, ChangedBy, EventPreferences, + }, + strip_mailto_scheme, }; use ahash::AHashSet; use calcard::icalendar::{ @@ -463,7 +466,7 @@ impl ArchivedCalendarEvent { _ => None, })) { - let value = value.strip_prefix("mailto:").unwrap_or(value).trim(); + let value = strip_mailto_scheme(value); let lang = if is_lang { detector.detect(value, MIN_LANGUAGE_SCORE); Language::Unknown diff --git a/crates/groupware/src/lib.rs b/crates/groupware/src/lib.rs index 38602c22..50777810 100644 --- a/crates/groupware/src/lib.rs +++ b/crates/groupware/src/lib.rs @@ -149,3 +149,10 @@ impl DavCalendarResource for DavResources { .map(|p| p.tz) } } + +pub fn strip_mailto_scheme(value: &str) -> &str { + value + .split_once(':') + .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("mailto")) + .map_or(value, |(_, address)| address.trim()) +} diff --git a/crates/groupware/src/scheduling/mod.rs b/crates/groupware/src/scheduling/mod.rs index 12a8f536..db88ec0a 100644 --- a/crates/groupware/src/scheduling/mod.rs +++ b/crates/groupware/src/scheduling/mod.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::strip_mailto_scheme; use ahash::{AHashMap, AHashSet}; use calcard::{ common::{IanaString, PartialDateTime}, @@ -238,7 +239,7 @@ impl Attendee<'_> { impl Email { pub fn new(email: &str, local_addresses: &[String]) -> Option { email.contains('@').then(|| { - let email = email.trim().trim_start_matches("mailto:").to_lowercase(); + let email = strip_mailto_scheme(email.trim()).to_lowercase(); let is_local = local_addresses.contains(&email); Email { email, is_local } }) diff --git a/crates/jmap/src/participant_identity/set.rs b/crates/jmap/src/participant_identity/set.rs index 5034be6d..e43041db 100644 --- a/crates/jmap/src/participant_identity/set.rs +++ b/crates/jmap/src/participant_identity/set.rs @@ -6,7 +6,10 @@ use crate::participant_identity::get::ParticipantIdentityGet; use common::Server; -use groupware::calendar::{ParticipantIdentities, ParticipantIdentity}; +use groupware::{ + calendar::{ParticipantIdentities, ParticipantIdentity}, + strip_mailto_scheme, +}; use jmap_proto::{ error::set::{SetError, SetErrorType}, method::set::{SetRequest, SetResponse}, @@ -226,11 +229,7 @@ fn validate_identity_value( } (ParticipantIdentityProperty::CalendarAddress, Value::Str(value)) => { if identity.calendar_address != value { - let email = if let Some(email) = value.strip_prefix("mailto:") { - sanitize_email(email) - } else { - sanitize_email(&value) - }; + let email = sanitize_email(strip_mailto_scheme(&value)); if let Some(email) = email { if allowed_emails.iter().any(|e| e == &email) { diff --git a/crates/jmap/src/principal/availability.rs b/crates/jmap/src/principal/availability.rs index 6b604429..dd6be22f 100644 --- a/crates/jmap/src/principal/availability.rs +++ b/crates/jmap/src/principal/availability.rs @@ -21,6 +21,7 @@ use common::{ use groupware::{ cache::GroupwareCache, calendar::{CALENDAR_SUBSCRIBED, CalendarEvent}, + strip_mailto_scheme, }; use jmap_proto::{ method::availability::{ @@ -258,9 +259,7 @@ impl PrincipalGetAvailability for Server { if include_in_availability == IncludeInAvailability::Attending => { if let Some(attendee) = value.as_text().and_then(|attendee| { - sanitize_email( - attendee.strip_prefix("mailto:").unwrap_or(attendee), - ) + sanitize_email(strip_mailto_scheme(attendee)) }) { // Condition: the Principal is a participant of the event, and has a "participationStatus" of "accepted" or "tentative". if principal_account.addresses().contains(&attendee) { diff --git a/crates/services/src/task_manager/alarm.rs b/crates/services/src/task_manager/alarm.rs index 9a06e272..35b26209 100644 --- a/crates/services/src/task_manager/alarm.rs +++ b/crates/services/src/task_manager/alarm.rs @@ -17,7 +17,10 @@ use common::{ ipc::{CalendarAlert, PushNotification}, network::{ServerInstance, stream::NullIo}, }; -use groupware::calendar::{ArchivedCalendarEvent, CalendarEvent}; +use groupware::{ + calendar::{ArchivedCalendarEvent, CalendarEvent}, + strip_mailto_scheme, +}; use mail_builder::{ MessageBuilder, headers::{HeaderType, content_type::ContentType}, @@ -478,7 +481,7 @@ async fn build_template( .values .first() .and_then(|v| v.as_text()) - .map(|v| v.strip_prefix("mailto:").unwrap_or(v)) + .map(strip_mailto_scheme) .and_then(sanitize_email); } _ => {} @@ -501,7 +504,7 @@ async fn build_template( .values .first() .and_then(|v| v.as_text()) - .map(|v| v.strip_prefix("mailto:").unwrap_or(v)); + .map(strip_mailto_scheme); let name = entry.params.iter().find_map(|param| { if let ArchivedICalendarParameterName::Cn = param.name { param.value.as_text()