autofax/autofax.py
Sochen aef5e5283a Initial commit: automated hourly fax sender for insurance claims
Sends doula coverage claims via Telnyx fax API every hour, logs every
attempt, and generates a printable HTML report for HR. Includes both
a Linux CLI with cron scheduling and a Windows GUI (tkinter) that can
be packaged as a portable exe via PyInstaller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:36:47 +00:00

296 lines
9.5 KiB
Python
Executable file

#!/usr/bin/env python3
"""AutoFax - Send a fax every hour via Telnyx and log results."""
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
import requests
import config
API = "https://api.telnyx.com/v2"
def headers():
return {"Authorization": f"Bearer {config.TELNYX_API_KEY}"}
def find_claim_pdf() -> Path:
pdfs = sorted(config.CLAIMS_DIR.glob("*.pdf"))
if not pdfs:
print("ERROR: No PDF found in claims/ directory.", file=sys.stderr)
sys.exit(1)
return pdfs[0]
def upload_media(pdf_path: Path) -> str:
"""Upload PDF to Telnyx media storage. Returns the media_name."""
media_name = f"autofax-claim-{pdf_path.stem}"
with open(pdf_path, "rb") as f:
resp = requests.post(
f"{API}/media",
headers={**headers(), "Content-Type": "application/pdf"},
params={"media_name": media_name},
data=f.read(),
)
resp.raise_for_status()
return media_name
def send_fax(pdf_path: Path) -> dict:
"""Upload PDF and send a fax. Returns result dict."""
timestamp = datetime.now(timezone.utc).isoformat()
try:
media_name = upload_media(pdf_path)
resp = requests.post(
f"{API}/faxes",
headers={**headers(), "Content-Type": "application/json"},
json={
"connection_id": config.TELNYX_CONNECTION_ID,
"media_name": media_name,
"to": config.FAX_TO_NUMBER,
"from": config.TELNYX_FROM_NUMBER,
},
)
resp.raise_for_status()
fax_data = resp.json().get("data", {})
return {
"timestamp": timestamp,
"status": fax_data.get("status", "queued"),
"fax_id": fax_data.get("id"),
"to": config.FAX_TO_NUMBER,
"from": config.TELNYX_FROM_NUMBER,
"file": pdf_path.name,
"error": None,
}
except requests.HTTPError as e:
error_body = ""
try:
error_body = e.response.json()
except Exception:
error_body = e.response.text[:500]
return {
"timestamp": timestamp,
"status": "send_failed",
"fax_id": None,
"to": config.FAX_TO_NUMBER,
"from": config.TELNYX_FROM_NUMBER,
"file": pdf_path.name,
"error": f"{e} | {error_body}",
}
except Exception as e:
return {
"timestamp": timestamp,
"status": "send_failed",
"fax_id": None,
"to": config.FAX_TO_NUMBER,
"from": config.TELNYX_FROM_NUMBER,
"file": pdf_path.name,
"error": str(e),
}
def check_fax_status(fax_id: str) -> dict | None:
if not fax_id:
return None
try:
resp = requests.get(f"{API}/faxes/{fax_id}", headers=headers())
resp.raise_for_status()
return resp.json().get("data", {})
except Exception:
return None
def load_log() -> list[dict]:
if config.LOG_FILE.exists():
return json.loads(config.LOG_FILE.read_text())
return []
def save_log(entries: list[dict]):
config.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
config.LOG_FILE.write_text(json.dumps(entries, indent=2))
def notify(subject: str, message: str, priority: str = "default"):
if not config.NTFY_URL:
return
try:
hdrs = {"Title": subject, "Priority": priority}
if config.NTFY_TOKEN:
hdrs["Authorization"] = config.NTFY_TOKEN
requests.post(config.NTFY_URL, data=message.encode(), headers=hdrs)
except Exception:
pass
def generate_report(entries: list[dict]):
config.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
total = len(entries)
failed = sum(1 for e in entries if e["status"] not in ("queued", "sending", "sent", "delivered"))
succeeded = sum(1 for e in entries if e["status"] == "delivered")
pending = total - failed - succeeded
rows = ""
for i, e in enumerate(entries, 1):
status = e["status"]
if status in ("delivered", "sent"):
status_class = "success"
status_display = status.upper()
elif status in ("queued", "sending"):
status_class = "pending"
status_display = status.upper()
else:
status_class = "failed"
status_display = "FAILED"
error_col = e.get("error") or e.get("failure_reason") or "-"
ts = e["timestamp"]
try:
dt = datetime.fromisoformat(ts)
ts_display = dt.strftime("%b %d, %Y %I:%M %p UTC")
except Exception:
ts_display = ts
rows += f"""
<tr>
<td>{i}</td>
<td>{ts_display}</td>
<td>{e.get('to', '-')}</td>
<td class="{status_class}">{status_display}</td>
<td>{e.get('fax_id') or '-'}</td>
<td class="error">{error_col}</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Fax Transmission Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; color: #333; }}
h1 {{ color: #1a1a2e; border-bottom: 2px solid #1a1a2e; padding-bottom: 10px; }}
.summary {{ background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0; }}
.summary span {{ font-weight: bold; }}
table {{ border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px; }}
th {{ background: #1a1a2e; color: white; padding: 12px 8px; text-align: left; }}
td {{ padding: 10px 8px; border-bottom: 1px solid #ddd; }}
tr:nth-child(even) {{ background: #f9f9f9; }}
.success {{ color: #2d8a4e; font-weight: bold; }}
.failed {{ color: #c0392b; font-weight: bold; }}
.pending {{ color: #e67e22; font-weight: bold; }}
.error {{ font-size: 12px; color: #888; max-width: 300px; word-wrap: break-word; }}
.footer {{ margin-top: 30px; font-size: 12px; color: #888; border-top: 1px solid #ddd; padding-top: 10px; }}
@media print {{ body {{ margin: 20px; }} }}
</style>
</head>
<body>
<h1>Fax Transmission Report</h1>
<div class="summary">
<p>This report documents automated fax transmission attempts to the insurance
company for doula coverage claim processing.</p>
<p>
Total attempts: <span>{total}</span> |
Delivered: <span class="success">{succeeded}</span> |
Failed: <span class="failed">{failed}</span> |
Pending: <span>{pending}</span>
</p>
<p>Fax destination: <span>{entries[0]['to'] if entries else 'N/A'}</span></p>
<p>Report generated: <span>{datetime.now(timezone.utc).strftime('%B %d, %Y at %I:%M %p UTC')}</span></p>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Date &amp; Time</th>
<th>Fax Number</th>
<th>Status</th>
<th>Confirmation ID</th>
<th>Error Details</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
<div class="footer">
<p>Generated by AutoFax automated fax system. Each row represents one
transmission attempt of the doula coverage claim (5 pages). Faxes are sent
once per hour. A status of FAILED indicates the receiving fax machine did
not accept the transmission.</p>
</div>
</body>
</html>"""
report_path = config.REPORTS_DIR / "fax_report.html"
report_path.write_text(html)
return report_path
def update_previous_statuses(entries: list[dict]) -> list[dict]:
for entry in entries:
if entry["status"] in ("queued", "sending"):
status_data = check_fax_status(entry.get("fax_id"))
if status_data:
new_status = status_data.get("status", entry["status"])
if new_status != entry["status"]:
entry["status"] = new_status
if new_status == "failed":
entry["failure_reason"] = status_data.get("failure_reason", "Unknown")
return entries
def main():
pdf_path = find_claim_pdf()
entries = load_log()
entries = update_previous_statuses(entries)
print(f"Sending fax: {pdf_path.name} -> {config.FAX_TO_NUMBER}")
result = send_fax(pdf_path)
entries.append(result)
# Wait briefly and check initial status
if result["fax_id"] and result["status"] == "queued":
time.sleep(15)
status_data = check_fax_status(result["fax_id"])
if status_data:
result["status"] = status_data.get("status", result["status"])
if result["status"] == "failed":
result["failure_reason"] = status_data.get("failure_reason", "Unknown")
save_log(entries)
report_path = generate_report(entries)
# Notify via ntfy
status_labels = {
"delivered": "Fax Delivered",
"sent": "Fax Sent",
"queued": "Fax Queued",
"sending": "Fax Sending",
}
subject = status_labels.get(result["status"], "Fax Failed")
msg = f"To: {result['to']}\nStatus: {result['status']}\nFile: {result['file']}"
if result.get("error"):
msg += f"\nError: {result['error']}"
priority = "high" if result["status"] not in ("queued", "sending", "sent", "delivered") else "default"
notify(subject, msg, priority)
print(f"Status: {result['status']}")
print(f"Report: {report_path}")
if result["status"] == "send_failed":
sys.exit(1)
if __name__ == "__main__":
main()