Pipeline to Dashboardnihalsulaiman.com

Week 1 · Ingest anything › Day 4

Day 4. Extracting data from email

⏱ 4h · learn 70m · lab 130m · ship 25m · review 15m🏷 retail (store sales emails)Before: Day 3

By the end of today you can

  • Parse .eml files and pull CSV/XLSX attachments with Python's email package
  • Choose between IMAP, Microsoft Graph and the Gmail API, and explain OAuth
  • Design sender allow-lists, subject parsing and dedup for email feeds
  • Never mark, move or delete mail in a shared mailbox without agreement

1. Why this matters

In retail, restaurants and hotels, a surprising share of daily data still arrives as email attachments: store sales, supplier price lists, bank files, OTA statements. A reliable email extractor is one of the most practical skills you can show, and the easiest to get subtly wrong.

2. The anatomy of an email

An email is a tree of MIME parts. The top has headers (From, Subject, Date, Message-ID). Children are the text body, an HTML body, and attachments, each with a Content-Type and a filename, usually base64-encoded.

from email import policy
from email.parser import BytesParser
from pathlib import Path

def attachments(eml_path: Path):
    msg = BytesParser(policy=policy.default).parse(eml_path.open("rb"))
    meta = {"message_id": msg["Message-ID"], "from": str(msg["From"]),
            "subject": str(msg["Subject"]), "date": str(msg["Date"])}
    for part in msg.iter_attachments():
        name = part.get_filename()
        if name and name.lower().endswith((".csv", ".xlsx", ".xls")):
            yield meta, name, part.get_content()   # bytes for binary, str for text/csv

The course .eml files have no Message-ID (real ones do). Use a hash of the raw message bytes as your key when it's missing.

3. Three ways to reach a mailbox

Method Use when Auth Notes
IMAP (imaplib, imap-tools) Generic mail servers, Gmail with app passwords, small setups Password/app password or OAuth2 (XOAUTH2) Microsoft has turned off basic-auth IMAP for Exchange Online; there you need OAuth
Microsoft Graph API (msal + REST) Outlook / Microsoft 365 (most companies you'll join) App registration in Entra ID, client credentials, Mail.Read permission Can be scoped to one mailbox with an application access policy. Best practice for a service
Gmail API Google Workspace OAuth client or service account with domain delegation Good label and search support

A Graph call to list messages with attachments in a folder looks like this:

import msal, requests
app = msal.ConfidentialClientApplication(CLIENT_ID, authority=f"https://login.microsoftonline.com/{TENANT}",
                                         client_credential=CLIENT_SECRET)
token = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])["access_token"]
H = {"Authorization": f"Bearer {token}"}
url = (f"https://graph.microsoft.com/v1.0/users/{MAILBOX}/mailFolders/inbox/messages"
       "?$filter=hasAttachments eq true and receivedDateTime ge 2026-09-01T00:00:00Z&$top=50")
while url:
    page = requests.get(url, headers=H, timeout=30).json()
    for m in page["value"]:
        atts = requests.get(f"https://graph.microsoft.com/v1.0/users/{MAILBOX}/messages/{m['id']}/attachments",
                            headers=H, timeout=30).json()["value"]
        # each fileAttachment has name, contentType and base64 contentBytes
    url = page.get("@odata.nextLink")              # follow pagination until it's gone

No company mailbox? Practise for free

Create a free Microsoft 365 developer sandbox if you qualify, or a throwaway Gmail account with an app password and IMAP. Send yourself three of the course .eml files as forwards and pull them back with code. Never practise on a work mailbox.

4. Rules that keep an email feed honest

  • Allow-list senders ([email protected]) and parse identity from the sender and subject. Reject the rest into a review folder in your raw zone, never silently.
  • Business date from the subject or the file content, not the email Date header. Emails sent at 00:30 are about yesterday; resends come days later.
  • Dedup twice: by message key (same email fetched again) and by attachment hash (manager hits send twice).
  • Don't change the mailbox unless the owner agrees. Marking read or moving to a "processed" folder seems tidy but breaks people who use the same box. Use a server-side cursor instead: "received after my last watermark".
Maths and principle

Principle: a watermark is the largest key you've safely processed. Next run you ask only for keys above it, minus a safety overlap.

Equation: fetch where \(t_{\text{received}} > w - \delta\), then dedup. New watermark \(w' = \max(t_{\text{received}})\) over landed items.

Analogy: reading a book with a bookmark, re-reading the last page in case you dozed off. Where it breaks: clocks disagree between servers, and mail can be delivered late with an old timestamp. The overlap \(\delta\) (say 2 days) plus hash dedup covers both.

Try it: watermark is 2026-09-14 07:10, overlap 48h. What window do you fetch? (Everything received after 2026-09-12 07:10; dedup removes what you already have.)

The 5-year lens

Ask the business for a dedicated intake mailbox (bi-intake@) and a rule "one store per email, one file per email, subject format X". Half of email-pipeline pain is social, not technical. Also, measure who is late: a weekly table of each sender's median arrival time is the evidence that gets a late store fixed by operations, not by you staying up.

Lab: extract the retail emails

  1. Write sources/email_files.py: walk landing/email_inbox/*.eml, extract attachments, compute hashes, land to raw/retail_email/business_date=YYYY-MM-DD/<store>__<sha8>.<ext>. Parse store and date from the subject Daily sales R104 14/09/2026 (note: day first).
  2. Add message_key = sha256(raw email bytes) and a second manifest table email_manifest(message_key, sender, subject, received, n_attachments).
  3. Reject unknown senders into raw/_rejected/ with a reason.
  4. Write a test with two identical attachments in two different emails: one raw file, two email manifest rows.
  5. Stretch: build sources/imap.py with imap-tools that downloads messages since a watermark into landing/email_inbox as .eml, so live mail and files share one parser. Test it against your practice Gmail.
  6. Query the manifest: which store is missing on which dates? You should find R107's four-day gap.

Ship

feat(ingest): email attachment extractor with sender allow-list and double dedup.

Check yourself

1. Your company uses Microsoft 365. What is the recommended way for a scheduled service to read an intake mailbox?

2. An email titled "Daily sales R104 14/09/2026" arrives on 16 Sept. What is its business date?

3. Why use a watermark with an overlap instead of marking emails as read?

My notes