Originally posted on Hashnode — full version and any follow-up deep-dives live here.
LinkedIn has no API for "someone accepted my connection request" or "a connection changed jobs." What it does have is email notifications. This post covers what I learned building a Python + IMAP + Airtable pipeline that parses those emails directly, including the undocumented headers and template variants that made it actually reliable.
The wrong starting assumption
I assumed one email template per event type. Wrong — LinkedIn has at least three templates just for "connection accepted":
Single-connection, HTML-based
Batch/digest, plain-text, for multiple acceptances at once
A "network digest" email that isn't about connections at all — it bundles job-change alerts, post reactions, and search-appearance notices into one message
None of this is documented. Found entirely by pulling raw MIME source (Show original in Gmail).
The header that made classification reliable
X-LinkedIn-Class: INVITE-ACCEPT
X-LinkedIn-Template: email_accept_invite_digest_02
X-LinkedIn-Class is invisible in the rendered email but present in every message — a clean, stable signal for event type. X-LinkedIn-Template identifies the exact variant, which is what let me route between the single and batch connection-accept parsers:
python
template = msg.get("X-LinkedIn-Template", "")
if "digest" in template.lower():
parsed_list = parse_connection_accept_digest(text_body)
else:
parsed = parse_connection_accept_email(html_body)
Check headers before you write a single regex against visible content — vendors often leave more structure in there than the UI exposes.
Parsing plain text over HTML (mostly)
For the digest emails, plain text turned out to be far more parseable than the deeply-nested HTML tables — LinkedIn's plain-text part cleanly separates notifications with blank lines, and profile slugs are recoverable straight from the raw URLs sitting after each block's CTA text:
python
def extract_profile_slug_from_compose_url(compose_url):
match = re.search(r"messaging/compose/([^/]+)/", compose_url)
return match.group(1) if match else None
But the single-connection template flipped this — its plain-text part didn't include a profile link at all, only the HTML had a data-test-id marker precise enough to extract it. Lesson: check both MIME parts, use whichever has what you need — don't assume one is always better.
Classify, flag, never guess
Digest emails bundle unrelated notification types in one message. My rule for anything that didn't match a known pattern: log it with a Needs Review status and the raw block text, never silently drop it or force-fit it into the wrong bucket.
python
return {"type": "needs_review", "raw_text": block[:1000]}
Templates drift. Build the "I don't recognize this" path on day one, not as an afterthought.
Performance: two-stage IMAP fetch
First version fetched full raw email bodies for every linkedin.com sender just to check one header — slow on any inbox with unrelated LinkedIn mail. Fix: check headers first, fetch bodies only for matches.
python
status, msg_data = conn.fetch(msg_id, "(BODY.PEEK[HEADER])")
...check X-LinkedIn-Class here...
only THEN, for matches:
status, msg_data = conn.fetch(msg_id, "(BODY.PEEK[])")
BODY.PEEK instead of RFC822 matters for a second reason: RFC822 silently marks a message as read as a side effect of fetching it — a side effect you don't want happening during a "just checking" step.
Read-state as a designed decision, not an accident
Once fetches don't auto-mark-read, you decide explicitly:
Processed successfully → mark read
Failed to parse → leave unread (cheap "check this manually" signal, and it resurfaces next run)
Get this backwards and either your failures vanish silently, or every run re-scans your whole inbox history, getting slower as it grows.
"Already exists" isn't boolean
When a job-change alert fires for someone already in the table, is that a duplicate or new info to apply? Ended up treating it as new info: if a headline changed, the row gets updated rather than staying stale.
Takeaways
Read raw MIME source before writing any parser — the rendered view hides the useful signals
Assume more format variants exist than you've seen; build the unknown-input path first
Fetch cheap (headers) before expensive (bodies)
Read/unread state and dedup logic are part of your data model, not plumbing
Flag, don't guess — a confident wrong parse is worse than an honest "needs review"
Happy to go deeper on any piece of this in the comments — schema design, the Airtable API side, or the full parser code.
Top comments (0)