Skip to content

Commit 5c27317

Browse files
committed
docs: update .env.example and HIPAA requirements to include Sinch backend configuration; enhance MCP integration guide with transport options and file handling best practices
1 parent 141d2a7 commit 5c27317

25 files changed

+826
-312
lines changed

.env.example

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ FAX_DISABLED=false
66
API_KEY=your_secure_api_key_here
77

88
# Backend Selection - Choose ONE
9-
# Options: "sip" (self-hosted) or "phaxio" (cloud)
9+
# Options: "sip" (self-hosted), "phaxio" (cloud fetch), or "sinch" (cloud direct upload)
1010
FAX_BACKEND=sip
1111

1212
# === PHAXIO CLOUD BACKEND ===
@@ -21,6 +21,15 @@ PHAXIO_CALLBACK_URL=http://localhost:8080/phaxio-callback
2121
PHAXIO_VERIFY_SIGNATURE=true
2222
ENFORCE_PUBLIC_HTTPS=false
2323

24+
# === SINCH FAX API v3 (Phaxio by Sinch) ===
25+
# Only needed if FAX_BACKEND=sinch
26+
# If left blank, SINCH_API_* fall back to PHAXIO_* values.
27+
SINCH_PROJECT_ID=your_sinch_project_id
28+
SINCH_API_KEY=
29+
SINCH_API_SECRET=
30+
# Optional region override (defaults to https://fax.api.sinch.com/v3)
31+
# SINCH_BASE_URL=https://us.fax.api.sinch.com/v3
32+
2433
# === SIP/ASTERISK BACKEND ===
2534
# Only needed if FAX_BACKEND=sip
2635
ASTERISK_AMI_HOST=asterisk

HIPAA_REQUIREMENTS.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,22 @@ Implement the following as minimum controls:
9292
- Rotate credentials periodically. Log and alert on failed auth.
9393

9494
## MCP (AI Assistant) Considerations
95-
- MCP transmits base64 file content for the `send_fax` tool. Treat the MCP server with the same controls as the API (auth, network restrictions, audit logging).
96-
- Do not send PHI to LLMs or external services unless you have a signed BAA and an approved use case.
97-
- All MCP servers must require authentication:
95+
- Stdio vs HTTP/SSE transports
96+
- Stdio (local): connects tools directly to desktop assistants without a network server. Convenient for individuals. Not generally used for provider‑side HIPAA workflows.
97+
- HTTP/SSE (server): network transports that can be authenticated (API key, OAuth2/JWT) and deployed under your security program. Use SSE+OAuth for provider‑side HIPAA workflows.
98+
- File handling
99+
- For stdio, prefer `send_fax` with `filePath` to avoid embedding PHI as base64 in conversations.
100+
- For HTTP/SSE, tool inputs are JSON; base64 increases size and token exposure. Enforce auth and rate limits and avoid logging request bodies.
101+
- Do not send PHI to LLMs or external services unless covered by a BAA and approved by policy. Faxbot’s MCP servers call your Faxbot API; they do not upload PHI to model providers.
102+
- All MCP servers must require authentication where applicable:
98103
- REST API: `X-API-Key` for /fax endpoints.
99104
- MCP HTTP/SSE: `Authorization: Bearer <JWT>` verified against your OIDC JWKS.
100105
- Serve MCP over TLS. Never log PHI (file content, rendered pages). Log only job IDs and metadata.
101106

107+
## Roles and Transport Choice (Practical Guidance)
108+
- Healthcare providers (CE/BA): use HTTPS for API, `phaxio` with HMAC or `sinch` with auth; for MCP use SSE+OAuth or skip MCP and call REST/SDKs directly.
109+
- Patients/individuals sending their own documents: HIPAA obligations differ; using local stdio MCP is generally acceptable. The receiving provider bears most compliance obligations upon receipt. Providers must still secure inbound faxes on their systems.
110+
102111
## Operational Checklist (Minimum)
103112
- [ ] Signed BAA with Phaxio (if using cloud backend).
104113
- [ ] TLS everywhere (HTTPS for public endpoints; VPN/private link for SIP media).

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ Simple fax-sending API with AI integration. Choose your backend:
1313

