Convert-by-Email Service
The convert-by-email service lets registered customers email a document to convert@pdf-html.com and receive an accessible HTML version back as an email reply. No browser, no login, no software to install.
Address split: Inbound email goes to convert@pdf-html.com (Cloudflare Email Routing), but outbound replies come from convert@theaccessible.org (Gmail API). This is because theaccessible.org MX records point to Google Workspace and canβt be moved to Cloudflare without breaking existing email. pdf-html.com is used solely for Cloudflare Email Routing inbound. convert@theaccessible.org is configured as an alias on the adminβs Google Workspace account.
How It Works
User emails PDF/DOCX/HTML to convert@pdf-html.com β βΌCloudflare Email Routing β (MX records for pdf-html.com point to Cloudflare) β (rule: convert@pdf-html.com β convert-email Worker) βΌconvert-email Worker (workers/convert/) β ββ 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 (1 standard, 2 complex 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 | Cloudflare Email Routing: convert@pdf-html.com β 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
convert@pdf-html.com
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
| Page type | Credits |
|---|---|
| Standard (text, images, simple lists) | 1 per page |
| Complex (equations, tables, forms, multi-column) | 2 per page |
Free tier: 10 lifetime pages (complex pages count as 2 from the allocation).
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
In the Cloudflare dashboard for pdf-html.com:
- Email > Email Routing > Routing Rules
- Add rule:
convert@pdf-html.comβ Send to Worker βconvert-email
MX records for pdf-html.com must 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" \ -d '{"to":"larry@anglin.com","subject":"Test","body":"Hello from convert-email"}'Full flow test
Send an email with a PDF attached to convert@pdf-html.com 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 file.
- Unsupported file type: Gets a reply listing supported formats (PDF, DOCX, HTML).
- 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.