diff --git a/routes/contacts_routes.py b/routes/contacts_routes.py index e045a3a..409184f 100644 --- a/routes/contacts_routes.py +++ b/routes/contacts_routes.py @@ -130,21 +130,28 @@ def _parse_vcards(text: str) -> List[Dict]: contact = {"name": "", "emails": [], "phones": [], "uid": ""} for line in block.split("\n"): line = line.strip() - if line.startswith("FN:") or line.startswith("FN;"): - contact["name"] = _vunesc(line.split(":", 1)[1]) if ":" in line else "" - elif line.startswith("EMAIL"): + # Strip an optional RFC 6350 group prefix (e.g. "item1.EMAIL;...") + # that Apple Contacts / iCloud / many CardDAV servers emit by + # default — without this the property-name checks below miss those + # lines and silently drop the email / phone. The group token only + # precedes the property name, so it is safe to strip for matching + # and value extraction, and a no-op for non-grouped lines. + name_part = re.sub(r"^[A-Za-z0-9-]+\.", "", line, count=1) + if name_part.startswith("FN:") or name_part.startswith("FN;"): + contact["name"] = _vunesc(name_part.split(":", 1)[1]) if ":" in name_part else "" + elif name_part.startswith("EMAIL"): # Handle EMAIL:foo@bar OR EMAIL;TYPE=...:foo@bar OR EMAIL;PREF=1:foo@bar - if ":" in line: - email_addr = _vunesc(line.split(":", 1)[1]) + if ":" in name_part: + email_addr = _vunesc(name_part.split(":", 1)[1]) if email_addr and email_addr not in contact["emails"]: contact["emails"].append(email_addr) - elif line.startswith("TEL"): - if ":" in line: - phone = _vunesc(line.split(":", 1)[1]) + elif name_part.startswith("TEL"): + if ":" in name_part: + phone = _vunesc(name_part.split(":", 1)[1]) if phone and phone not in contact["phones"]: contact["phones"].append(phone) - elif line.startswith("UID:"): - contact["uid"] = _vunesc(line[4:]) + elif name_part.startswith("UID:"): + contact["uid"] = _vunesc(name_part[4:]) if contact["name"] or contact["emails"]: contacts.append(contact) return contacts diff --git a/tests/test_contacts_vcard_parse.py b/tests/test_contacts_vcard_parse.py new file mode 100644 index 0000000..32140cb --- /dev/null +++ b/tests/test_contacts_vcard_parse.py @@ -0,0 +1,38 @@ +"""Regression: _parse_vcards must read Apple/iCloud item-grouped properties. + +RFC 6350 property groups (the default emitted by Apple Contacts.app / iCloud and +many CardDAV servers) prefix the property name with a group token, e.g. +`item1.EMAIL;type=pref:jane@example.com`. The parser matched property names with +a bare `line.startswith("EMAIL")` / `"TEL"` / `"FN:"`, so grouped lines never +matched and the email / phone were silently dropped — breaking contact search by +email, the email-composer autocomplete, and vCard/CSV export round-trips for any +address book synced from Apple. +""" +from routes.contacts_routes import _parse_vcards + + +def test_apple_item_grouped_properties_parsed(): + vcf = ( + "BEGIN:VCARD\nVERSION:3.0\nFN:Jane Doe\n" + "item1.EMAIL;type=INTERNET;type=pref:jane@example.com\n" + "item2.TEL;type=CELL;type=pref:+15550100\n" + "UID:abc-123\nEND:VCARD\n" + ) + c = _parse_vcards(vcf)[0] + assert c["emails"] == ["jane@example.com"] + assert c["phones"] == ["+15550100"] + assert c["uid"] == "abc-123" + + +def test_plain_ungrouped_properties_still_parsed(): + vcf = ( + "BEGIN:VCARD\nVERSION:3.0\nFN:John Smith\n" + "EMAIL;TYPE=INTERNET:john@example.com\n" + "TEL;TYPE=CELL:+15550199\n" + "UID:xyz\nEND:VCARD\n" + ) + c = _parse_vcards(vcf)[0] + assert c["name"] == "John Smith" + assert c["emails"] == ["john@example.com"] + assert c["phones"] == ["+15550199"] + assert c["uid"] == "xyz"