Convert-by-Email Service
The convert-by-email service lets registered customers email a PDF to [email protected] and receive an accessible HTML version back as an email reply. No browser, no login, no software to install. PDF is the only accepted input format.
Address routing: The user-facing address is [email protected]. A Google Workspace default-routing rule rewrites the envelope recipient to [email protected], which has its MX records pointing at Cloudflare Email Routing. CF then dispatches to the convert-email Worker. Replies are sent from [email protected] via the Gmail API. The two addresses exist because theaccessible.org MX is on Google Workspace and canβt be moved to Cloudflare without breaking the rest of the orgβs mail; pdf-html.com is a dedicated zone used solely as an inbound landing pad for the Worker.
How It Works
User emails PDF to [email protected] β βΌGoogle Workspace (theaccessible.org) β Default-routing rule: [email protected] β β "Change envelope recipient" β [email protected] βΌCloudflare Email Routing (pdf-html.com) β (MX records for pdf-html.com point to Cloudflare) β (rule: [email protected] β convert-email Worker) βΌconvert-email Worker (workers/convert-email/) β ββ 1. Parse MIME, extract attachment (postal-mime) ββ 2. Look up sender in Supabase (customers table) ββ 3. Validate file type, size (25MB max) ββ 4. Estimate pages, pre-check credits ββ 5. Send acknowledgment email via Gmail API ββ 6. Store source file in R2 (temporary) ββ 7. Call accessible-pdf-api via Service Binding ββ 8. Calculate actual credits (flat 1 credit per page) ββ 9. Deduct credits in Supabase (atomic RPC) ββ 10. Send result email with accessible HTML attached ββ 11. Log job in Supabase (conversion_jobs table) ββ 12. Clean up R2 (async)Infrastructure
| Component | Details |
|---|---|
| Worker | convert-email on Cloudflare Workers |
| Worker URL | https://convert-email.larry-c6c.workers.dev |
| Pipeline | Service Binding to accessible-pdf-api (no auth, no network hop) |
| R2 Bucket | convert-email-files (temporary file storage during processing) |
| Supabase | Project vuvwmfxssjosfphzpzim β customers and conversion_jobs tables |
| Outbound email | Gmail API via Google Workspace service account with domain-wide delegation |
| Inbound email | User-facing: [email protected] (Google Workspace alias) β [email protected] (Cloudflare Email Routing) β Worker |
Source Files
All source is in workers/convert/src/:
| File | Purpose |
|---|---|
index.js | Main entry point. Handles the full email-to-reply flow. Also exposes /health and /test-send HTTP endpoints. |
gmail.js | Gmail API module. JWT signing with Web Crypto API, token exchange, MIME building, send/reply/attachment methods. |
templates.js | Branded HTML email templates for every reply type (unregistered, no attachment, unsupported file, insufficient credits, processing started, success, error). |
supabase.js | REST client for customer lookups, credit deduction (via atomic RPC), and job logging. |
mime.js | MIME parsing via postal-mime, file type detection, base64 utilities. |
credits.js | Credit calculation: 1 per standard page, 2 per complex page. |
Gmail API Auth Chain
The Worker sends all outbound email via the Gmail API using a Google Workspace service account:
- Worker loads the service account JSON key from the
GOOGLE_SERVICE_ACCOUNT_KEYsecret - Creates a JWT signed with the service accountβs RSA private key (Web Crypto API β no npm dependencies)
- Exchanges the JWT for an access token at
https://oauth2.googleapis.com/token - Calls
POST gmail.googleapis.com/gmail/v1/users/me/messages/sendwith a base64url-encoded MIME message - Google sends the email as
[email protected]
The access token is cached in memory for up to 1 hour within a single Worker invocation.
Google Cloud setup (already configured)
- Project:
TheAccessibleOrg-Email - API: Gmail API enabled
- Service account:
convert-email-senderwith a JSON key stored as a Worker secret
Google Admin Console (already configured)
- Domain-wide delegation: The service accountβs Client ID is authorized for scope
https://www.googleapis.com/auth/gmail.send - This allows the service account to send email as any user in the domain
Supabase Schema
customers
| Column | Type | Notes |
|---|---|---|
| id | uuid | Primary key |
| text | Unique, indexed β the senderβs email | |
| name | text | |
| plan_type | text | free or paid |
| credits_remaining | integer | Paid tier only |
| lifetime_free_pages_used | integer | Default 0, max 10 |
| created_at | timestamptz |
conversion_jobs
| Column | Type | Notes |
|---|---|---|
| id | uuid | Primary key |
| customer_id | uuid | FK to customers |
| sender_email | text | |
| filename | text | |
| file_type | text | pdf, docx, html |
| file_size_bytes | integer | |
| total_pages | integer | |
| standard_pages | integer | |
| complex_pages | integer | |
| credits_charged | integer | |
| status | text | success or error |
| error_message | text | Nullable |
| created_at | timestamptz |
RPC Functions
deduct_credits(p_customer_id, p_amount)β Atomic credit deduction. Raises exception if insufficient.use_free_pages(p_customer_id, p_pages)β Atomic free page usage. Raises exception if lifetime limit (10) exceeded.
Credit System
Every page costs 1 credit, including complex pages (equations, tables, forms, multi-column) β the high-fidelity 2x multiplier was retired 2026-06-12 (HIGH_FIDELITY_CREDIT_MULTIPLIER = 1).
Free tier: 10 lifetime pages.
The Worker estimates page count from file size before processing (for pre-check), then uses the actual page classification returned by the pipeline for the final charge.
Secrets
Set via npx wrangler secret put <NAME> from the workers/convert/ directory:
| Secret | Purpose |
|---|---|
GOOGLE_SERVICE_ACCOUNT_KEY | Full JSON contents of the Google service account key file |
SUPABASE_SERVICE_KEY | Supabase service_role key for server-side access |
The PIPELINE_API_KEY secret is not needed β the pipeline is called via a Cloudflare Service Binding which bypasses auth.
Email Routing Setup
Two pieces β Google Workspace alias, then Cloudflare dispatch.
Google Workspace (theaccessible.org):
- Admin console β Apps β Google Workspace β Gmail β Default routing β Add setting.
- Envelope filter: βOnly affect specific envelope recipientsβ β exact match
[email protected]. - Action: Change envelope recipient β
[email protected]. - Save. No mailbox/alias/group needed at the Google end β the rule accepts unknown recipients.
Cloudflare (pdf-html.com):
- Dashboard β
pdf-html.comβ Email β Email Routing β Routing Rules. - Add rule:
[email protected]β Send to Worker βconvert-email. - MX records for
pdf-html.commust point to Cloudflare (configured automatically when Email Routing is enabled).
Deployment
cd workers/convertnpm installnpx wrangler deployTesting
Health check
curl https://convert-email.larry-c6c.workers.dev/healthTest Gmail sending
curl -X POST https://convert-email.larry-c6c.workers.dev/test-send \ -H "Content-Type: application/json" \Full flow test
Send an email with a PDF attached to [email protected] from an email address that exists in the customers table.
Error Handling
- Unregistered sender: Gets a branded reply explaining how to register.
- No attachment: Gets a reply asking them to attach a PDF.
- Unsupported file type: Gets a reply explaining that only PDF files are accepted via email.
- File too large: Gets an error reply (25MB limit).
- Insufficient credits: Gets a reply with their balance and a link to purchase more.
- Pipeline failure: Gets an error reply. No credits charged. Error logged to
conversion_jobs. - Gmail send failure: Logged to console. The Worker catches and logs notification failures separately so they donβt mask the original error.
Reusing GmailSender in Other Workers
The GmailSender class in src/gmail.js is a standalone module. To use it in another Worker:
- Copy
gmail.jsinto the other Worker - Add the same two secrets (
GOOGLE_SERVICE_ACCOUNT_KEY,GMAIL_SENDER) - Import and call
new GmailSender(env).send(to, subject, text, html)
No additional Google configuration needed β the service account has domain-wide delegation.