1414
[→ Phaxio Setup Guide](docs/PHAXIO_SETUP.md)
1515

16-
### Option 2: Self-Hosted SIP/Asterisk
16+
### Option 2: Sinch Fax API v3 (Cloud)
17+
- Direct upload model (no PUBLIC_API_URL fetch)
18+
- Works with “Phaxio by Sinch” accounts
19+
- Requires Project ID + API key/secret
20+
21+
[→ Sinch Setup Guide](docs/SINCH_SETUP.md)
22+
23+
### Option 3: Self-Hosted SIP/Asterisk
1724
- Full control
1825
- No per-fax cloud charges
1926
- Requires SIP trunk and T.38 knowledge
@@ -27,6 +34,17 @@ Simple fax-sending API with AI integration. Choose your backend:
2734
- Legacy servers remain under `api/` and Python `python_mcp/`.
2835
- OAuth2‑protected SSE MCP servers are available in both Node and Python.
2936

37+
Important file-type note
38+
- Faxbot accepts only PDF and TXT. If you have images (PNG/JPG), convert them to PDF before sending.
39+
- Quick conversions:
40+
- macOS Preview: File → Export As… → PDF
41+
- macOS CLI: `sips -s format pdf "in.png" --out "out.pdf"`
42+
- Linux: `img2pdf in.png -o out.pdf` or `magick convert in.png out.pdf`
43+
- Windows: open image → Print → “Microsoft Print to PDF”.
44+
45+
Stdio “just works” tip
46+
- For desktop assistants, prefer the Node or Python stdio MCP and call `send_fax` with `filePath` to your local PDF/TXT. This bypasses base64 and avoids token limits.
47+
3048
## Client SDKs
3149
- Python: `pip install faxbot`
3250
- Node.js: `npm install faxbot`
@@ -37,6 +55,7 @@ Simple fax-sending API with AI integration. Choose your backend:
3755
- [API Reference](docs/API_REFERENCE.md) — Endpoints and examples
3856
- [Troubleshooting](docs/TROUBLESHOOTING.md) — Common issues
3957
- [HIPAA Requirements](HIPAA_REQUIREMENTS.md) — Security, BAAs, and compliance checklist
58+
- [Images vs Text PDFs](docs/IMAGES_AND_PDFS.md) — The right way to fax scans/photos
4059

4160
## Notes
4261
- Send-only. Receiving is out of scope.

TODO.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Faxbot Security Audit Report - Critical & High Priority Findings
22

3+
# Faxbot Security Audit Report - Critical & High Priority Findings
4+
5+
## Immediate
6+
- Implement Sinch webhook handling to reach parity with Phaxio
7+
- Add `/sinch-callback` endpoint and signature/auth validation per Sinch docs
8+
- Map provider events to queued/in_progress/SUCCESS/FAILED
9+
- Update tests and docs (API_REFERENCE.md, SINCH_SETUP.md, TROUBLESHOOTING.md)
10+
- Consider configurable verification and retention rules analogous to Phaxio HMAC
11+
312
## Executive Summary
413

514
**Critical security and compliance issues identified:**
@@ -138,4 +147,4 @@
138147
**Documentation Gaps:**
139148
- HIPAA_REQUIREMENTS.md mentions controls not yet implemented in codebase
140149
- Missing BAA templates and risk analysis templates referenced in documentation
141-
- Troubleshooting guide doesn't cover security-specific error scenarios
150+
- Troubleshooting guide doesn't cover security-specific error scenarios

api/app/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ class Settings(BaseModel):
3131
# Public API URL (needed for cloud backend to fetch PDFs, e.g., Phaxio)
3232
public_api_url: str = Field(default_factory=lambda: os.getenv("PUBLIC_API_URL", "http://localhost:8080"))
3333

