Skip to content

Commit d2ca9bf

Browse files
committed
fix: accept_invite() falls back to client username when calendar-user-address-set is unavailable
When the server does not expose the calendar-user-address-set property (RFC6638 §2.4.1), accept_invite() (and decline_invite(), tentatively_accept_invite()) now fall back to the client username as the attendee email address. A NotFoundError with a descriptive message is raised when the username is also not an email address. Fixes #399
1 parent 82e513a commit d2ca9bf

File tree

4 files changed

+194
-1
lines changed

4 files changed

+194
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
2121
* `Calendar.get_supported_components()`
2222
* raised `KeyError` when the server did not include the `supported-calendar-component-set` property in its response. RFC 4791 section 5.2.3 states this property is optional and that its absence means all component types are accepted; the method now returns the RFC default `["VEVENT", "VTODO", "VJOURNAL"]` in that case, trimmed by any known server limitations from the compatibility hints (e.g. if `save-load.todo` is `unsupported`, `VTODO` is excluded). Fixes https://github.com/python-caldav/caldav/issues/653
2323
* async path returned an unawaited coroutine instead of the actual result.
24+
* `accept_invite()` (and `decline_invite()`, `tentatively_accept_invite()`) now fall back to the client username as the attendee email address when the server does not expose the `calendar-user-address-set` property (RFC6638 §2.4.1). A `NotFoundError` with a descriptive message is raised when the username is also not an email address. Fixes https://github.com/python-caldav/caldav/issues/399
2425

2526
## [3.1.0] - 2026-03-19
2627

caldav/calendarobjectresource.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1055,7 +1055,22 @@ def change_attendee_status(self, attendee: Any | None = None, **kwargs) -> None:
10551055
cnt = 0
10561056

10571057
if isinstance(attendee, Principal):
1058-
attendee_emails = attendee.calendar_user_address_set()
1058+
try:
1059+
attendee_emails = attendee.calendar_user_address_set()
1060+
except error.NotFoundError:
1061+
## Server does not expose calendar-user-address-set (RFC6638 §2.4.1).
1062+
## Fall back to client.username if it looks like an email address.
1063+
## See https://github.com/python-caldav/caldav/issues/399
1064+
username = getattr(self.client, "username", None)
1065+
if username and "@" in str(username):
1066+
attendee_emails = ["mailto:" + username]
1067+
else:
1068+
raise error.NotFoundError(
1069+
"Server does not provide the calendar-user-address-set property "
1070+
"(RFC6638 §2.4.1) and the client username is not an email address. "
1071+
"Cannot determine which attendee to update. "
1072+
"Pass the attendee email address explicitly to change_attendee_status()."
1073+
) from None
10591074
for addr in attendee_emails:
10601075
try:
10611076
self.change_attendee_status(addr, **kwargs)

tests/test_caldav.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,79 @@ def testInviteAndRespond(self):
835835
assert new_organizer_inbox_items[0].is_invite_reply()
836836
new_organizer_inbox_items[0].delete()
837837

