Week 1 · Ingest anything › Day 4
Day 4. Extracting data from email
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
Dateheader. 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
- Write
sources/email_files.py: walklanding/email_inbox/*.eml, extract attachments, compute hashes, land toraw/retail_email/business_date=YYYY-MM-DD/<store>__<sha8>.<ext>. Parse store and date from the subjectDaily sales R104 14/09/2026(note: day first). - Add
message_key = sha256(raw email bytes)and a second manifest tableemail_manifest(message_key, sender, subject, received, n_attachments). - Reject unknown senders into
raw/_rejected/with a reason. - Write a test with two identical attachments in two different emails: one raw file, two email manifest rows.
- Stretch: build
sources/imap.pywithimap-toolsthat downloads messages since a watermark intolanding/email_inboxas.eml, so live mail and files share one parser. Test it against your practice Gmail. - 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?
Basic-auth IMAP is disabled for Exchange Online. Graph with app permissions, scoped by policy, is the supported path.
2. An email titled "Daily sales R104 14/09/2026" arrives on 16 Sept. What is its business date?
Business date comes from what the data is about, not when it arrived. And 14/09 is day-first.
3. Why use a watermark with an overlap instead of marking emails as read?
You avoid side effects on shared systems and stay correct when timestamps are messy.