1"""CAL-ADDRESS values from :rfc:`5545`."""
2
3from typing import Any, ClassVar
4from xml.etree.ElementTree import Element, SubElement
5
6from icalendar.compatibility import Self
7from icalendar.error import JCalParsingError
8from icalendar.parser import Parameters
9from icalendar.parser.xcal.base import XCalParser
10from icalendar.parser.xcal.wrapper import from_xcal_wrapper
11from icalendar.parser_tools import DEFAULT_ENCODING, to_unicode
12
13
14class vCalAddress(str):
15 r"""Calendar User Address
16
17 Value Name:
18 CAL-ADDRESS
19
20 Purpose:
21 This value type is used to identify properties that contain a
22 calendar user address.
23
24 Description:
25 The value is a URI as defined by [RFC3986] or any other
26 IANA-registered form for a URI. When used to address an Internet
27 email transport address for a calendar user, the value MUST be a
28 mailto URI, as defined by [RFC2368].
29
30 Example:
31 ``mailto:`` is in front of the address.
32
33 .. code-block:: ics
34
35 mailto:jane_doe@example.com
36
37 Parsing:
38
39 .. code-block:: pycon
40
41 >>> from icalendar import vCalAddress
42 >>> cal_address = vCalAddress.from_ical('mailto:jane_doe@example.com')
43 >>> cal_address
44 vCalAddress('mailto:jane_doe@example.com')
45
46 Encoding:
47
48 .. code-block:: pycon
49
50 >>> from icalendar import vCalAddress, Event
51 >>> event = Event()
52 >>> jane = vCalAddress("mailto:jane_doe@example.com")
53 >>> jane.name = "Jane"
54 >>> event["organizer"] = jane
55 >>> print(event.to_ical().decode().replace('\\r\\n', '\\n').strip())
56 BEGIN:VEVENT
57 ORGANIZER;CN=Jane:mailto:jane_doe@example.com
58 END:VEVENT
59 """
60
61 default_value: ClassVar[str] = "CAL-ADDRESS"
62 params: Parameters
63 __slots__ = ("params",)
64
65 def __new__(
66 cls,
67 value: str | bytes,
68 encoding: str = DEFAULT_ENCODING,
69 /,
70 params: dict[str, Any] | None = None,
71 ) -> Self:
72 value = to_unicode(value, encoding=encoding)
73 if "\r" in value or "\n" in value:
74 raise ValueError(
75 f"A CAL-ADDRESS value may not contain CR or LF characters: {value!r}"
76 )
77 self = super().__new__(cls, value)
78 self.params = Parameters(params)
79 return self
80
81 def __repr__(self) -> str:
82 return f"vCalAddress('{self}')"
83
84 def to_ical(self) -> bytes:
85 return self.encode(DEFAULT_ENCODING)
86
87 @classmethod
88 def from_ical(cls, ical: str | bytes) -> Self:
89 return cls(ical)
90
91 def __eq__(self, other: object) -> bool:
92 """Compare calendar addresses by email address.
93
94 Two addresses are equal if their email addresses match
95 case-insensitively. The ``mailto:`` prefix is optional on either
96 side, so ``mailto:user@example.com`` equals ``user@example.com``.
97
98 A plain :class:`str` is converted to :class:`vCalAddress` before
99 comparing, so ``"me@example.com" == vCalAddress("MAILTO:me@example.com")``
100 is ``True``. This is what lets the common pattern
101 ``my_email in event.attendees`` work (see :pr:`1901`). If the
102 string cannot form a valid address (for example, it contains a newline),
103 comparison returns :data:`NotImplemented`, leaving the reflected
104 operation to decide.
105
106 Comparison against other types returns :data:`NotImplemented`.
107
108 Known limitation: hash consistency is guaranteed only between
109 two :class:`vCalAddress` instances. A plain :class:`str` hashes
110 by its literal value, so ``hash(vCalAddress(...))`` may differ
111 from ``hash(str(...))`` even when they compare equal. This is
112 accepted; see the discussion in :pr:`1901`.
113
114 See :issue:`1896`.
115 """
116 if isinstance(other, vCalAddress):
117 return self.email.lower() == other.email.lower()
118 if isinstance(other, str):
119 try:
120 other = vCalAddress(other)
121 except ValueError:
122 # for example, the string contains CR or LF and cannot be an address
123 return NotImplemented
124 return self == other
125 return NotImplemented
126
127 def __ne__(self, other: object) -> bool:
128 """Negation of :meth:`__eq__`.
129
130 This method is required because :class:`vCalAddress` extends
131 :class:`str`. Without it, ``!=`` would use :meth:`str.__ne__`
132 using plain string comparison and could disagree with
133 :meth:`__eq__`. For example, case-insensitively equal addresses would
134 be both ``==`` and ``!=`` at the same time.
135 """
136 result = self.__eq__(other)
137 if result is NotImplemented:
138 return result
139 return not result
140
141 def __hash__(self) -> int:
142 """Hash of the lowercased email address, consistent with :meth:`__eq__`."""
143 return hash(self.email.lower())
144
145 @property
146 def ical_value(self) -> str:
147 """The ``mailto:`` part of the address."""
148 return str(self)
149
150 @property
151 def uri(self) -> str:
152 """The calendar user address as a URI, usually the ``mailto:`` link.
153
154 This provides the same interface as :attr:`vUri.uri
155 <icalendar.prop.uri.vUri.uri>`.
156 Use :attr:`email` to get the address without ``mailto:``.
157
158 .. code-block:: pycon
159
160 >>> from icalendar import vCalAddress
161 >>> vCalAddress("mailto:jane_doe@example.com").uri
162 'mailto:jane_doe@example.com'
163 """
164 return self.ical_value
165
166 @property
167 def email(self) -> str:
168 """The email address without ``mailto:`` at the start."""
169 if self.lower().startswith("mailto:"):
170 return self[7:]
171 return str(self)
172
173 def has_email(self, email: str) -> bool:
174 """Check whether an email matches this calendar user.
175
176 The email can match either the calendar address or its ``EMAIL``
177 parameter.
178 Comparison ignores case and an optional ``mailto:`` prefix on either
179 address. This method does not validate email syntax.
180
181 Parameters:
182 email: Required. The address to compare, with or without ``mailto:``.
183
184 Returns:
185 bool: ``True`` if either address matches, otherwise ``False``.
186
187 Examples:
188 .. code-block:: pycon
189
190 >>> from icalendar import vCalAddress
191 >>> attendee = vCalAddress("mailto:calendar@example.com")
192 >>> attendee.EMAIL = "contact@example.com"
193 >>> attendee.has_email("CALENDAR@example.com")
194 True
195 >>> attendee.has_email("mailto:contact@example.com")
196 True
197 >>> attendee.has_email("other@example.com")
198 False
199
200 .. versionadded:: 0.0.0
201 """
202 normalized = email.casefold().removeprefix("mailto:")
203 return any(
204 address is not None
205 and address.casefold().removeprefix("mailto:") == normalized
206 for address in (self.ical_value, self.EMAIL)
207 )
208
209 from icalendar.param import (
210 CN,
211 CUTYPE,
212 DELEGATED_FROM,
213 DELEGATED_TO,
214 DIR,
215 EMAIL,
216 LANGUAGE,
217 MEMBER,
218 PARTSTAT,
219 ROLE,
220 RSVP,
221 SENT_BY,
222 VALUE,
223 )
224
225 DELEGATED_FROM = DELEGATED_FROM
226 """Specify the calendar users that delegated their participation.
227
228 This parameter can be specified on properties with a
229 CAL-ADDRESS value type. This parameter specifies those calendar
230 users that have delegated their participation in a group-scheduled
231 event or to-do to the calendar user specified by the property.
232 The individual calendar address parameter values MUST each be
233 specified in a quoted-string.
234 """
235
236 DELEGATED_TO = DELEGATED_TO
237 """Specify the calendar users to whom participation was delegated.
238
239 This parameter can be specified on properties with a
240 CAL-ADDRESS value type. This parameter specifies those calendar
241 users to whom participation in a group-scheduled event or to-do was
242 delegated by the calendar user specified by the property.
243 The individual calendar address parameter values MUST each be
244 specified in a quoted-string.
245 """
246
247 MEMBER = MEMBER
248 """Specify the group or list membership of a calendar user.
249
250 This parameter can be specified on properties with a
251 CAL-ADDRESS value type. The parameter identifies the groups or
252 list membership for the calendar user specified by the property.
253 The parameter value is either a single calendar address in a
254 quoted-string or a COMMA-separated list of calendar addresses,
255 each in a quoted-string. The individual calendar address
256 parameter values MUST each be specified in a quoted-string.
257 """
258
259 name = CN
260
261 @staticmethod
262 def _get_email(email: str) -> str:
263 """Extract email and add mailto: prefix if needed.
264
265 Handles case-insensitive mailto: prefix checking.
266
267 Parameters:
268 email: Email string that may or may not have mailto: prefix
269
270 Returns:
271 Email string with mailto: prefix
272 """
273 if not email.lower().startswith("mailto:"):
274 return f"mailto:{email}"
275 return email
276
277 @classmethod
278 def new(
279 cls,
280 email: str,
281 /,
282 cn: str | None = None,
283 cutype: str | None = None,
284 delegated_from: str | None = None,
285 delegated_to: str | None = None,
286 directory: str | None = None,
287 language: str | None = None,
288 partstat: str | None = None,
289 role: str | None = None,
290 rsvp: bool | None = None, # noqa: FBT001, RUF100
291 sent_by: str | None = None,
292 ) -> Self:
293 """Create a new vCalAddress with RFC 5545 parameters.
294
295 Creates a vCalAddress instance with automatic mailto: prefix handling
296 and support for all standard RFC 5545 parameters.
297
298 Parameters:
299 email: The email address (mailto: prefix added automatically if missing)
300 cn: Common Name parameter
301 cutype: Calendar user type (INDIVIDUAL, GROUP, RESOURCE, ROOM)
302 delegated_from: Email of the calendar user that delegated
303 delegated_to: Email of the calendar user that was delegated to
304 directory: Reference to directory information
305 language: Language for text values
306 partstat: Participation status (NEEDS-ACTION, ACCEPTED, DECLINED, etc.)
307 role: Role (REQ-PARTICIPANT, OPT-PARTICIPANT, NON-PARTICIPANT, CHAIR)
308 rsvp: Whether RSVP is requested
309 sent_by: Email of the calendar user acting on behalf of this user
310
311 Returns:
312 vCalAddress: A new calendar address with specified parameters
313
314 Raises:
315 TypeError: If email is not a string
316
317 Examples:
318 Basic usage:
319
320 >>> from icalendar.prop import vCalAddress
321 >>> addr = vCalAddress.new("test@test.com")
322 >>> str(addr)
323 'mailto:test@test.com'
324
325 With parameters:
326
327 >>> addr = vCalAddress.new("test@test.com", cn="Test User", role="CHAIR")
328 >>> addr.params["CN"]
329 'Test User'
330 >>> addr.params["ROLE"]
331 'CHAIR'
332 """
333 if not isinstance(email, str):
334 raise TypeError(f"Email must be a string, not {type(email).__name__}")
335
336 # Handle mailto: prefix (case-insensitive)
337 email_with_prefix = cls._get_email(email)
338
339 # Create the address
340 addr = cls(email_with_prefix)
341
342 # Set parameters if provided
343 if cn is not None:
344 addr.params["CN"] = cn
345 if cutype is not None:
346 addr.params["CUTYPE"] = cutype
347 if delegated_from is not None:
348 addr.params["DELEGATED-FROM"] = cls._get_email(delegated_from)
349 if delegated_to is not None:
350 addr.params["DELEGATED-TO"] = cls._get_email(delegated_to)
351 if directory is not None:
352 addr.params["DIR"] = directory
353 if language is not None:
354 addr.params["LANGUAGE"] = language
355 if partstat is not None:
356 addr.params["PARTSTAT"] = partstat
357 if role is not None:
358 addr.params["ROLE"] = role
359 if rsvp is not None:
360 addr.params["RSVP"] = "TRUE" if rsvp else "FALSE"
361 if sent_by is not None:
362 addr.params["SENT-BY"] = cls._get_email(sent_by)
363
364 return addr
365
366 def to_jcal(self, name: str) -> list:
367 """Return this property in jCal format."""
368 return [name, self.params.to_jcal(), self.VALUE.lower(), self.ical_value]
369
370 @classmethod
371 def examples(cls) -> list[Self]:
372 """Examples of vCalAddress."""
373 return [cls.new("you@example.org", cn="You There")]
374
375 @classmethod
376 def from_jcal(cls, jcal_property: list) -> Self:
377 """Parse jCal from :rfc:`7265`.
378
379 Parameters:
380 jcal_property: The jCal property to parse.
381
382 Raises:
383 ~icalendar.error.JCalParsingError: If the provided jCal is invalid.
384 """
385 JCalParsingError.validate_property(jcal_property, cls)
386 JCalParsingError.validate_value_type(jcal_property[3], str, cls, 3)
387 return cls(
388 jcal_property[3],
389 params=Parameters.from_jcal_property(jcal_property),
390 )
391
392 @classmethod
393 @from_xcal_wrapper
394 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
395 """Parse xCal from :rfc:`6321`.
396
397 Parameters:
398 xml: The XML to parse or a parser.
399
400 Raises:
401 ~error.XCalParsingError: If the provided xCal is invalid.
402 """
403 element = parser.parse_tag(cls.default_value)
404 return cls(element.get_xsd_token(), params=params)
405
406 def to_xcal(self, element: Element) -> None:
407 """The xCal representation of this property according to :rfc:`6321`."""
408 self.params.to_xcal(element)
409 element = SubElement(element, "cal-address")
410 element.text = self.ical_value
411
412
413__all__ = ["vCalAddress"]