838+
def testAcceptInviteUsernameEmailFallback(self):
839+
"""accept_invite() works when the invite was built with username-as-email (issue #399).
840+
841+
The invite is constructed using the attendee's login username directly
842+
instead of get_vcal_address(), mirroring what a client must do when the
843+
server does not expose calendar-user-address-set.
844+
845+
On servers that expose calendar-user-address-set the normal code path
846+
runs inside accept_invite(); on servers that do not, the username-email
847+
fallback introduced by the fix kicks in. Both paths produce the same
848+
observable outcome (PARTSTAT updated, invite accepted), so this test is
849+
valid regardless of whether calendar-user-address-set is available.
850+
851+
Only runs when:
852+
- two principals are available
853+
- the server delivers iTIP requests to the inbox
854+
- the attendee's login username is an email address
855+
"""
856+
if len(self.principals) < 2:
857+
pytest.skip("need 2 principals to do the invite and respond test")
858+
859+
attendee_client = self.clients[1]
860+
if not attendee_client.features.is_supported("scheduling.mailbox.inbox-delivery"):
861+
pytest.skip("server does not deliver iTIP requests to the inbox")
862+
attendee_username = getattr(attendee_client, "username", None)
863+
if not attendee_username or "@" not in str(attendee_username):
864+
pytest.skip(
865+
"Attendee username %r is not an email address; "
866+
"cannot build a matching ATTENDEE line" % attendee_username
867+
)
868+
attendee_email = "mailto:" + attendee_username
869+
870+
inbox_items = set(x.url for x in self.principals[0].schedule_inbox().get_items())
871+
inbox_items.update(x.url for x in self.principals[1].schedule_inbox().get_items())
872+
873+
organizers_calendar = self._getCalendar(0)
874+
attendee_calendar = self._getCalendar(1)
875+
## Build the invite using the attendee's email directly, since
876+
## get_vcal_address() would also fail without calendar-user-address-set.
877+
## Use a fresh UUID so Zimbra (and other servers) don't treat this as a
878+
## duplicate of the event sent by testInviteAndRespond. Both tests use
879+
## the module-level `sched` which has the same UID for the whole test
880+
## session, so reusing it causes Zimbra to silently skip inbox delivery.
881+
fresh_sched = sched_template % (
882+
str(uuid.uuid4()),
883+
"%2i%2i%2i" % (random.randint(0, 23), random.randint(0, 59), random.randint(0, 59)),
884+
random.randint(1, 28),
885+
"%2i%2i%2i" % (random.randint(0, 23), random.randint(0, 59), random.randint(0, 59)),
886+
)
887+
saved_event = organizers_calendar.save_with_invites(
888+
fresh_sched, [self.principals[0], attendee_email]
889+
)
890+
self._auto_scheduled_event_uids.append(saved_event.id)
891+
892+
new_attendee_inbox_items = []
893+
for _ in range(30):
894+
new_attendee_inbox_items = [
895+
item
896+
for item in self.principals[1].schedule_inbox().get_items()
897+
if item.url not in inbox_items
898+
]
899+
if new_attendee_inbox_items:
900+
break
901+
time.sleep(1)
902+
903+
assert len(new_attendee_inbox_items) == 1, (
904+
"expected exactly one new inbox item for attendee"
905+
)
906+
assert new_attendee_inbox_items[0].is_invite_request()
907+
908+
## accept_invite() must work via the username-email fallback.
909+
new_attendee_inbox_items[0].accept_invite(calendar=attendee_calendar)
910+
838911
## TODO. Invite two principals, let both of them load the
839912
## invitation, and then let them respond in order. Lacks both
840913
## tests and the implementation also apparently doesn't work as
@@ -1190,6 +1263,53 @@ def testSchedulingInfo(self):
11901263
calendar_user_address_set = self.principal.calendar_user_address_set()
11911264
me_a_participant = self.principal.get_vcal_address()
11921265

