# Parse email addresses to enrich contacts

Source: https://developer.nylas.com/docs/cookbook/contacts/parse-email-addresses/

Every message that lands in a connected mailbox carries structured contact data: a sender, a list of recipients, and often a reply-to address. Most teams let that data rot in the inbox and ask users to type contacts in by hand. You can do better. Pull the participants off each message, normalize the addresses, and write them straight into your contact database.

This guide shows how to parse sender and recipient email addresses from messages with the Nylas Email API, then upsert each one as a contact so your CRM stays current without manual entry.

## How do I parse email addresses in Python to enrich a contact database?

Read messages with `GET /v3/grants/{grant_id}/messages`, then pull the `from`, `to`, `cc`, and `bcc` arrays off each message. Every entry is a `{name, email}` object, so no regex parsing is needed. Lowercase each address, dedupe, and write the result to `POST /v3/grants/{grant_id}/contacts`. One list call returns up to 50 messages per page.

The request below lists the most recent messages for a connected grant. Use the `limit` parameter to control page size, which defaults to 50 and caps at 200 per request. The response gives you parsed participant objects on every message, which is the data you'll turn into contacts.

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?limit=50' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

```python
from nylas import Client

nylas = Client(api_key="<NYLAS_API_KEY>")

messages = nylas.messages.list(
    "<NYLAS_GRANT_ID>",
    query_params={"limit": 50},
)

# Collect every address across from / to / cc / bcc
seen = {}
for message in messages.data:
    participants = (
        (message.from_ or [])
        + (message.to or [])
        + (message.cc or [])
        + (message.bcc or [])
    )
    for person in participants:
        address = (person.get("email") or "").strip().lower()
        if address:
            seen[address] = person.get("name") or ""

print(f"Found {len(seen)} unique addresses")
```

The `from_` attribute is spelled with a trailing underscore in Python because `from` is a reserved keyword. Each provider returns the same participant shape, so this loop works on Google, Microsoft, Yahoo, iCloud, IMAP, and Exchange accounts without per-provider branching.

## How do I pull sender and recipient addresses from a message?

To extract contacts from a thread, filter the message list to one conversation and read the participants off each message. Pass a `thread_id` to `GET /v3/grants/{grant_id}/messages`, or use `any_email` to find every message touching a given address. The `any_email` parameter accepts up to 25 comma-separated addresses per request, which covers most threads in one call.

The request below pulls every message tied to a thread, so you capture participants who joined partway through. Threads accumulate addresses as people are added to the conversation, so reading the whole thread catches recipients a single message would miss. Each match still returns the same `{name, email}` participant objects.

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?thread_id=<THREAD_ID>&limit=50' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

```python
thread_messages = nylas.messages.list(
    "<NYLAS_GRANT_ID>",
    query_params={"thread_id": "<THREAD_ID>", "limit": 50},
)

contacts = {}
for message in thread_messages.data:
    for person in (message.to or []) + (message.cc or []):
        address = (person.get("email") or "").strip().lower()
        if address and address not in contacts:
            contacts[address] = person.get("name") or ""
```

One practical filter: skip your own grant's address and known internal domains before you upsert. Otherwise you'll create a contact record for the mailbox owner and every teammate on the thread. A small allowlist of domains to ignore keeps the database focused on external people, which is what a CRM actually wants.

If you also want names and titles from signature blocks rather than just the header participants, that's a separate parsing job on the message body. The [extract contacts from email threads](/docs/cookbook/contacts/extract-contacts-from-email/) guide covers signature parsing, which complements the participant approach here. Header parsing gives you a clean address on all 50 messages a page returns; signature parsing only helps on the subset of business messages that actually carry a signature block.

## How do I manage email contacts programmatically for a CRM SaaS product?

Write each parsed address to `POST /v3/grants/{grant_id}/contacts`. The endpoint takes an `emails` array of `{email, type}` objects plus optional fields like `given_name`, `surname`, and `company_name`. There's no native "upsert" verb, so check for an existing contact with the `email` filter on the list endpoint first, then create only when nothing matches.

The call below creates a contact from a parsed address. Set `source` to `address_book` so the record lives in the user's editable contacts rather than the read-only `inbox` source the API derives automatically. Splitting the display name into `given_name` and `surname` keeps records consistent across the providers you sync.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/contacts' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "given_name": "Leyah",
    "surname": "Miller",
    "company_name": "Acme Robotics",
    "source": "address_book",
    "emails": [{ "email": "leyah.miller@example.com", "type": "work" }]
  }'
```

```python
def upsert_contact(address, name):
    existing = nylas.contacts.list(
        "<NYLAS_GRANT_ID>",
        query_params={"email": address, "limit": 1},
    )
    if existing.data:
        return existing.data[0]

    given, _, surname = name.partition(" ")
    return nylas.contacts.create(
        "<NYLAS_GRANT_ID>",
        request_body={
            "given_name": given or address.split("@")[0],
            "surname": surname,
            "source": "address_book",
            "emails": [{"email": address, "type": "work"}],
        },
    ).data

for address, name in seen.items():
    upsert_contact(address, name)
```

For a multi-tenant SaaS product, run this loop per grant on a schedule and store a watermark so you only process new messages. The contacts endpoint returns `429` when you exceed the per-account rate limit, so batch your writes and back off on that status. The list-then-create check above prevents duplicate records, which is the failure mode that wrecks a contact database first.

## Nylas vs parsing raw email headers yourself

You could skip the API and parse `From` and `To` headers out of raw MIME with Python's `email.utils.parseaddr`. That works, but you own the OAuth flow, token refresh, IMAP and Graph differences, and the edge cases of malformed headers and encoded display names. The table compares the two paths on the work contact enrichment actually requires.

| Task | Raw header parsing | **Nylas Email API** |
| --- | --- | --- |
| **Auth** | Per-provider OAuth, IMAP, and Graph yourself | 1 grant, OAuth handled for you |
| **Address parsing** | `parseaddr` plus MIME decoding edge cases | Parsed `{name, email}` objects |
| **Provider coverage** | Build Gmail, Graph, and IMAP separately | Google, Microsoft, Yahoo, iCloud, IMAP, Exchange |
| **Write contacts** | None, build your own store | `POST /v3/grants/{grant_id}/contacts` |
| **Thread context** | Reassemble threads by header | `thread_id` and `any_email` filters |

Raw parsing is the right call when you already run your own mail server and never touch a hosted provider. The moment you add Gmail or Microsoft 365, the unified read path saves you two OAuth integrations and a pile of header-decoding code. Python's [`email.utils`](https://docs.python.org/3/library/email.utils.html) module documents the parsing rules if you go that route.

There's a real cost on the hosted side too. The list endpoint caps each page at 200 messages (50 by default), so backfilling a year of history means paging in a loop and respecting the `429` rate limit. If you only ever read a single mailbox you own, parsing raw MIME locally has no network round trips and no rate ceiling. The API earns its place once you cross more than one provider or want parsed participant objects without writing a decoder for RFC 2047 encoded display names.

## What's next

- [List and sync contacts](/docs/cookbook/contacts/list-and-sync-contacts/) for the inbox-derived contact source and sync details
- [Create and update contacts](/docs/cookbook/contacts/create-and-update-contacts/) for the full contact field reference and update flow
- [Recipient autocomplete](/docs/cookbook/contacts/recipient-autocomplete/) to surface these enriched contacts in a compose UI
- [Getting started with Nylas](/docs/v3/getting-started/) to create a project, connector, and your first grant