Hardening Email Rendering Across Gmail Web & Mobile: A Case Study in CSS Containment & CloudFront URL Rewriting
This post documents a production incident and the infrastructure fixes deployed to prevent email rendering failures in Gmail Web, plus a parallel effort to eliminate 404s across a legacy domain using CloudFront Functions.
The Problem: Gmail Web Stripping Outer `<div>` Backgrounds
On May 17, a crew dispatch email sent via AWS SES rendered as unreadable white text on white background when viewed in Gmail Web. The root cause: the email template wrapped the entire message in a `<div style="background:#0a1628">`, expecting the dark navy background to render as the email backdrop.
Gmail Web strips outer `<div>` styles at the message boundary. Mobile clients and Outlook preserve them, but the Web client—where most users read mail—rendered the wrapper as transparent, leaving cream-colored text (`#f3efe7`) on the default white canvas. This broke readability for 14 crew members and required immediate resend.
Technical Solution: Table-Wrapped Backgrounds with `!important` & CSS Containment
The fix replaces the unsafe `<div>` wrapper with an explicit `<table>` structure, applying `bgcolor` attributes at every cell level with `!important` overrides:
<table bgcolor="#0a1628" cellpadding="0" cellspacing="0"
style="background-color: #0a1628 !important; width: 100%;">
<tr>
<td bgcolor="#0a1628" style="background-color: #0a1628 !important; padding: 20px;">
<!-- content -->
</td>
</tr>
</table>
Why this works:
- HTML `bgcolor` attributes: Predate CSS and are respected by all email clients, including Gmail Web's sanitizer, because they're not style-strippable.
- `!important` on every `style`: Defeats Gmail's specificity resets without triggering spam filters (used sparingly, only on color safety-critical rules).
- Per-cell containment: Each `<td>` carries its own background declaration, preventing inheritance collapse if outer layers are stripped.
The corrected email was resent May 20 via `/tmp/uniform_resend.py` (custom SES wrapper) to the original 14-person crew list, with an added validation gate: no email can be sent until it renders correctly in a Gmail Web pane simulator.
Infrastructure: Email Build & Delivery Pipeline
The resend flow involved three layers:
1. Template Layer — Updated `/var/folders/_h/.../memory/feedback_email_light_theme.md` to document the "blue background, gold accents, white text" JADA brand standard and the new `<table bgcolor>` rule.
2. Build Layer — Created `/tmp/uniform_resend.py`, a Python script that:
- Reads the crew roster from DynamoDB (queenofsandiego crew table)
- Constructs the email with the hardened table-based HTML
- Invokes AWS SES `SendEmail` API with UTF-8 charset and explicit `MessageId` tracking
- Logs delivery status (resulting MessageId: `0100019e45b138ad-…`)
3. Delivery Layer — SES was chosen over SNS/Lambda for batch sends because it provides direct MessageId tracking and doesn't trigger the per-message Lambda cold start overhead. Travis Neel, marked as SMS-only in the crew system, received the rule via Twilio SMS to 530-262-5427 instead.
New Page: Uniforms Reference at `/uniforms.html`
To ensure the crew had a persistent, readable reference, a new page was deployed:
- Path: `/Users/cb/Documents/repos/sites/queenofsandiego.com/uniforms.html`
- Live URL: https://queenofsandiego.com/uniforms.html
- Design: Dark navy (`#0a1628`) with gold accent borders, matching the resend email and JADA brand
- Content: Two cards — one for ash scattering uniform requirements, one for standard charters
- Meta tag: `<meta name="robots" content="noindex">` — internal reference only, not SEO-indexed
- Deployment: Pushed to S3 bucket `queenofsandiego-prod` with CloudFront invalidation (no overwrite, new file)
Parallel Fix: CloudFront Function for 404 Elimination on dangerouscentaur.com
While addressing email rendering, a separate incident surfaced: dangerouscentaur.com was returning 404s for valid URLs. Root cause: the site had been migrated but the routing rules hadn't been updated.
CloudFront Function Deployment
Created `/tmp/dc-clean-urls.js`, a CloudFront Function (viewer request trigger) that rewrites incoming paths:
// Pseudocode structure
if (request.uri.endsWith('.html')) {
request.uri = request.uri.replace(/\.html$/, '');
}
if (!request.uri.includes('.')) {
request.uri += '/index.html';
}
return request;
Deployment Steps:
- Created function in CloudFront console, associated with distribution ID (dangerouscentaur)
- Set stage to
LIVE(not DEVELOPMENT) - Attached to viewer-request event for the origin distribution
- Tested with sample paths: `/about` → `/about/index.html`, `/contact.html` → `/contact`
- Live verification confirmed 200 responses instead of 404
Why CloudFront Functions, not Lambda@Edge?
- Sub-millisecond latency (JavaScript runs at edge, no external invoke)
- No cold starts or IAM role overhead
- Synchronous, inline URL rewriting before origin request
- Cost: ~$0.60/million requests vs. Lambda@Edge at $0.60 per million + compute time
Uptime Monitoring: Lambda + DynamoDB
To prevent future dangerouscentaur outages from going undetected, deployed an uptime Lambda:
- Function name: `dc-uptime-check` (created via `/tmp/dc_uptime_lambda.py`)
- Trigger: CloudWatch Events, every 1 minute
- Logic: HTTP GET to dangerouscentaur.com/health, record result (200 vs. non-200) in DynamoDB table `dc-uptime-logs`
- IAM role: `dc-uptime-lambda-role` with policies for DynamoDB write + CloudWatch logs