34+
# Sinch Fax (Phaxio by Sinch) — direct upload flow
35+
sinch_project_id: str = Field(default_factory=lambda: os.getenv("SINCH_PROJECT_ID", ""))
36+
sinch_api_key: str = Field(default_factory=lambda: os.getenv("SINCH_API_KEY", os.getenv("PHAXIO_API_KEY", "")))
37+
sinch_api_secret: str = Field(default_factory=lambda: os.getenv("SINCH_API_SECRET", os.getenv("PHAXIO_API_SECRET", "")))
38+
3439
# Fax presentation
3540
fax_header: str = Field(default_factory=lambda: os.getenv("FAX_HEADER", "Faxbot"))
3641
fax_station_id: str = Field(default_factory=lambda: os.getenv("FAX_LOCAL_STATION_ID", "+10000000000"))

api/app/main.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .conversion import ensure_dir, txt_to_pdf, pdf_to_tiff
1515
from .ami import ami_client
1616
from .phaxio_service import get_phaxio_service
17+
from .sinch_service import get_sinch_service
1718
import hmac
1819
import hashlib
1920
from urllib.parse import urlparse
@@ -146,6 +147,10 @@ async def send_fax(background: BackgroundTasks, to: str = Form(...), file: Uploa
146147
if settings.fax_disabled:
147148
# Test mode: nothing else to prepare
148149
pass
150+
elif settings.fax_backend == "sinch":
151+
# Sinch cloud backend: no local TIFF; provider handles rasterization
152+
pages = None
153+
# nothing else to prepare here
149154
else:
150155
# SIP/Asterisk requires TIFF
151156
if settings.fax_disabled:
@@ -175,10 +180,10 @@ async def send_fax(background: BackgroundTasks, to: str = Form(...), file: Uploa
175180
# Kick off fax sending based on backend
176181
if not settings.fax_disabled:
177182
if settings.fax_backend == "phaxio":
178-
# For Phaxio, we need a public URL for the PDF
179183
background.add_task(_send_via_phaxio, job_id, to, pdf_path)
184+
elif settings.fax_backend == "sinch":
185+
background.add_task(_send_via_sinch, job_id, to, pdf_path)
180186
else:
181-
# SIP/Asterisk backend
182187
background.add_task(_originate_job, job_id, to, tiff_path)
183188

184189
return _serialize_job(job)
@@ -418,6 +423,49 @@ async def _send_via_phaxio(job_id: str, to: str, pdf_path: str):
418423
audit_event("job_failed", job_id=job_id, error=str(e))
419424

420425

426+
async def _send_via_sinch(job_id: str, to: str, pdf_path: str):
427+
"""Send fax via Sinch Fax API v3 (Phaxio by Sinch)."""
428+
try:
429+
sinch = get_sinch_service()
430+
if not sinch or not sinch.is_configured():
431+
raise Exception("Sinch Fax is not properly configured")
432+
433+
audit_event("job_dispatch", job_id=job_id, method="sinch")
434+
435+
# Create fax by uploading the PDF directly (multipart/form-data)
436+
resp = await sinch.send_fax_file(to, pdf_path)
437+
438+
fax_id = str(resp.get("id") or resp.get("data", {}).get("id") or "")
439+
status = (resp.get("status") or resp.get("data", {}).get("status") or "in_progress").upper()
440+
if status == "IN_PROGRESS":
441+
internal_status = "in_progress"
442+
elif status in {"SUCCESS", "COMPLETED", "COMPLETED_OK"}:
443+
internal_status = "SUCCESS"
444+
elif status in {"FAILED", "FAILURE", "ERROR"}:
445+
internal_status = "FAILED"
446+
else:
447+
internal_status = "queued"
448+
449+
with SessionLocal() as db:
450+
job = db.get(FaxJob, job_id)
451+
if job:
452+
job.provider_sid = fax_id
453+
job.status = internal_status
454+
job.updated_at = datetime.utcnow()
455+
db.add(job)
456+
db.commit()
457+
except Exception as e:
458+
with SessionLocal() as db:
459+
job = db.get(FaxJob, job_id)
460+
if job:
461+
job.status = "failed"
462+
job.error = str(e)
463+
job.updated_at = datetime.utcnow()
464+
db.add(job)
465+
db.commit()
466+
audit_event("job_failed", job_id=job_id, error=str(e))
467+
468+
421469
def _serialize_job(job: FaxJob) -> FaxJobOut:
422470
return FaxJobOut(
423471
id=job.id,

api/app/sinch_service.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import asyncio
2+
from typing import Optional, Dict, Any
3+
import httpx
4+
import logging
5+
import os
6+
7+
from .config import settings, reload_settings
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class SinchFaxService:
13+
"""
14+
Sinch Fax API v3 integration ("Phaxio by Sinch").
15+
16+
Flow:
17+
1) POST /v3/projects/{projectId}/files (multipart/form-data) → returns file id
18+
2) POST /v3/projects/{projectId}/faxes { to, file } → returns fax object (id/status)
19+
3) GET /v3/projects/{projectId}/faxes/{id} → poll status (optional)
20+
"""
21+
22+
DEFAULT_BASES = (
23+
"https://fax.api.sinch.com/v3",
24+
"https://us.fax.api.sinch.com/v3",
25+
"https://eu.fax.api.sinch.com/v3",
26+
)
27+
28+
def __init__(self, project_id: str, api_key: str, api_secret: str, base_url: Optional[str] = None):
29+
self.project_id = project_id
30+
self.api_key = api_key
31+
self.api_secret = api_secret
32+
self.base_url = base_url or os.getenv("SINCH_BASE_URL") or self.DEFAULT_BASES[0]
33+
34+
def is_configured(self) -> bool:
35+
return bool(self.project_id and self.api_key and self.api_secret)
36+
37+
def _auth(self) -> tuple[str, str]:
38+
return (self.api_key, self.api_secret)
39+
40+
async def upload_file(self, file_path: str) -> int:
41+
if not os.path.exists(file_path):
42+
raise FileNotFoundError(file_path)
43+
urls = [self.base_url] + [b for b in self.DEFAULT_BASES if b != self.base_url]
44+
last = None
45+
for base in urls:
46+
url = f"{base}/projects/{self.project_id}/files"
47+
try:
48+
async with httpx.AsyncClient(timeout=60.0) as client:
49+
files = {"file": (os.path.basename(file_path), open(file_path, "rb"), "application/pdf")}
50+
resp = await client.post(url, files=files, auth=self._auth())
51+
if resp.status_code < 400:
52+
data = resp.json()
53+
file_id = data.get("id") or data.get("data", {}).get("id")
54+
if file_id is None:
55+
raise RuntimeError(f"Unexpected Sinch upload response: {data}")
56+
return int(file_id)
57+
last = (url, resp.status_code, resp.text)
58+
except Exception as e: # pragma: no cover
59+
last = (url, "exception", str(e))
60+
continue
61+
raise RuntimeError(f"Sinch file upload failed: {last}")
62+
files = {"file": (os.path.basename(file_path), open(file_path, "rb"), "application/pdf")}
63+
async with httpx.AsyncClient(timeout=60.0) as client:
64+
resp = await client.post(url, files=files, auth=self._auth())
65+
if resp.status_code >= 400:
66+
raise RuntimeError(f"Sinch file upload error {resp.status_code}: {resp.text}")
67+
data = resp.json()
68+
file_id = data.get("id") or data.get("data", {}).get("id")
69+
if file_id is None:
70+
raise RuntimeError(f"Unexpected Sinch upload response: {data}")
71+
return int(file_id)
72+
73+
async def send_fax(self, to_number: str, file_id: int) -> Dict[str, Any]:
74+
# Normalize number to E.164 if possible
75+
to = to_number
76+
if not to.startswith('+'):
77+
digits = ''.join(c for c in to if c.isdigit())
78+
if len(digits) >= 10:
79+
to = f"+{digits}"
80+
url = f"{self.base_url}/projects/{self.project_id}/faxes"
81+
payload = {"to": to, "file": file_id}
82+
async with httpx.AsyncClient(timeout=30.0) as client:
83+
resp = await client.post(url, json=payload, auth=self._auth())
84+
if resp.status_code >= 400:
85+
raise RuntimeError(f"Sinch create fax error {resp.status_code}: {resp.text}")
86+
return resp.json()
87+
88+
async def get_fax_status(self, fax_id: str) -> Dict[str, Any]:
89+
url = f"{self.BASE_URL}/projects/{self.project_id}/faxes/{fax_id}"
90+
async with httpx.AsyncClient(timeout=15.0) as client:
91+
resp = await client.get(url, auth=self._auth())
92+
resp.raise_for_status()
93+
return resp.json()
94+
95+
async def send_fax_file(self, to_number: str, file_path: str) -> Dict[str, Any]:
96+
"""Create a fax by posting the file directly as multipart/form-data.
97+
98+
This mirrors what the Sinch console does and avoids a separate /files upload.
99+
"""
100+
if not os.path.exists(file_path):
101+
raise FileNotFoundError(file_path)
102+
to = to_number
103+
if not to.startswith('+'):
104+
digits = ''.join(c for c in to if c.isdigit())
105+
if len(digits) >= 10:
106+
to = f"+{digits}"
107+
url = f"{self.base_url}/projects/{self.project_id}/faxes"
108+
async with httpx.AsyncClient(timeout=60.0) as client:
109+
files = {
110+
"file": (os.path.basename(file_path), open(file_path, "rb"), "application/pdf"),
111+
"to": (None, to),
112+
}
113+
resp = await client.post(url, files=files, auth=self._auth())
114+
if resp.status_code >= 400:
115+
raise RuntimeError(f"Sinch create fax error {resp.status_code}: {resp.text}")
116+
return resp.json()
117+
118+
119+
_sinch_service: Optional[SinchFaxService] = None
120+
121+
122+
def get_sinch_service() -> Optional[SinchFaxService]:
123+
global _sinch_service
124+
reload_settings()
125+
if not (settings.sinch_project_id and settings.sinch_api_key and settings.sinch_api_secret):
126+
_sinch_service = None
127+
return None
128+
if _sinch_service is None:
129+
_sinch_service = SinchFaxService(
130+
project_id=settings.sinch_project_id,
131+
api_key=settings.sinch_api_key,
132+
api_secret=settings.sinch_api_secret,
133+
base_url=os.getenv("SINCH_BASE_URL") or None,
134+
)
135+
return _sinch_service

docs/API_REFERENCE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,17 @@ curl -H "X-API-Key: $API_KEY" http://localhost:8080/fax/$JOB_ID
5050
- `status: string` (queued | in_progress | SUCCESS | FAILED | disabled)
5151
- `error?: string`
5252
- `pages?: number`
53-
- `backend: string` ("phaxio" or "sip")
53+
- `backend: string` ("phaxio", "sinch", or "sip")
5454
- `provider_sid?: string`
5555
- `created_at: ISO8601`
5656
- `updated_at: ISO8601`
5757

5858
## Notes
59-
- Backend chosen via `FAX_BACKEND` env var.
59+
- Backend chosen via `FAX_BACKEND` env var: `phaxio` (cloud via Phaxio/Phaxio‑by‑Sinch V2 style), `sinch` (cloud via Sinch Fax API v3 direct upload), or `sip` (self‑hosted Asterisk).
6060
- TXT files are converted to PDF before TIFF conversion.
6161
- If Ghostscript is missing, TIFF step is stubbed with pages=1; install for production.
62-
- For the Phaxio backend, TIFF conversion is skipped; page count is finalized via the provider callback.
62+
- For the `phaxio` backend, TIFF conversion is skipped; page count is finalized via the provider callback (`/phaxio-callback`, HMAC verification supported).
63+
- For the `sinch` backend, the API uploads your PDF directly to Sinch. Webhook support is under evaluation; status reflects the provider’s immediate response and may be updated by polling in future versions.
6364
- Tokenized PDF access has a TTL (`PDF_TOKEN_TTL_MINUTES`, default 60). The `/fax/{id}/pdf?token=...` link expires after TTL.
6465
- Optional retention: enable automatic cleanup of artifacts by setting `ARTIFACT_TTL_DAYS>0` (default disabled). Cleanup runs every `CLEANUP_INTERVAL_MINUTES` (default 1440).
6566

0 commit comments

Comments
 (0)