diff --git a/routes/contacts_routes.py b/routes/contacts_routes.py index 7ba32d47a..81024ed6c 100644 --- a/routes/contacts_routes.py +++ b/routes/contacts_routes.py @@ -150,6 +150,14 @@ def _vunesc(value: str) -> str: def _parse_vcards(text: str) -> List[Dict]: """Parse a stream of vCards into dicts with name, email, phone.""" + # Unfold RFC 6350 3.2 line folding first: a CRLF/LF followed by a single + # space or tab is a continuation of the previous logical line. Real + # CardDAV servers (Radicale, iCloud, Apple/Google) fold long EMAIL / FN / + # PHOTO lines, and splitting on raw newlines without unfolding dropped the + # continuation (e.g. "...@example\n .com" lost the ".com"), truncating the + # email/name. + text = re.sub(r"\r\n[ \t]", "", text or "") + text = re.sub(r"\n[ \t]", "", text) contacts = [] for block in re.split(r"BEGIN:VCARD", text): if not block.strip(): diff --git a/tests/test_vcard_unfolding.py b/tests/test_vcard_unfolding.py new file mode 100644 index 000000000..fda680338 --- /dev/null +++ b/tests/test_vcard_unfolding.py @@ -0,0 +1,45 @@ +"""vCard parsing must unfold RFC 6350 folded lines. + +CardDAV servers fold logical lines longer than 75 octets onto continuation +lines that begin with a space/tab. _parse_vcards split on raw newlines +without unfolding, so a folded EMAIL/FN line lost its continuation (a long +address like ...@exampledomain.com was stored as ...@exampledomain), +silently corrupting the contact. +""" +from routes.contacts_routes import _parse_vcards + + +def test_folded_email_is_reassembled(): + vcard = ( + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "FN:John Doe\r\n" + "EMAIL;TYPE=INTERNET:john.doe.with.a.very.long.local.part@exampledomain\r\n" + " .com\r\n" + "END:VCARD\r\n" + ) + contacts = _parse_vcards(vcard) + assert len(contacts) == 1 + assert contacts[0]["emails"] == [ + "john.doe.with.a.very.long.local.part@exampledomain.com" + ] + + +def test_folded_display_name_is_reassembled(): + vcard = ( + "BEGIN:VCARD\n" + "FN:A Very Long Display Name That The Server\n" + " Decided To Fold\n" + "EMAIL:x@y.com\n" + "END:VCARD\n" + ) + c = _parse_vcards(vcard)[0] + assert c["name"] == "A Very Long Display Name That The Server Decided To Fold" + + +def test_unfolded_vcard_still_parses(): + vcard = "BEGIN:VCARD\nFN:Jane\nEMAIL:jane@z.com\nTEL:+15550001\nEND:VCARD\n" + c = _parse_vcards(vcard)[0] + assert c["name"] == "Jane" + assert c["emails"] == ["jane@z.com"] + assert c["phones"] == ["+15550001"]