MEGAINFRA-WEBAPP/scripts/po_email_intake.py
DEls 41ff08742c fix(po-email): recognise a site named with an instance number
MEGCPO9566's subject reads "MEGCREQ11976 (Equestria 215)". A site name with
a bare instance number was not recognised as a site reference, so the PO
fell through the whole name chain and came back as "no MEGA site name found"
rather than naming the site it wanted.

A bracketed name with an instance number is now a site reference, and a
text whose bracketed names all resolve is not flagged at all.
2026-11-10 10:12:19 +02:00

674 lines
25 KiB
Python
Executable file

#!/usr/bin/env python3
"""Hourly PO email intake for the MEGA mailbox.
Reads ai@megainfra.co.za through Mail.app (AppleScript), finds purchase-order
documents in new messages, checks each one against the purchase_orders collection
for duplicates, and files the ones that are new into the same pending queue the
browser upload uses -- so the "MEGA - Purchase Order Ingest" n8n workflow OCRs and
finalises them.
Safe to run as often as you like:
* every message that has been looked at is remembered in the state file;
* every PO is checked by number and by file hash before anything is created;
* a PO whose site cannot be resolved is never filed -- it is reported for review.
Usage:
python3 scripts/po_email_intake.py # check and file
python3 scripts/po_email_intake.py --dry-run # report only, create nothing
python3 scripts/po_email_intake.py --force # re-check every inbox message
Run it from anywhere; the paths resolve from this file.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import urllib.parse
import uuid
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from pb_env import PB, http, superuser_token # noqa: E402
MAIL_ACCOUNT = "ai@megainfra.co.za"
INBOX = "Inbox"
STATE_DIR = Path(__file__).resolve().parent / "data" / "po-email-intake"
STATE_FILE = STATE_DIR / "state.json"
LAST_RUN_FILE = STATE_DIR / "last-run.json"
RUN_LOG = STATE_DIR / "runs.jsonl"
SITES_FILE = STATE_DIR / "sites.json"
PDF_BIN = "/opt/homebrew/bin/pdftotext"
DOC_TYPES = {"application/pdf"}
# A trailing instance qualifier on a site name: "Munyaka 2", "Nellmapius Ext 25".
QUALIFIER = re.compile(r"\s+(?:ext\.?\s*\d*|\d+)$", re.I)
# MEGA does not always write a site's name the way the app has it. These are
# the spellings seen on real POs, mapped to the site they mean:
# "West Park EXT6" -> Wespark (MEGCPO9554, 2026-11-06)
# "Nellmpius EXT25" -> Nellmapius Ext 25 (MEGCPO9475, 2026-11-05)
SITE_ALIASES = {
"WESTPARK": "Wespark",
"NELLMPIUS": "Nellmapius Ext 25",
}
# A site name a PO writes for itself in brackets: "(Willow Park EXT98)".
BRACKETED = re.compile(r"\(([^)]{3,40})\)")
# --------------------------------------------------------------------------- helpers
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z")
def norm(value: str) -> str:
"""Uppercase and strip everything that is not a letter or a digit."""
return re.sub(r"[^A-Z0-9]", "", (value or "").upper())
def osa(script: str, timeout: int = 180) -> str:
"""Run an AppleScript and return stdout."""
proc = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=timeout,
)
if proc.returncode != 0:
raise RuntimeError(f"osascript failed: {proc.stderr.strip()[:400]}")
return proc.stdout
def ensure_mail_running() -> None:
running = subprocess.run(
["pgrep", "-x", "Mail"], capture_output=True, text=True
).returncode == 0
if running:
return
subprocess.run(["open", "-a", "Mail"], check=False)
for _ in range(30):
time.sleep(2)
if subprocess.run(["pgrep", "-x", "Mail"], capture_output=True).returncode == 0:
time.sleep(5) # let the Exchange account finish syncing
return
raise RuntimeError("Mail.app did not start")
# --------------------------------------------------------------------------- mail
def inbox_messages() -> list[dict]:
"""List the inbox: id, subject, sender, received date."""
script = f'''
tell application "Mail"
set acct to account "{MAIL_ACCOUNT}"
set out to ""
set n to count of messages of mailbox "{INBOX}" of acct
repeat with i from 1 to n
set m to message i of mailbox "{INBOX}" of acct
set out to out & (message id of m) & "\\t" & ((date received of m) as string) & "\\t" & (sender of m) & "\\t" & (subject of m) & linefeed
end repeat
return out
end tell
'''
raw = osa(script)
messages = []
for line in raw.splitlines():
if not line.strip():
continue
parts = line.split("\t")
if len(parts) < 4:
continue
messages.append(
{
"id": parts[0].strip(),
"received": parts[1].strip(),
"sender": parts[2].strip(),
"subject": parts[3].strip(),
}
)
return messages
def raw_source(message_id: str) -> bytes:
"""Raw RFC822 source of one message (attachments included, base64)."""
script = f'''
tell application "Mail"
set mb to mailbox "{INBOX}" of account "{MAIL_ACCOUNT}"
set m to (first message of mb whose message id is "{message_id}")
return source of m
end tell
'''
return osa(script).encode("utf-8", "replace")
def mark_handled(message_id: str) -> None:
"""Mark the message read and flagged so it reads as handled in Mail and Outlook."""
script = f'''
tell application "Mail"
set mb to mailbox "{INBOX}" of account "{MAIL_ACCOUNT}"
set m to (first message of mb whose message id is "{message_id}")
set read status of m to true
set flagged status of m to true
end tell
'''
try:
osa(script)
except Exception as exc: # noqa: BLE001 - a failed flag must not lose the PO
print(f" ! could not flag message {message_id}: {exc}")
# --------------------------------------------------------------------------- documents
def pdf_attachments(source: bytes) -> list[tuple[str, bytes]]:
"""PDF attachments from a raw MIME message, as (filename, bytes)."""
import email
msg = email.message_from_bytes(source)
out = []
for part in msg.walk():
if part.get_content_type() not in DOC_TYPES:
continue
if part.get_filename() is None and part.get("Content-Disposition") is None:
continue # inline part, not a document
data = part.get_payload(decode=True) or b""
name = part.get_filename() or "document.pdf"
out.append((name, data))
return out
def header_of(source: bytes, name: str) -> str:
import email
msg = email.message_from_bytes(source)
return (msg.get(name) or "").strip()
def parse_pdf(pdf: bytes) -> dict:
"""Pull the header fields out of a MEGA PO document."""
tmp = STATE_DIR / f"tmp-{uuid.uuid4().hex}.pdf"
tmp.write_bytes(pdf)
try:
text = subprocess.run(
[PDF_BIN, "-layout", str(tmp), "-"],
capture_output=True,
text=True,
timeout=120,
).stdout
finally:
tmp.unlink(missing_ok=True)
parsed = parse_po_text(text)
parsed["text"] = text
return parsed
def parse_po_text(text: str) -> dict:
"""Pull the header fields out of the text of a MEGA PO document."""
def grab(pattern: str, flags=re.I | re.S) -> str:
m = re.search(pattern, text, flags)
return " ".join(m.group(1).split()) if m else ""
po_number = grab(r"Purchase Order\(s\)\s*#\s*([A-Za-z0-9\-]+)")
# The subject line carries the site, e.g. "MEGCREQ11928 a CO on MEGCREQ11927 (Nellmapius EXT25)"
subject_segment = grab(r"Subject\s*:\s*(.*?)\s*Description\s*:")
if not subject_segment:
subject_segment = grab(r"Subject\s*:\s*(.*?)\s*To\s*:")
# MEGA's own project reference on each line (e.g. "C26/27, 0001"). Only the
# line-item block is scanned, so a code quoted in the letterhead -- or the
# "Head office" in the contact block -- cannot be mistaken for the site's code.
line_items = text.split("Cost Code", 1)[-1] if "Cost Code" in text else text
cost_codes = sorted({m.group(1) for m in re.finditer(r"\b([A-Z]\d{2}/\d{2})\b", line_items)})
return {
"po_number": po_number.upper(),
"subject_line": subject_segment,
"supplier": grab(r"\bTo\s*:\s*(.*?)\s*(?:PO Creation Date|Code\s*:|Req No)"),
"description": grab(r"Description\s*:\s*(.*?)\s*(?:PO Creation Date|To\s*:|Req No)"),
"order_date": grab(r"PO Creation Date\s*:\s*([0-9/]+)"),
"required_by": grab(r"Required By\s*:\s*([0-9/]+)"),
"cost_codes": cost_codes,
}
# --------------------------------------------------------------------------- sites
def load_sites(token: str) -> list[dict]:
"""MEGA's sites (id, name, project code) — cached for a day."""
if SITES_FILE.exists():
try:
cached = json.loads(SITES_FILE.read_text())
if time.time() - cached.get("fetched_at", 0) < 86400:
return cached["sites"]
except Exception: # noqa: BLE001
pass
code, body = http(
"GET",
"/api/collections/sites/records?perPage=200&fields=id,name,project_code",
token=token,
)
if code != 200:
return []
sites = [
{"id": r.get("id"), "name": r.get("name") or "", "code": r.get("project_code") or ""}
for r in body.get("items", [])
]
SITES_FILE.parent.mkdir(parents=True, exist_ok=True)
SITES_FILE.write_text(json.dumps({"fetched_at": time.time(), "sites": sites}, indent=1))
return sites
def name_variants(name: str) -> list[str]:
"""The site name, and the same name without its instance qualifier.
MEGA writes "Munyaka 2" but a PO may say only "Munyaka", and "Nellmapius
Ext 25" may appear as "Nellmapius". The site is still named.
"""
out = [name.strip()]
stem = name.strip()
while True:
stripped = QUALIFIER.sub("", stem)
if stripped == stem or not stripped:
break
stem = stripped
out.append(stem)
return out
def resolve_site_by_name(text: str, sites: list[dict]) -> dict | None:
"""The MEGA site whose name is written in the text.
This is what a PO is booked against. The project code on the PO's lines is
not used: it is a cost allocation, not a statement of where the work is
going -- MEGCPO9550 is a Cedar Gate delivery with four of its seven lines
billed to Castle Gate's project code. The name is written on the PO
itself, in its subject line or its description.
A PO that names two sites is ambiguous, and is left for a person rather
than booked against one of them.
"""
flat = norm(text)
if not flat:
return None
matched: list[dict] = []
for site in sites:
for variant in name_variants(site.get("name") or ""):
key = norm(variant)
if len(key) >= 4 and key in flat:
matched.append(site)
break
for alias, target in SITE_ALIASES.items():
key = norm(alias)
if len(key) >= 4 and key in flat:
for site in sites:
if site.get("name") == target:
matched.append(site)
break
unique = {site.get("id"): site for site in matched}
if len(unique) == 1:
return next(iter(unique.values()))
return None
def site_vocabulary(sites: list[dict]) -> set[str]:
"""The words our site names are made of, plus the spellings we alias."""
words: set[str] = set()
for site in sites:
words.update(
word
for word in re.split(r"[^A-Za-z0-9]+", (site.get("name") or "").lower())
if len(word) >= 4
)
for alias in SITE_ALIASES:
words.update(
word
for word in re.split(r"[^A-Za-z0-9]+", alias.lower())
if len(word) >= 4
)
return words
def names_a_site(candidate: str, vocabulary: set[str]) -> bool:
"""Is this bracketed phrase MEGA writing the site, rather than a code?
"EXT98" is MEGA's own way of marking an instance of a site, a name with an
instance number -- "(Equestria 215)" -- is one too, and a phrase whose
words are all ours is a site name. "(DN150)", "(T16)" and
"(Union fitting)" are none of those, so they are not site names.
"""
tokens = [t for t in re.split(r"[^A-Za-z0-9]+", candidate.lower()) if t]
if not tokens:
return False
if any(re.fullmatch(r"ext\d+", t) for t in tokens):
return True
words = [t for t in tokens if len(t) >= 4 and t.isalpha()]
if any(re.fullmatch(r"\d{2,}", t) for t in tokens):
return bool(words)
return bool(words) and all(word in vocabulary for word in words)
def unknown_site_name(text: str, sites: list[dict]) -> str | None:
"""The site a PO names in brackets that is not one of our sites.
MEGCPO9564's own subject reads "MEGCREQ11972 (Willow Park EXT98)" but the
email it arrived in was titled "RE: Rock Grid PO Cedar Gate" -- a thread
title left over from an earlier PO. Falling through to the email subject
booked it against the wrong site, so a PO naming a site we do not have is
now left for a person instead.
"""
vocabulary = site_vocabulary(sites)
candidates = [
match.group(1).strip()
for match in BRACKETED.finditer(text or "")
if names_a_site(match.group(1).strip(), vocabulary)
]
if not candidates:
return None
if any(resolve_site_by_name(candidate, sites) for candidate in candidates):
return None
return candidates[0]
def site_for_po(
parsed: dict, email_subject: str, sites: list[dict]
) -> tuple[dict | None, str | None]:
"""The site a PO is booked against, and the unknown site name if it has one.
The name written on the PO decides: its subject line first, then its
description, then the email subject. The project code on the PO's lines is
a cost allocation, not a statement of where the work is going, so it never
decides the site.
A PO that names a site we do not have does not get to fall through to the
email subject: email threads keep the title of the first PO that used
them, so the email can name a different site altogether. MEGCPO9564
arrived in "RE: Rock Grid PO Cedar Gate" and is a Willow Park delivery.
Returns (site, None), (None, unknown_name) or (None, None).
"""
site = resolve_site_by_name(parsed["subject_line"], sites)
if site:
return site, None
unknown = unknown_site_name(parsed["subject_line"], sites) or unknown_site_name(
parsed["description"], sites
)
if unknown:
return None, unknown
site = resolve_site_by_name(parsed["description"], sites) or resolve_site_by_name(
email_subject, sites
)
return site, None
# --------------------------------------------------------------------------- pocketbase
def pb_find(token: str, flt: str, fields: str = "") -> list[dict]:
query = {"filter": flt, "perPage": "10"}
if fields:
query["fields"] = fields
code, body = http(
"GET",
"/api/collections/purchase_orders/records?" + urllib.parse.urlencode(query),
token=token,
)
if code != 200:
return []
return body.get("items", [])
def multipart(fields: dict, file_field: str, filename: str, blob: bytes, mime: str) -> tuple[bytes, str]:
boundary = f"----pobound{uuid.uuid4().hex}"
out = bytearray()
for key, value in fields.items():
out += f"--{boundary}\r\n".encode()
out += f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode()
out += f"{value}\r\n".encode()
out += f"--{boundary}\r\n".encode()
out += (
f'Content-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\n'
f"Content-Type: {mime}\r\n\r\n"
).encode()
out += blob
out += f"\r\n--{boundary}--\r\n".encode()
return bytes(out), f"multipart/form-data; boundary={boundary}"
def pb_create_po(token: str, pdf: bytes, site_id: str, file_hash: str, meta: dict) -> dict:
number = "PENDING-" + file_hash[:12].upper()
fields = {
"purchase_order_number": number,
"site": site_id,
"processing_status": "pending",
"attempts": "0",
"is_verified": "false",
"file_hash": file_hash,
"date_uploaded": now_iso(),
"raw_ocr_data": json.dumps(meta),
}
body, content_type = multipart(
fields, "purchase_order_file", f"{number}.pdf", pdf, "application/pdf"
)
req = urllib.request.Request(
f"{PB}/api/collections/purchase_orders/records",
data=body,
method="POST",
)
req.add_header("Authorization", token)
req.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as exc:
detail = exc.read().decode()[:400]
raise RuntimeError(f"create failed: {exc.code} {detail}") from exc
# --------------------------------------------------------------------------- main
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="report only, create nothing")
parser.add_argument("--force", action="store_true", help="re-check every inbox message")
args = parser.parse_args()
STATE_DIR.mkdir(parents=True, exist_ok=True)
state = {"seen": {}}
if STATE_FILE.exists():
try:
state = json.loads(STATE_FILE.read_text())
except Exception: # noqa: BLE001
state = {"seen": {}}
state.setdefault("seen", {})
token = superuser_token()
sites = load_sites(token)
ensure_mail_running()
messages = inbox_messages()
report = {
"ran_at": now_iso(),
"dry_run": args.dry_run,
"messages_seen": len(messages),
"already_seen": [],
"filed": [],
"already_filed": [],
"duplicate_number": [],
"needs_review": [],
"skipped": [],
}
for msg in messages:
if not args.force and msg["id"] in state["seen"]:
report["already_seen"].append(msg["subject"])
continue
try:
source = raw_source(msg["id"])
except Exception as exc: # noqa: BLE001
report["skipped"].append({"subject": msg["subject"], "error": str(exc)[:200]})
continue
message_id = header_of(source, "Message-ID")
attachments = pdf_attachments(source)
# The message is only remembered once it has been classified. A dry run must
# not consume it -- otherwise a new PO seen first by --dry-run would be
# skipped by every later real run and never filed, never flagged.
outcomes: list[str] = []
if not attachments:
outcomes.append("no_pdf")
report["skipped"].append({"subject": msg["subject"], "reason": "no PDF attachment"})
if not args.dry_run:
state["seen"][msg["id"]] = {
"message_id": message_id,
"subject": msg["subject"],
"seen_at": now_iso(),
"outcome": outcomes,
}
continue
for filename, blob in attachments:
file_hash = hashlib.sha256(blob).hexdigest()
parsed = parse_pdf(blob)
po_number = parsed["po_number"]
if not po_number:
report["needs_review"].append(
{
"subject": msg["subject"],
"file": filename,
"reason": "no PO number found in the document",
}
)
outcomes.append("needs_review")
continue
# 1. same document, same site -> already filed
same = pb_find(token, f'file_hash="{file_hash}" && site!=""')
if any(r.get("file_hash") == file_hash for r in same):
report["already_filed"].append(
{"po_number": po_number, "subject": msg["subject"], "file": filename}
)
outcomes.append("already_filed")
continue
# 2. same PO number already in the system -> a human must look
clash = pb_find(token, f'purchase_order_number="{po_number}"')
if clash:
report["duplicate_number"].append(
{
"po_number": po_number,
"subject": msg["subject"],
"existing_id": clash[0].get("id"),
"existing_site": clash[0].get("site"),
"reason": "PO number already in the system",
}
)
outcomes.append("duplicate_number")
continue
# The site comes from the name written on the PO: its subject line
# first, then its description, then the email subject. The project
# code on the PO's lines is a cost allocation, not a statement of
# where the work is going, so it never decides the site.
site, unknown = site_for_po(parsed, msg["subject"], sites)
if unknown:
report["needs_review"].append(
{
"po_number": po_number,
"subject": msg["subject"],
"file": filename,
"reason": f"the PO names a site that is not in the app: {unknown}",
}
)
outcomes.append("needs_review")
continue
if not site:
report["needs_review"].append(
{
"po_number": po_number,
"subject": msg["subject"],
"file": filename,
"reason": "no MEGA site name found on the PO or in the email",
}
)
outcomes.append("needs_review")
continue
meta = {
"source": "mail-intake",
"message_id": message_id,
"subject": msg["subject"],
"from": msg["sender"],
"received": msg["received"],
"site_code": site.get("code"),
"site_name": site.get("name"),
"po_cost_codes": parsed["cost_codes"],
"po_number_hint": po_number,
"file_name": filename,
"backfilled_at": now_iso(),
}
if args.dry_run:
report["filed"].append(
{
"po_number": po_number,
"site": site.get("name"),
"supplier": parsed["supplier"],
"description": parsed["description"],
"value": None,
"dry_run": True,
}
)
outcomes.append("would_file")
continue
created = pb_create_po(token, blob, site["id"], file_hash, meta)
report["filed"].append(
{
"po_number": po_number,
"id": created.get("id"),
"site": site.get("name"),
"supplier": parsed["supplier"],
"description": parsed["description"],
"file": filename,
}
)
outcomes.append("filed")
mark_handled(msg["id"])
if not args.dry_run:
state["seen"][msg["id"]] = {
"message_id": message_id,
"subject": msg["subject"],
"seen_at": now_iso(),
"outcome": outcomes,
}
if not args.dry_run:
STATE_FILE.write_text(json.dumps(state, indent=1))
LAST_RUN_FILE.write_text(json.dumps(report, indent=1))
with RUN_LOG.open("a") as fh:
fh.write(json.dumps({k: v for k, v in report.items() if k != "skipped"}) + "\n")
filed = len(report["filed"])
print(
f"PO intake: {report['messages_seen']} message(s) in inbox | "
f"{len(report['already_seen'])} already seen | "
f"{filed} filed | {len(report['already_filed'])} already filed | "
f"{len(report['duplicate_number'])} duplicate number | "
f"{len(report['needs_review'])} need review"
)
for item in report["filed"]:
print(f" FILED {item['po_number']} -> {item.get('site')} | {item.get('supplier')}")
for item in report["already_filed"]:
print(f" SKIP {item['po_number']} already filed")
for item in report["duplicate_number"]:
print(f" REVIEW {item['po_number']} number already in the system")
for item in report["needs_review"]:
print(f" REVIEW {item.get('po_number', '?')} {item['reason']}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(f"PO INTAKE FAILED: {exc}", file=sys.stderr)
sys.exit(1)