1266+
def testIssue399ChangeAttendeeStatusUsernameEmailFallback(self):
1267+
"""change_attendee_status() works when the attendee is identified
1268+
by the client username rather than calendar_user_address_set() (issue #399).
1269+
1270+
On servers that expose calendar-user-address-set the normal path runs;
1271+
on servers that do not, the username-email fallback introduced by the
1272+
fix kicks in. Either way the PARTSTAT update must succeed.
1273+
Only skipped when the login username is not an email address.
1274+
"""
1275+
self.skip_unless_support("scheduling")
1276+
username = getattr(self.caldav, "username", None)
1277+
if not username or "@" not in str(username):
1278+
pytest.skip(
1279+
"Client username %r is not an email address; "
1280+
"cannot build a matching ATTENDEE line" % username
1281+
)
1282+
my_email = "mailto:" + username
1283+
1284+
invite_data = """\
1285+
BEGIN:VCALENDAR
1286+
VERSION:2.0
1287+
PRODID:-//Test//Test//EN
1288+
METHOD:REQUEST
1289+
BEGIN:VEVENT
1290+
UID:test-issue-399-%s@test.example
1291+
DTSTAMP:%s
1292+
DTSTART:%s
1293+
DTEND:%s
1294+
SUMMARY:Test invite for issue 399
1295+
ORGANIZER:mailto:organizer@test.example
1296+
ATTENDEE;PARTSTAT=NEEDS-ACTION:%s
1297+
END:VEVENT
1298+
END:VCALENDAR
1299+
""" % (
1300+
uuid.uuid4(),
1301+
datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
1302+
(datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y%m%dT%H%M%SZ"),
1303+
(datetime.now(timezone.utc) + timedelta(days=10, hours=1)).strftime("%Y%m%dT%H%M%SZ"),
1304+
my_email,
1305+
)
1306+
1307+
ev = Event(client=self.caldav, data=invite_data)
1308+
ev.change_attendee_status(partstat="ACCEPTED")
1309+
1310+
attendee = ev.icalendar_component["attendee"]
1311+
assert attendee.params.get("PARTSTAT") == "ACCEPTED"
1312+
11931313
def testSchedulingMailboxes(self):
11941314
self.skip_unless_support("scheduling.mailbox")
11951315
inbox = self.principal.schedule_inbox()

tests/test_caldav_unit.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2866,3 +2866,60 @@ def test_add_organizer_no_arg_no_client_raises(self):
28662866
ev.client = None
28672867
with pytest.raises(ValueError):
28682868
ev.add_organizer()
2869+
2870+
2871+
class TestChangeAttendeeStatusFallback:
2872+
"""Unit tests for change_attendee_status() fallback when calendar_user_address_set() is unavailable.
2873+
2874+
Covers issue https://github.com/python-caldav/caldav/issues/399: accept_invite() fails on servers
2875+
that do not expose the calendar-user-address-set property (RFC6638 §2.4.1).
2876+
"""
2877+
2878+
_invite = """\
2879+
BEGIN:VCALENDAR
2880+
VERSION:2.0
2881+
PRODID:-//Test//Test//EN
2882+
METHOD:REQUEST
2883+
BEGIN:VEVENT
2884+
UID:test-invite-399@example.com
2885+
DTSTAMP:20240101T000000Z
2886+
DTSTART:20240601T100000Z
2887+
DTEND:20240601T110000Z
2888+
SUMMARY:Test invite
2889+
ORGANIZER:mailto:organizer@example.com
2890+
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:attendee@example.com
2891+
END:VEVENT
2892+
END:VCALENDAR
2893+
"""
2894+
2895+
def _make_event_with_mock_client(self, username):
2896+
from caldav.collection import Principal
2897+
from caldav.lib import error as caldav_error
2898+
2899+
ev = Event(data=self._invite)
2900+
mock_client = mock.MagicMock()
2901+
mock_client.username = username
2902+
mock_principal = mock.MagicMock(spec=Principal)
2903+
mock_principal.calendar_user_address_set.side_effect = caldav_error.NotFoundError(
2904+
"calendar-user-address-set not supported"
2905+
)
2906+
mock_client.principal.return_value = mock_principal
2907+
ev.client = mock_client
2908+
return ev
2909+
2910+
def test_change_attendee_status_falls_back_to_email_username(self):
2911+
"""When calendar_user_address_set() raises NotFoundError and username is an email,
2912+
change_attendee_status() should use the username as the attendee address."""
2913+
ev = self._make_event_with_mock_client("attendee@example.com")
2914+
ev.change_attendee_status(partstat="ACCEPTED")
2915+
attendee = ev.icalendar_component["attendee"]
2916+
assert attendee.params.get("PARTSTAT") == "ACCEPTED"
2917+
2918+
def test_change_attendee_status_raises_when_username_not_email(self):
2919+
"""When calendar_user_address_set() raises NotFoundError and username is not an email,
2920+
change_attendee_status() should re-raise NotFoundError with a descriptive message."""
2921+
from caldav.lib import error as caldav_error
2922+
2923+
ev = self._make_event_with_mock_client("just_a_username")
2924+
with pytest.raises(caldav_error.NotFoundError):
2925+
ev.change_attendee_status(partstat="ACCEPTED")

0 commit comments

Comments
 (0)