2026-04-29 19:44:46 -04:00
|
|
|
|
#!/usr/bin/env python3.12
|
|
|
|
|
|
"""Parse Coupa PO emails from an MBOX file and extract line items + ship-to data."""
|
|
|
|
|
|
|
|
|
|
|
|
import mailbox
|
|
|
|
|
|
import re
|
|
|
|
|
|
import json
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
2026-05-08 15:56:24 -04:00
|
|
|
|
|
2026-04-29 19:44:46 -04:00
|
|
|
|
def parse_amount(s):
|
|
|
|
|
|
"""Parse amount string, handling formats like '1.0 EACH x 3,000.00' or '10,000.00'."""
|
|
|
|
|
|
s = s.strip()
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if " x " in s.lower():
|
|
|
|
|
|
s = s.split(" x ")[-1].strip()
|
|
|
|
|
|
elif " X " in s:
|
|
|
|
|
|
s = s.split(" X ")[-1].strip()
|
|
|
|
|
|
return float(s.replace(",", ""))
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_po_email(body):
|
|
|
|
|
|
"""Extract PO data from a Coupa 'issued' email plaintext body."""
|
2026-05-08 15:56:24 -04:00
|
|
|
|
lines = body.split("\n")
|
|
|
|
|
|
lines = [line.strip() for line in lines]
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
|
|
|
|
|
# PO number from body
|
2026-05-08 15:56:24 -04:00
|
|
|
|
po_match = re.search(r"Purchase Order #?((?:2D|B187|FK)-\d+)", body)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
if not po_match:
|
|
|
|
|
|
return None
|
|
|
|
|
|
po_number = po_match.group(1)
|
|
|
|
|
|
|
|
|
|
|
|
# Line items: between "Items" line and the coupahost URL
|
|
|
|
|
|
items_start = None
|
|
|
|
|
|
items_end = None
|
|
|
|
|
|
for i, line in enumerate(lines):
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if line == "Items" and items_start is None:
|
2026-04-29 19:44:46 -04:00
|
|
|
|
items_start = i + 1
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if items_start and "supplier.coupahost.com/orders/" in line:
|
2026-04-29 19:44:46 -04:00
|
|
|
|
items_end = i
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
line_items = []
|
|
|
|
|
|
if items_start and items_end:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
item_lines = [line for line in lines[items_start:items_end] if line]
|
2026-04-29 19:44:46 -04:00
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(item_lines):
|
|
|
|
|
|
desc = item_lines[i]
|
|
|
|
|
|
# Skip if this line looks like a number/currency
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if re.match(r"^[\d,]", desc) or desc == "USD":
|
2026-04-29 19:44:46 -04:00
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
amount = None
|
|
|
|
|
|
# Look ahead for amount
|
|
|
|
|
|
for j in range(i + 1, min(i + 4, len(item_lines))):
|
|
|
|
|
|
candidate = item_lines[j]
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if re.match(r"^[\d,]", candidate):
|
2026-04-29 19:44:46 -04:00
|
|
|
|
try:
|
|
|
|
|
|
amount = parse_amount(candidate)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
break
|
2026-05-08 15:56:24 -04:00
|
|
|
|
line_items.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"description": desc,
|
|
|
|
|
|
"amount": amount,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
# Skip past amount + currency lines
|
|
|
|
|
|
i = j + 1 if amount is not None else i + 1
|
2026-05-08 15:56:24 -04:00
|
|
|
|
while i < len(item_lines) and item_lines[i] == "USD":
|
2026-04-29 19:44:46 -04:00
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
|
|
# Key-value pairs after "More Detail"
|
|
|
|
|
|
kv = {}
|
|
|
|
|
|
detail_start = None
|
|
|
|
|
|
for i, line in enumerate(lines):
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if line == "More Detail":
|
2026-04-29 19:44:46 -04:00
|
|
|
|
detail_start = i + 1
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if detail_start:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
kv_keys = [
|
|
|
|
|
|
"PO ID",
|
|
|
|
|
|
"Department",
|
|
|
|
|
|
"Status",
|
|
|
|
|
|
"Last Opened",
|
|
|
|
|
|
"Order Date",
|
|
|
|
|
|
"Acknowledged At",
|
|
|
|
|
|
"Revision Date",
|
|
|
|
|
|
"Payment Term",
|
|
|
|
|
|
"Req #",
|
|
|
|
|
|
]
|
2026-04-29 19:44:46 -04:00
|
|
|
|
for i in range(detail_start, len(lines)):
|
|
|
|
|
|
for k in kv_keys:
|
|
|
|
|
|
if lines[i] == k and i + 1 < len(lines):
|
|
|
|
|
|
kv[k] = lines[i + 1]
|
|
|
|
|
|
|
|
|
|
|
|
# Ship-to address: second "Shipping" section
|
|
|
|
|
|
shipping_count = 0
|
|
|
|
|
|
ship_to_lines = []
|
|
|
|
|
|
capturing = False
|
|
|
|
|
|
skipping_blanks = False
|
|
|
|
|
|
for i, line in enumerate(lines):
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if line == "Shipping":
|
2026-04-29 19:44:46 -04:00
|
|
|
|
shipping_count += 1
|
|
|
|
|
|
if shipping_count == 2:
|
|
|
|
|
|
capturing = True
|
|
|
|
|
|
skipping_blanks = True
|
|
|
|
|
|
continue
|
|
|
|
|
|
elif capturing:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if skipping_blanks and line == "":
|
2026-04-29 19:44:46 -04:00
|
|
|
|
continue
|
|
|
|
|
|
skipping_blanks = False
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if (
|
|
|
|
|
|
line in ("", "Ship To Address")
|
|
|
|
|
|
or line.startswith("---")
|
|
|
|
|
|
or line.startswith("http")
|
|
|
|
|
|
):
|
2026-04-29 19:44:46 -04:00
|
|
|
|
break
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if line in kv_keys or line == "Supplier" or line == "More Detail":
|
2026-04-29 19:44:46 -04:00
|
|
|
|
break
|
|
|
|
|
|
ship_to_lines.append(line)
|
|
|
|
|
|
|
2026-05-08 15:56:24 -04:00
|
|
|
|
ship_to_raw = "\n".join(ship_to_lines) if ship_to_lines else None
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
|
|
|
|
|
# Extract site code from ship-to
|
|
|
|
|
|
site_code = None
|
|
|
|
|
|
if ship_to_raw:
|
|
|
|
|
|
# (CODE) pattern — e.g. "Amazon.com Services LLC (XSF2)"
|
2026-05-08 15:56:24 -04:00
|
|
|
|
m = re.search(r"\(([A-Z0-9]{3,5})\)", ship_to_raw)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
if m:
|
|
|
|
|
|
site_code = m.group(1)
|
|
|
|
|
|
# "LLC - CODE" — e.g. "Amazon.com Services LLC - WUT9"
|
|
|
|
|
|
if not site_code:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
m = re.search(r"(?:LLC|Inc)\s*-\s*([A-Z0-9]{3,5})\b", ship_to_raw)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
if m:
|
|
|
|
|
|
site_code = m.group(1)
|
|
|
|
|
|
# "ATTN: ... Station CODE" or "ATTN: ... DS - CODE"
|
|
|
|
|
|
if not site_code:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
m = re.search(
|
|
|
|
|
|
r"ATTN:.*?(?:Station|DS)\s*[-–]?\s*([A-Z0-9]{3,5})\b", ship_to_raw
|
|
|
|
|
|
)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
if m:
|
|
|
|
|
|
site_code = m.group(1)
|
|
|
|
|
|
# "CODE - Amazon" at start of first line
|
|
|
|
|
|
if not site_code:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
m = re.match(r"^([A-Z0-9]{3,5})\s*-\s*Amazon", ship_to_raw)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
if m:
|
|
|
|
|
|
site_code = m.group(1)
|
|
|
|
|
|
if not site_code and line_items:
|
|
|
|
|
|
for li in line_items:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
m = re.match(r"^([A-Z]{1,4}[0-9]{1,2}|[A-Z]{4,5})\b", li["description"])
|
2026-04-29 19:44:46 -04:00
|
|
|
|
if m:
|
|
|
|
|
|
site_code = m.group(1)
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-08 15:56:24 -04:00
|
|
|
|
"po_number": po_number,
|
|
|
|
|
|
"site_code": site_code,
|
|
|
|
|
|
"status": kv.get("Status"),
|
|
|
|
|
|
"order_date": kv.get("Order Date"),
|
|
|
|
|
|
"revision_date": kv.get("Revision Date"),
|
|
|
|
|
|
"payment_term": kv.get("Payment Term"),
|
|
|
|
|
|
"req_number": kv.get("Req #"),
|
|
|
|
|
|
"ship_to_raw": ship_to_raw,
|
|
|
|
|
|
"line_items": line_items,
|
2026-04-29 19:44:46 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def process_mbox(mbox_path, target_pos=None, output_path=None):
|
|
|
|
|
|
"""Process an MBOX file and return parsed PO records."""
|
|
|
|
|
|
mbox = mailbox.mbox(mbox_path)
|
|
|
|
|
|
results = {}
|
|
|
|
|
|
processed = 0
|
|
|
|
|
|
matched = 0
|
|
|
|
|
|
|
|
|
|
|
|
for msg in mbox:
|
|
|
|
|
|
processed += 1
|
2026-05-08 15:56:24 -04:00
|
|
|
|
subject = msg.get("Subject", "")
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if "issued" not in subject.lower() or "Purchase Order" not in subject:
|
2026-04-29 19:44:46 -04:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if msg.is_multipart():
|
|
|
|
|
|
body = None
|
|
|
|
|
|
for part in msg.walk():
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if part.get_content_type() == "text/plain":
|
|
|
|
|
|
body = part.get_payload(decode=True).decode(
|
|
|
|
|
|
"utf-8", errors="replace"
|
|
|
|
|
|
)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
break
|
|
|
|
|
|
if not body:
|
|
|
|
|
|
continue
|
|
|
|
|
|
else:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
body = msg.get_payload(decode=True).decode("utf-8", errors="replace")
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
|
|
|
|
|
record = parse_po_email(body)
|
|
|
|
|
|
if not record:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-05-08 15:56:24 -04:00
|
|
|
|
po = record["po_number"]
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
|
|
|
|
|
if target_pos and po not in target_pos:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Keep latest version if duplicate
|
|
|
|
|
|
if po not in results:
|
|
|
|
|
|
results[po] = record
|
|
|
|
|
|
matched += 1
|
|
|
|
|
|
|
|
|
|
|
|
if matched % 500 == 0 and matched > 0:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f" Matched {matched} POs so far... ({processed} messages scanned)",
|
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
|
)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
|
|
|
|
|
mbox.close()
|
|
|
|
|
|
|
|
|
|
|
|
result_list = list(results.values())
|
2026-05-08 15:56:24 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"Processed {processed} messages, matched {matched} unique POs", file=sys.stderr
|
|
|
|
|
|
)
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
|
|
|
|
|
if output_path:
|
2026-05-08 15:56:24 -04:00
|
|
|
|
with open(output_path, "w") as f:
|
2026-04-29 19:44:46 -04:00
|
|
|
|
json.dump(result_list, f, indent=2)
|
|
|
|
|
|
print(f"Saved to {output_path}", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
return result_list
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 15:56:24 -04:00
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
mbox_path = (
|
|
|
|
|
|
sys.argv[1]
|
|
|
|
|
|
if len(sys.argv) > 1
|
|
|
|
|
|
else "/tmp/coupa-emails/coupa-po-dump--info@seahavenind.com-wHxmHD.mbox"
|
|
|
|
|
|
)
|
|
|
|
|
|
po_list_path = sys.argv[2] if len(sys.argv) > 2 else "output/po-list.json"
|
|
|
|
|
|
output_path = sys.argv[3] if len(sys.argv) > 3 else "output/email-parsed-pos.json"
|
2026-04-29 19:44:46 -04:00
|
|
|
|
|
|
|
|
|
|
target_pos = None
|
|
|
|
|
|
if os.path.exists(po_list_path):
|
|
|
|
|
|
with open(po_list_path) as f:
|
|
|
|
|
|
target_pos = set(json.load(f))
|
|
|
|
|
|
print(f"Targeting {len(target_pos)} specific POs", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
process_mbox(mbox_path, target_pos, output_path)
|