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>
This commit is contained in:
commit
aef5e5283a
13 changed files with 1139 additions and 0 deletions
18
.env.example
Normal file
18
.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Telnyx API credentials
|
||||||
|
# Sign up at https://portal.telnyx.com/ and get your API key
|
||||||
|
TELNYX_API_KEY=KEY_your_api_key_here
|
||||||
|
|
||||||
|
# Your Telnyx Fax Application connection ID
|
||||||
|
# Create a Fax Application in Mission Control Portal under Messaging > Fax
|
||||||
|
TELNYX_CONNECTION_ID=your_connection_id_here
|
||||||
|
|
||||||
|
# Your Telnyx fax-enabled phone number (E.164 format)
|
||||||
|
# Purchase one in the Mission Control Portal under Numbers, assign to Fax App
|
||||||
|
TELNYX_FROM_NUMBER=+1XXXXXXXXXX
|
||||||
|
|
||||||
|
# Destination fax number (E.164 format)
|
||||||
|
FAX_TO_NUMBER=+1XXXXXXXXXX
|
||||||
|
|
||||||
|
# ntfy notification URL (optional, comment out to disable)
|
||||||
|
NTFY_URL=https://nt.nevo.engineer/your-channel
|
||||||
|
NTFY_TOKEN=Bearer tk_your_token_here
|
||||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Credentials and config
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Claim documents (personal data)
|
||||||
|
claims/*.pdf
|
||||||
|
claims/*.tiff
|
||||||
|
claims/*.png
|
||||||
|
|
||||||
|
# Generated reports
|
||||||
|
reports/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# GUI config (contains credentials)
|
||||||
|
autofax_config.json
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
5
PROJECT.md
Normal file
5
PROJECT.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Basically, my health insurance claims to cover doulas, however requires a tedious process where one must either fax them or snail mail them the claim. I did both. The fax returned as failure and I haven't seen the claim added even after mailing it a month ago. My coworker has the same issue.
|
||||||
|
|
||||||
|
Build a script (can integrate with cron) to sent an electronic fax to them every hour on the hour, and safely collect the returned failed responses in a directory. I will show this to them or to my companies HR. Also find an electronic fax API to use with a service that is the cheapest for a single month. Assume the doula claim paperwork is about 5 black and white pages.
|
||||||
|
|
||||||
|
You've got a figure out directive, you can ask some clarifying questions in the beginning, but then I want you to go and build the thing. Preferred language is python.
|
||||||
94
README.md
Normal file
94
README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# AutoFax
|
||||||
|
|
||||||
|
Automated hourly fax sender for insurance claim submissions. Sends your claim via Telnyx's fax API every hour and generates a printable HTML report documenting every attempt and its status -- useful for showing HR or the insurance company that their fax system is rejecting claims.
|
||||||
|
|
||||||
|
## Quick Start (Windows GUI)
|
||||||
|
|
||||||
|
If someone gave you `AutoFax.exe` on a flash drive:
|
||||||
|
|
||||||
|
1. Double-click `AutoFax.exe`
|
||||||
|
2. Fill in the Telnyx credentials (API Key, Connection ID, From Number)
|
||||||
|
3. Set the destination fax number
|
||||||
|
4. Click **Browse** and select your claim PDF
|
||||||
|
5. Click **Start Hourly Faxing**
|
||||||
|
|
||||||
|
Config is saved next to the exe, so it remembers your settings. Reports are generated in a `reports/` folder next to the exe -- open `fax_report.html` in a browser and print it for HR.
|
||||||
|
|
||||||
|
## Building the Windows Executable
|
||||||
|
|
||||||
|
On a Windows machine with Python 3.10+ installed:
|
||||||
|
|
||||||
|
```
|
||||||
|
build_windows.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces `dist/AutoFax.exe` -- a single self-contained file you can copy to a flash drive.
|
||||||
|
|
||||||
|
## Linux/Server Setup (CLI + cron)
|
||||||
|
|
||||||
|
### 1. Telnyx Account
|
||||||
|
|
||||||
|
1. Sign up at [portal.telnyx.com](https://portal.telnyx.com/)
|
||||||
|
2. Add a payment method
|
||||||
|
3. Go to **Messaging > Fax** and create a Fax Application
|
||||||
|
4. Go to **Numbers** and purchase a fax-enabled number (~$1/month)
|
||||||
|
5. Assign the number to your Fax Application
|
||||||
|
6. Go to **API Keys** and copy your API key
|
||||||
|
|
||||||
|
### 2. Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your Telnyx credentials and the destination fax number
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add Your Claim
|
||||||
|
|
||||||
|
Place your claim PDF in the `claims/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /path/to/your/claim.pdf claims/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Install & Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Create a Python virtual environment
|
||||||
|
- Install dependencies
|
||||||
|
- Set up an hourly cron job
|
||||||
|
- Schedule auto-cleanup after 7 days
|
||||||
|
|
||||||
|
### 5. Manual Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv/bin/python autofax.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reports
|
||||||
|
|
||||||
|
After each fax attempt, a printable HTML report is generated at `reports/fax_report.html`. Open it in a browser and print to PDF for HR. It includes:
|
||||||
|
|
||||||
|
- Timestamp of every attempt
|
||||||
|
- Delivery status (DELIVERED, FAILED, QUEUED)
|
||||||
|
- Confirmation IDs
|
||||||
|
- Error details for failures
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
Optional push notifications via [ntfy](https://ntfy.sh). Configure in `.env` (CLI) or in the GUI settings.
|
||||||
|
|
||||||
|
## Uninstall (CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./uninstall.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
Telnyx pricing: ~$0.007/page + ~$1/month for the phone number.
|
||||||
|
One week of hourly faxes (168 faxes x 5 pages = 840 pages) costs roughly $8-15.
|
||||||
296
autofax.py
Executable file
296
autofax.py
Executable file
|
|
@ -0,0 +1,296 @@
|
||||||
|
#!/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 & 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()
|
||||||
19
build.sh
Executable file
19
build.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== Building AutoFax Executable ==="
|
||||||
|
|
||||||
|
# Create/use venv
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
python3 -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -q pyinstaller requests python-dotenv
|
||||||
|
|
||||||
|
echo "Building..."
|
||||||
|
pyinstaller --onefile --windowed --name AutoFax gui.py
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build successful! ==="
|
||||||
|
echo "Executable: dist/AutoFax"
|
||||||
30
build_windows.bat
Normal file
30
build_windows.bat
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
@echo off
|
||||||
|
echo === Building AutoFax Windows Executable ===
|
||||||
|
echo.
|
||||||
|
|
||||||
|
where python >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo ERROR: Python not found. Install Python 3.10+ from python.org
|
||||||
|
echo Make sure to check "Add Python to PATH" during install.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Installing build dependencies...
|
||||||
|
pip install pyinstaller requests python-dotenv
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Building executable...
|
||||||
|
pyinstaller --onefile --windowed --name AutoFax --icon=NONE gui.py
|
||||||
|
|
||||||
|
echo.
|
||||||
|
if exist dist\AutoFax.exe (
|
||||||
|
echo === Build successful! ===
|
||||||
|
echo Executable: dist\AutoFax.exe
|
||||||
|
echo.
|
||||||
|
echo Copy AutoFax.exe to a flash drive and run it anywhere.
|
||||||
|
echo Config and reports will be saved next to the exe.
|
||||||
|
) else (
|
||||||
|
echo Build failed. Check the output above for errors.
|
||||||
|
)
|
||||||
|
pause
|
||||||
0
claims/.gitkeep
Normal file
0
claims/.gitkeep
Normal file
21
config.py
Normal file
21
config.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
CLAIMS_DIR = BASE_DIR / "claims"
|
||||||
|
REPORTS_DIR = BASE_DIR / "reports"
|
||||||
|
LOG_FILE = REPORTS_DIR / "fax_log.json"
|
||||||
|
|
||||||
|
# Telnyx
|
||||||
|
TELNYX_API_KEY = os.environ["TELNYX_API_KEY"]
|
||||||
|
TELNYX_CONNECTION_ID = os.environ["TELNYX_CONNECTION_ID"]
|
||||||
|
TELNYX_FROM_NUMBER = os.environ["TELNYX_FROM_NUMBER"]
|
||||||
|
FAX_TO_NUMBER = os.environ["FAX_TO_NUMBER"]
|
||||||
|
|
||||||
|
# ntfy (optional)
|
||||||
|
NTFY_URL = os.environ.get("NTFY_URL")
|
||||||
|
NTFY_TOKEN = os.environ.get("NTFY_TOKEN")
|
||||||
534
gui.py
Normal file
534
gui.py
Normal file
|
|
@ -0,0 +1,534 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AutoFax GUI - Simple tkinter interface for sending faxes on a schedule."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import tkinter as tk
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, messagebox, scrolledtext
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Determine base directory (works for both script and frozen exe)
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
BASE_DIR = Path(sys.executable).parent
|
||||||
|
else:
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
CONFIG_FILE = BASE_DIR / "autofax_config.json"
|
||||||
|
CLAIMS_DIR = BASE_DIR / "claims"
|
||||||
|
REPORTS_DIR = BASE_DIR / "reports"
|
||||||
|
LOG_FILE = REPORTS_DIR / "fax_log.json"
|
||||||
|
API = "https://api.telnyx.com/v2"
|
||||||
|
|
||||||
|
|
||||||
|
class AutoFaxApp:
|
||||||
|
def __init__(self, root: tk.Tk):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("AutoFax - Insurance Claim Faxer")
|
||||||
|
self.root.geometry("620x700")
|
||||||
|
self.root.resizable(False, False)
|
||||||
|
|
||||||
|
self.running = False
|
||||||
|
self.timer_thread = None
|
||||||
|
self.config = self.load_config()
|
||||||
|
|
||||||
|
self.build_ui()
|
||||||
|
self.populate_fields()
|
||||||
|
self.update_status_display()
|
||||||
|
|
||||||
|
# ── Config persistence ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_config(self) -> dict:
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(CONFIG_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_config(self):
|
||||||
|
self.config = {
|
||||||
|
"api_key": self.api_key_var.get().strip(),
|
||||||
|
"connection_id": self.conn_id_var.get().strip(),
|
||||||
|
"from_number": self.from_var.get().strip(),
|
||||||
|
"to_number": self.to_var.get().strip(),
|
||||||
|
"pdf_path": self.pdf_var.get().strip(),
|
||||||
|
"ntfy_url": self.ntfy_url_var.get().strip(),
|
||||||
|
"ntfy_token": self.ntfy_token_var.get().strip(),
|
||||||
|
}
|
||||||
|
CONFIG_FILE.write_text(json.dumps(self.config, indent=2))
|
||||||
|
|
||||||
|
# ── UI ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_ui(self):
|
||||||
|
# Header
|
||||||
|
header = tk.Frame(self.root, bg="#1a1a2e", height=50)
|
||||||
|
header.pack(fill="x")
|
||||||
|
header.pack_propagate(False)
|
||||||
|
tk.Label(
|
||||||
|
header, text="AutoFax", font=("Arial", 18, "bold"),
|
||||||
|
bg="#1a1a2e", fg="white",
|
||||||
|
).pack(side="left", padx=15, pady=8)
|
||||||
|
self.status_label = tk.Label(
|
||||||
|
header, text="Stopped", font=("Arial", 11),
|
||||||
|
bg="#1a1a2e", fg="#e74c3c",
|
||||||
|
)
|
||||||
|
self.status_label.pack(side="right", padx=15)
|
||||||
|
|
||||||
|
# Main frame with padding
|
||||||
|
main = tk.Frame(self.root, padx=15, pady=10)
|
||||||
|
main.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
# ── Telnyx Settings ──
|
||||||
|
lf_telnyx = tk.LabelFrame(main, text="Telnyx Settings", padx=10, pady=5)
|
||||||
|
lf_telnyx.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
self.api_key_var = tk.StringVar()
|
||||||
|
self.conn_id_var = tk.StringVar()
|
||||||
|
self.from_var = tk.StringVar()
|
||||||
|
|
||||||
|
self._add_field(lf_telnyx, "API Key:", self.api_key_var, show="*")
|
||||||
|
self._add_field(lf_telnyx, "Connection ID:", self.conn_id_var)
|
||||||
|
self._add_field(lf_telnyx, "From Number:", self.from_var, placeholder="+1XXXXXXXXXX")
|
||||||
|
|
||||||
|
# ── Fax Settings ──
|
||||||
|
lf_fax = tk.LabelFrame(main, text="Fax Settings", padx=10, pady=5)
|
||||||
|
lf_fax.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
self.to_var = tk.StringVar()
|
||||||
|
self.pdf_var = tk.StringVar()
|
||||||
|
|
||||||
|
self._add_field(lf_fax, "To Number:", self.to_var, placeholder="+18019382102")
|
||||||
|
|
||||||
|
pdf_frame = tk.Frame(lf_fax)
|
||||||
|
pdf_frame.pack(fill="x", pady=2)
|
||||||
|
tk.Label(pdf_frame, text="Claim PDF:", width=14, anchor="w").pack(side="left")
|
||||||
|
tk.Entry(pdf_frame, textvariable=self.pdf_var, width=35).pack(side="left", fill="x", expand=True)
|
||||||
|
tk.Button(pdf_frame, text="Browse...", command=self.browse_pdf).pack(side="left", padx=(5, 0))
|
||||||
|
|
||||||
|
# ── ntfy (optional) ──
|
||||||
|
lf_ntfy = tk.LabelFrame(main, text="Notifications (optional)", padx=10, pady=5)
|
||||||
|
lf_ntfy.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
self.ntfy_url_var = tk.StringVar()
|
||||||
|
self.ntfy_token_var = tk.StringVar()
|
||||||
|
|
||||||
|
self._add_field(lf_ntfy, "ntfy URL:", self.ntfy_url_var, placeholder="https://ntfy.sh/your-topic")
|
||||||
|
self._add_field(lf_ntfy, "ntfy Token:", self.ntfy_token_var, show="*", placeholder="Bearer tk_...")
|
||||||
|
|
||||||
|
# ── Controls ──
|
||||||
|
btn_frame = tk.Frame(main)
|
||||||
|
btn_frame.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
self.start_btn = tk.Button(
|
||||||
|
btn_frame, text="Start Hourly Faxing", font=("Arial", 12, "bold"),
|
||||||
|
bg="#2d8a4e", fg="white", command=self.toggle_running,
|
||||||
|
width=20, height=2,
|
||||||
|
)
|
||||||
|
self.start_btn.pack(side="left")
|
||||||
|
|
||||||
|
tk.Button(
|
||||||
|
btn_frame, text="Send Once Now", command=self.send_once,
|
||||||
|
).pack(side="left", padx=(10, 0))
|
||||||
|
|
||||||
|
tk.Button(
|
||||||
|
btn_frame, text="Open Report", command=self.open_report,
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
# ── Log ──
|
||||||
|
tk.Label(main, text="Activity Log:", anchor="w").pack(fill="x")
|
||||||
|
self.log_text = scrolledtext.ScrolledText(
|
||||||
|
main, height=12, font=("Consolas", 9), state="disabled",
|
||||||
|
)
|
||||||
|
self.log_text.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
def _add_field(self, parent, label, var, show=None, placeholder=None):
|
||||||
|
frame = tk.Frame(parent)
|
||||||
|
frame.pack(fill="x", pady=2)
|
||||||
|
tk.Label(frame, text=label, width=14, anchor="w").pack(side="left")
|
||||||
|
entry = tk.Entry(frame, textvariable=var, show=show or "")
|
||||||
|
entry.pack(side="left", fill="x", expand=True)
|
||||||
|
if placeholder:
|
||||||
|
entry.insert(0, placeholder)
|
||||||
|
entry.config(fg="grey")
|
||||||
|
entry.bind("<FocusIn>", lambda e, ent=entry, ph=placeholder: self._clear_placeholder(ent, ph))
|
||||||
|
entry.bind("<FocusOut>", lambda e, ent=entry, ph=placeholder, v=var: self._restore_placeholder(ent, ph, v))
|
||||||
|
|
||||||
|
def _clear_placeholder(self, entry, placeholder):
|
||||||
|
if entry.get() == placeholder:
|
||||||
|
entry.delete(0, "end")
|
||||||
|
entry.config(fg="black")
|
||||||
|
|
||||||
|
def _restore_placeholder(self, entry, placeholder, var):
|
||||||
|
if not var.get().strip() or var.get().strip() == placeholder:
|
||||||
|
entry.delete(0, "end")
|
||||||
|
entry.insert(0, placeholder)
|
||||||
|
entry.config(fg="grey")
|
||||||
|
|
||||||
|
def populate_fields(self):
|
||||||
|
c = self.config
|
||||||
|
if c.get("api_key"):
|
||||||
|
self.api_key_var.set(c["api_key"])
|
||||||
|
if c.get("connection_id"):
|
||||||
|
self.conn_id_var.set(c["connection_id"])
|
||||||
|
if c.get("from_number"):
|
||||||
|
self.from_var.set(c["from_number"])
|
||||||
|
if c.get("to_number"):
|
||||||
|
self.to_var.set(c["to_number"])
|
||||||
|
if c.get("pdf_path"):
|
||||||
|
self.pdf_var.set(c["pdf_path"])
|
||||||
|
if c.get("ntfy_url"):
|
||||||
|
self.ntfy_url_var.set(c["ntfy_url"])
|
||||||
|
if c.get("ntfy_token"):
|
||||||
|
self.ntfy_token_var.set(c["ntfy_token"])
|
||||||
|
|
||||||
|
# ── Actions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def browse_pdf(self):
|
||||||
|
path = filedialog.askopenfilename(
|
||||||
|
title="Select Claim PDF",
|
||||||
|
filetypes=[("PDF files", "*.pdf")],
|
||||||
|
initialdir=str(CLAIMS_DIR) if CLAIMS_DIR.exists() else str(BASE_DIR),
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.pdf_var.set(path)
|
||||||
|
|
||||||
|
def validate(self) -> bool:
|
||||||
|
problems = []
|
||||||
|
if not self.api_key_var.get().strip():
|
||||||
|
problems.append("API Key is required")
|
||||||
|
if not self.conn_id_var.get().strip():
|
||||||
|
problems.append("Connection ID is required")
|
||||||
|
from_num = self.from_var.get().strip()
|
||||||
|
if not from_num or from_num == "+1XXXXXXXXXX":
|
||||||
|
problems.append("From Number is required")
|
||||||
|
to_num = self.to_var.get().strip()
|
||||||
|
if not to_num or to_num == "+18019382102":
|
||||||
|
problems.append("To Number is required")
|
||||||
|
pdf = self.pdf_var.get().strip()
|
||||||
|
if not pdf or not Path(pdf).is_file():
|
||||||
|
problems.append("Select a valid claim PDF file")
|
||||||
|
if problems:
|
||||||
|
messagebox.showerror("Missing Configuration", "\n".join(problems))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def toggle_running(self):
|
||||||
|
if self.running:
|
||||||
|
self.stop()
|
||||||
|
else:
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if not self.validate():
|
||||||
|
return
|
||||||
|
self.save_config()
|
||||||
|
self.running = True
|
||||||
|
self.start_btn.config(text="Stop Faxing", bg="#c0392b")
|
||||||
|
self.status_label.config(text="Running (hourly)", fg="#2d8a4e")
|
||||||
|
self.log("Started hourly fax schedule.")
|
||||||
|
self.timer_thread = threading.Thread(target=self.run_loop, daemon=True)
|
||||||
|
self.timer_thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
self.start_btn.config(text="Start Hourly Faxing", bg="#2d8a4e")
|
||||||
|
self.status_label.config(text="Stopped", fg="#e74c3c")
|
||||||
|
self.log("Stopped.")
|
||||||
|
|
||||||
|
def send_once(self):
|
||||||
|
if not self.validate():
|
||||||
|
return
|
||||||
|
self.save_config()
|
||||||
|
self.log("Sending single fax...")
|
||||||
|
threading.Thread(target=self._do_send, daemon=True).start()
|
||||||
|
|
||||||
|
def run_loop(self):
|
||||||
|
self._do_send()
|
||||||
|
while self.running:
|
||||||
|
# Sleep in small increments so we can stop quickly
|
||||||
|
for _ in range(3600):
|
||||||
|
if not self.running:
|
||||||
|
return
|
||||||
|
time.sleep(1)
|
||||||
|
if self.running:
|
||||||
|
self._do_send()
|
||||||
|
|
||||||
|
# ── Fax logic ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _headers(self):
|
||||||
|
return {"Authorization": f"Bearer {self.api_key_var.get().strip()}"}
|
||||||
|
|
||||||
|
def _do_send(self):
|
||||||
|
pdf_path = Path(self.pdf_var.get().strip())
|
||||||
|
to_number = self.to_var.get().strip()
|
||||||
|
from_number = self.from_var.get().strip()
|
||||||
|
conn_id = self.conn_id_var.get().strip()
|
||||||
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Upload media
|
||||||
|
self.log(f"Uploading {pdf_path.name}...")
|
||||||
|
media_name = f"autofax-claim-{pdf_path.stem}"
|
||||||
|
with open(pdf_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{API}/media",
|
||||||
|
headers={**self._headers(), "Content-Type": "application/pdf"},
|
||||||
|
params={"media_name": media_name},
|
||||||
|
data=f.read(),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
# Send fax
|
||||||
|
self.log(f"Sending fax to {to_number}...")
|
||||||
|
resp = requests.post(
|
||||||
|
f"{API}/faxes",
|
||||||
|
headers={**self._headers(), "Content-Type": "application/json"},
|
||||||
|
json={
|
||||||
|
"connection_id": conn_id,
|
||||||
|
"media_name": media_name,
|
||||||
|
"to": to_number,
|
||||||
|
"from": from_number,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
fax_data = resp.json().get("data", {})
|
||||||
|
fax_id = fax_data.get("id")
|
||||||
|
status = fax_data.get("status", "queued")
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"status": status,
|
||||||
|
"fax_id": fax_id,
|
||||||
|
"to": to_number,
|
||||||
|
"from": from_number,
|
||||||
|
"file": pdf_path.name,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Brief wait then check status
|
||||||
|
if fax_id and status == "queued":
|
||||||
|
time.sleep(15)
|
||||||
|
try:
|
||||||
|
sr = requests.get(
|
||||||
|
f"{API}/faxes/{fax_id}", headers=self._headers(), timeout=15,
|
||||||
|
)
|
||||||
|
sr.raise_for_status()
|
||||||
|
sd = sr.json().get("data", {})
|
||||||
|
entry["status"] = sd.get("status", status)
|
||||||
|
if entry["status"] == "failed":
|
||||||
|
entry["failure_reason"] = sd.get("failure_reason", "Unknown")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.log(f"Fax {entry['status'].upper()} (ID: {fax_id})")
|
||||||
|
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
error_detail = ""
|
||||||
|
try:
|
||||||
|
error_detail = e.response.text[:300]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
entry = {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"status": "send_failed",
|
||||||
|
"fax_id": None,
|
||||||
|
"to": to_number,
|
||||||
|
"from": from_number,
|
||||||
|
"file": pdf_path.name,
|
||||||
|
"error": f"{e} | {error_detail}",
|
||||||
|
}
|
||||||
|
self.log(f"FAILED: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
entry = {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"status": "send_failed",
|
||||||
|
"fax_id": None,
|
||||||
|
"to": to_number,
|
||||||
|
"from": from_number,
|
||||||
|
"file": pdf_path.name,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
self.log(f"FAILED: {e}")
|
||||||
|
|
||||||
|
# Save to log and regenerate report
|
||||||
|
self._save_entry(entry)
|
||||||
|
self._generate_report()
|
||||||
|
self._notify(entry)
|
||||||
|
self.update_status_display()
|
||||||
|
|
||||||
|
def _save_entry(self, entry: dict):
|
||||||
|
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
entries = []
|
||||||
|
if LOG_FILE.exists():
|
||||||
|
try:
|
||||||
|
entries = json.loads(LOG_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
entries.append(entry)
|
||||||
|
LOG_FILE.write_text(json.dumps(entries, indent=2))
|
||||||
|
|
||||||
|
def _notify(self, entry: dict):
|
||||||
|
ntfy_url = self.ntfy_url_var.get().strip()
|
||||||
|
if not ntfy_url or ntfy_url.startswith("https://ntfy.sh/your"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status_labels = {
|
||||||
|
"delivered": "Fax Delivered", "sent": "Fax Sent",
|
||||||
|
"queued": "Fax Queued", "sending": "Fax Sending",
|
||||||
|
}
|
||||||
|
subject = status_labels.get(entry["status"], "Fax Failed")
|
||||||
|
msg = f"To: {entry['to']}\nStatus: {entry['status']}\nFile: {entry['file']}"
|
||||||
|
if entry.get("error"):
|
||||||
|
msg += f"\nError: {entry['error']}"
|
||||||
|
hdrs = {"Title": subject}
|
||||||
|
ntfy_token = self.ntfy_token_var.get().strip()
|
||||||
|
if ntfy_token and not ntfy_token.startswith("Bearer tk_..."):
|
||||||
|
hdrs["Authorization"] = ntfy_token
|
||||||
|
if entry["status"] not in ("queued", "sending", "sent", "delivered"):
|
||||||
|
hdrs["Priority"] = "high"
|
||||||
|
requests.post(ntfy_url, data=msg.encode(), headers=hdrs, timeout=10)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _generate_report(self):
|
||||||
|
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
entries = []
|
||||||
|
if LOG_FILE.exists():
|
||||||
|
try:
|
||||||
|
entries = json.loads(LOG_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
|
||||||
|
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"):
|
||||||
|
sc, sd = "success", status.upper()
|
||||||
|
elif status in ("queued", "sending"):
|
||||||
|
sc, sd = "pending", status.upper()
|
||||||
|
else:
|
||||||
|
sc, sd = "failed", "FAILED"
|
||||||
|
|
||||||
|
error_col = e.get("error") or e.get("failure_reason") or "-"
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(e["timestamp"])
|
||||||
|
ts = dt.strftime("%b %d, %Y %I:%M %p UTC")
|
||||||
|
except Exception:
|
||||||
|
ts = e["timestamp"]
|
||||||
|
|
||||||
|
rows += f'<tr><td>{i}</td><td>{ts}</td><td>{e.get("to", "-")}</td>'
|
||||||
|
rows += f'<td class="{sc}">{sd}</td><td>{e.get("fax_id") or "-"}</td>'
|
||||||
|
rows += f'<td class="error">{error_col}</td></tr>\n'
|
||||||
|
|
||||||
|
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:#fff;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']}</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 & 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. Each row represents one
|
||||||
|
transmission attempt of the doula coverage claim (5 pages). Faxes are sent
|
||||||
|
once per hour. FAILED = the receiving fax machine did not accept.</p></div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
(REPORTS_DIR / "fax_report.html").write_text(html)
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def update_status_display(self):
|
||||||
|
"""Update the status label with attempt count."""
|
||||||
|
if LOG_FILE.exists():
|
||||||
|
try:
|
||||||
|
entries = json.loads(LOG_FILE.read_text())
|
||||||
|
total = len(entries)
|
||||||
|
failed = sum(1 for e in entries if e["status"] not in ("queued", "sending", "sent", "delivered"))
|
||||||
|
if total > 0:
|
||||||
|
extra = f" | {total} sent, {failed} failed"
|
||||||
|
current = self.status_label.cget("text").split(" |")[0]
|
||||||
|
self.status_label.config(text=f"{current}{extra}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def open_report(self):
|
||||||
|
report = REPORTS_DIR / "fax_report.html"
|
||||||
|
if not report.exists():
|
||||||
|
messagebox.showinfo("No Report", "No fax attempts recorded yet.")
|
||||||
|
return
|
||||||
|
# Cross-platform open
|
||||||
|
if sys.platform == "win32":
|
||||||
|
os.startfile(str(report))
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.run(["open", str(report)])
|
||||||
|
else:
|
||||||
|
subprocess.run(["xdg-open", str(report)])
|
||||||
|
|
||||||
|
def log(self, message: str):
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
line = f"[{timestamp}] {message}\n"
|
||||||
|
|
||||||
|
def _append():
|
||||||
|
self.log_text.config(state="normal")
|
||||||
|
self.log_text.insert("end", line)
|
||||||
|
self.log_text.see("end")
|
||||||
|
self.log_text.config(state="disabled")
|
||||||
|
|
||||||
|
self.root.after(0, _append)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
CLAIMS_DIR.mkdir(exist_ok=True)
|
||||||
|
REPORTS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
root = tk.Tk()
|
||||||
|
AutoFaxApp(root)
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
82
install.sh
Executable file
82
install.sh
Executable file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
VENV_DIR="$SCRIPT_DIR/venv"
|
||||||
|
PYTHON="$VENV_DIR/bin/python"
|
||||||
|
CRON_TAG="# autofax"
|
||||||
|
|
||||||
|
echo "=== AutoFax Setup ==="
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
"$VENV_DIR/bin/pip" install -q -r "$SCRIPT_DIR/requirements.txt"
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
mkdir -p "$SCRIPT_DIR/claims" "$SCRIPT_DIR/reports"
|
||||||
|
|
||||||
|
# Check for .env
|
||||||
|
if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "WARNING: No .env file found!"
|
||||||
|
echo "Copy .env.example to .env and fill in your credentials:"
|
||||||
|
echo " cp $SCRIPT_DIR/.env.example $SCRIPT_DIR/.env"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for claim PDF
|
||||||
|
PDF_COUNT=$(find "$SCRIPT_DIR/claims" -name "*.pdf" 2>/dev/null | wc -l)
|
||||||
|
if [ "$PDF_COUNT" -eq 0 ]; then
|
||||||
|
echo "WARNING: No PDF found in claims/ directory."
|
||||||
|
echo "Place your claim PDF in: $SCRIPT_DIR/claims/"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Calculate expiry date (7 days from now)
|
||||||
|
if date --version >/dev/null 2>&1; then
|
||||||
|
# GNU date
|
||||||
|
EXPIRY=$(date -d "+7 days" "+%Y-%m-%d %H:%M")
|
||||||
|
else
|
||||||
|
# BSD/macOS date
|
||||||
|
EXPIRY=$(date -v+7d "+%Y-%m-%d %H:%M")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install cron jobs
|
||||||
|
echo "Installing cron jobs..."
|
||||||
|
# Remove any existing autofax entries
|
||||||
|
crontab -l 2>/dev/null | grep -v "$CRON_TAG" > /tmp/autofax_cron || true
|
||||||
|
|
||||||
|
# Hourly fax job
|
||||||
|
echo "0 * * * * $PYTHON $SCRIPT_DIR/autofax.py >> $SCRIPT_DIR/reports/cron.log 2>&1 $CRON_TAG" >> /tmp/autofax_cron
|
||||||
|
|
||||||
|
# Self-destruct: remove autofax cron entries after 7 days
|
||||||
|
# Runs once at the expiry time, removes all autofax lines, then removes itself
|
||||||
|
echo "0 0 * * * $PYTHON -c \"
|
||||||
|
import subprocess, datetime
|
||||||
|
if datetime.datetime.now() >= datetime.datetime.fromisoformat('$(date -d '+7 days' '+%Y-%m-%dT%H:%M' 2>/dev/null || date -v+7d '+%Y-%m-%dT%H:%M')'):
|
||||||
|
result = subprocess.run(['crontab', '-l'], capture_output=True, text=True)
|
||||||
|
lines = [l for l in result.stdout.splitlines() if 'autofax' not in l]
|
||||||
|
subprocess.run(['crontab', '-'], input=chr(10).join(lines), text=True)
|
||||||
|
\" 2>/dev/null $CRON_TAG" >> /tmp/autofax_cron
|
||||||
|
|
||||||
|
crontab /tmp/autofax_cron
|
||||||
|
rm /tmp/autofax_cron
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Setup Complete ==="
|
||||||
|
echo "Fax will be sent every hour on the hour."
|
||||||
|
echo "Auto-expires: $EXPIRY (7 days from now)"
|
||||||
|
echo ""
|
||||||
|
echo "Checklist:"
|
||||||
|
echo " [ ] .env file configured with Telnyx credentials"
|
||||||
|
echo " [ ] Claim PDF placed in claims/ directory"
|
||||||
|
echo ""
|
||||||
|
echo "Manual test: $PYTHON $SCRIPT_DIR/autofax.py"
|
||||||
|
echo "View report: open $SCRIPT_DIR/reports/fax_report.html"
|
||||||
|
echo "View log: cat $SCRIPT_DIR/reports/fax_log.json"
|
||||||
|
echo "Remove cron: crontab -l | grep -v autofax | crontab -"
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
requests>=2.28.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
9
uninstall.sh
Executable file
9
uninstall.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Removing AutoFax cron jobs..."
|
||||||
|
crontab -l 2>/dev/null | grep -v "autofax" | crontab - 2>/dev/null || true
|
||||||
|
echo "Done. Cron jobs removed."
|
||||||
|
echo ""
|
||||||
|
echo "Reports are preserved in: $(cd "$(dirname "$0")" && pwd)/reports/"
|
||||||
|
echo "To fully remove: rm -rf $(cd "$(dirname "$0")" && pwd)/venv"
|
||||||
Loading…
Reference in a new issue