Our inbox gets invoices as plain emails: some in plain text, some in HTML, some with the amount written 1.234,56 and some with $48.00. Copying them into a spreadsheet by hand takes a few minutes per invoice and goes wrong in exactly the boring places: a wrong digit, a wrong date, a forgotten vendor.
Below is a script that reads a folder of .eml files (export them from any mail client, or drag them out of the inbox) and writes one CSV row per email: vendor, invoice number, date, amount, currency. It uses only the Python standard library, so there is nothing to install.
How it works, and the decisions that matter:
- The vendor comes from the From header's display name, falling back to the domain.
- The invoice number is whatever follows "Invoice", "Invoice no.", "Invoice #" or "Invoice number", in the subject or the body.
- The amount is the last "total", "total due", "amount due" or "grand total" in the body. The last one wins because the final line of an invoice is usually the real total. "Subtotal" is deliberately not matched.
- Number formats:
1.234,56,1,234.56,12,50and48.00all become plain numbers (1234.56,1234.56,12.50,48.00). - HTML emails are stripped to text before matching.
- Anything it cannot read gets
needs_review = yesinstead of a guessed value. A wrong number in a ledger is worse than an empty cell.
#!/usr/bin/env python3 """Turn a folder of invoice emails (.eml) into one CSV. Standard library only.""" import csv, re, sys, html from datetime import datetime from email import policy from email.parser import BytesParser from pathlib import Path MONEY = r"(?P<cur>[$€£]|USD|EUR|GBP)?\s*(?P<amt>\d{1,3}(?:[.,\s]\d{3})*(?:[.,]\d{2})|\d+(?:[.,]\d{2})?)\s*(?P<cur2>USD|EUR|GBP|[$€£])?" NUM_RE = re.compile(r"invoice\s*(?:no\.?|number|#|id)?\s*[:#]?\s*([A-Z0-9][A-Z0-9\-/]{2,})", re.I) TOTAL_RE = re.compile(r"(?<![A-Za-z])(?:grand\s+total|total\s+due|amount\s+due|total)\s*[:\-]?\s*" + MONEY, re.I) DATE_RES = [ (re.compile(r"\b(\d{4}-\d{2}-\d{2})\b"), "%Y-%m-%d"), (re.compile(r"\b(\d{1,2}\.\d{1,2}\.\d{4})\b"), "%d.%m.%Y"), (re.compile(r"\b([A-Z][a-z]{2,8} \d{1,2}, \d{4})\b"), "%B %d, %Y"), ] CUR = {"$": "USD", "€": "EUR", "£": "GBP"} def body_text(msg): part = msg.get_body(preferencelist=("plain", "html")) if part is None: return "" text = part.get_content() if part.get_content_type() == "text/html": text = html.unescape(re.sub(r"<[^>]+>", " ", text)) return re.sub(r"[ \t\r\f\v]+", " ", text) def to_amount(s): s = s.replace(" ", "") if "," in s and "." in s: # 1.234,56 or 1,234.56 dec = "," if s.rfind(",") > s.rfind(".") else "." s = s.replace("." if dec == "," else ",", "").replace(dec, ".") elif "," in s: # 12,50 -> decimal comma s = s.replace(",", ".") return float(s) def first_date(text): for rx, fmt in DATE_RES: m = rx.search(text) if m: try: return datetime.strptime(m.group(1), fmt).strftime("%Y-%m-%d") except ValueError: pass return "" def parse(path): msg = BytesParser(policy=policy.default).parse(open(path, "rb")) text = body_text(msg) subj = str(msg["subject"] or "") blob = subj + "\n" + text num = NUM_RE.search(blob) totals = [m for m in TOTAL_RE.finditer(text)] row = {"file": path.name, "vendor": "", "invoice_no": "", "date": "", "amount": "", "currency": "", "needs_review": ""} name, addr = (msg["from"].addresses[0].display_name, msg["from"].addresses[0].addr_spec) if msg["from"] else ("", "") row["vendor"] = name or addr.split("@")[-1] if num: row["invoice_no"] = num.group(1).strip("-/") row["date"] = first_date(text) or (msg["date"].datetime.strftime("%Y-%m-%d") if msg["date"] else "") if totals: m = totals[-1] # the last "total" wins: it is usually the final line row["amount"] = f"{to_amount(m.group('amt')):.2f}" cur = m.group("cur") or m.group("cur2") or "" row["currency"] = CUR.get(cur, cur.upper()) row["needs_review"] = "" if row["invoice_no"] and row["amount"] else "yes" return row def main(folder, out="invoices.csv"): rows = [parse(p) for p in sorted(Path(folder).glob("*.eml"))] with open(out, "w", newline="", encoding="utf-8-sig") as f: w = csv.DictWriter(f, fieldnames=["file", "vendor", "invoice_no", "date", "amount", "currency", "needs_review"]) w.writeheader(); w.writerows(rows) print(f"{len(rows)} emails -> {out}, {sum(1 for r in rows if r['needs_review'])} need review") if __name__ == "__main__": if len(sys.argv) < 2: sys.exit("usage: python invoices2csv.py FOLDER [out.csv]") main(*sys.argv[1:3]) Run it on a folder of emails:
python invoices2csv.py eml out.csv 4 emails -> out.csv, 1 need review The CSV that came out of four test emails (a plain-text invoice, an invoice with a 1.234,56 total, an HTML receipt with a subtotal line, and one email that is not an invoice at all):
file,vendor,invoice_no,date,amount,currency,needs_review a.eml,Acme Hosting,INV-2041,2026-10-05,48.00,USD, b.eml,Northwind Office Supplies,2026/118,2026-10-06,1234.56,EUR, c.eml,Cloud Tools,CT-77781,2026-10-07,108.00,GBP, d.eml,Random Person,,2026-10-08,,,yes The file is written with a BOM, so Excel shows accented names correctly.
Known limits, so you do not trust it more than you should:
- PDF attachments are not read. If the invoice only exists as a PDF, the script sees an empty email and flags it.
- Vendors with unusual wording ("Payable", "Balance") need one more pattern in
TOTAL_RE. Add it and re-run; the flagged rows show where. - It does not check that an invoice is real or unpaid. Treat the CSV as data entry done for you, and read every row marked
yes.
To run it on a whole mailbox, replace the folder loop with imaplib and feed each message to BytesParser; the parsing part stays the same.
More ready-to-use prompts and scripts: https://swarmagency.shop/kit.html
S.W.A.R.M / swarm_agency