Automating PDF Badge Delivery via AWS SES: Fixing MIME Boundary Corruption & Field Mapping Drift
Symptom Statement Link to this section
During high-throughput badge distribution, attendees receive PDFs that terminate mid-stream, render as garbled binary, or arrive with QR codes and barcodes shoved past the printable margin — and AWS SES SendRawEmail reports success the whole time. The email is delivered, the MessageId comes back clean, but the payload on the wire is corrupt. This is a delivery-transport failure, not a rendering failure: the badge composed correctly and only degraded once it was wrapped for email. This page is one of the delivery targets documented under PDF Routing Workflows, the routing stage of the Badge Generation & Template Sync pipeline, and it covers the exact SES path from validated PDF bytes to an inbox attachment that a Zebra or Dymo station will actually accept.
Root Cause Analysis Link to this section
Three concrete faults produce this class of silent degradation. Each sits at a different boundary — MIME construction, template synchronization, and printer hardware — and each fails quietly because SES only validates the envelope, never the attachment payload.
- MIME boundary corruption. Streaming raw PDF bytes into
email.mimeobjects without explicitContent-Transfer-Encoding: base64and strict CRLF (\r\n) line endings lets a stray0x0Ainside the binary body match a multipart boundary marker. SES treats that byte run as an early part terminator and silently drops every trailing byte, so the recipient gets a PDF that ends mid-object. - Dynamic field mapping drift. Variable-length metadata — sponsor tiers, VIP designations, dietary tags — shifts layout coordinates when template versions are unpinned. A long field pushes scannable elements out of their bounding box, which directly degrades QR code generation and barcode threshold tuning output because the codes now straddle the non-printable edge. The upstream binding for those fields lives in dynamic field mapping.
- Label printer threshold violation. SES-delivered files that lack embedded DPI metadata or carry the wrong raster threshold are rejected at the print station. A 300 DPI thermal printer expects an exact media box and a strict grayscale threshold; a page a few points off spec, or an image rasterized below the resolution floor, is refused at the spooler.
Symptom-to-Resolution Matrix Link to this section
MIME Boundary Corruption Link to this section
Symptoms
- Attendee PDFs open then error “unexpected end of file” or render only the first page object.
SendRawEmailreturns a validMessageId, so monitoring shows 100% delivery.- Occasional
InvalidParameterValuewhen a boundary collision produces a malformed part header.
Root cause. The binary body contains bare \n bytes and no enforced transfer encoding, so SES’s MIME parser mistakes body bytes for structural markers and truncates the part.
Fix steps
- Attach the PDF with
MIMEApplication, which base64-encodes the payload so no raw binary reaches the boundary parser. - Set
Content-Transfer-Encoding: base64explicitly, guarding against any middleware that strips the default header. - Serialize with
msg.as_bytes()(notas_string()), which emits canonical\r\nline endings and a collision-free boundary token.
Dynamic Field Mapping Drift Link to this section
Symptoms
- QR or barcode elements clip at the badge edge or fail scan validation intermittently.
- Layout shifts appear only for records with long sponsor or company strings.
- The same template renders correctly for short records and overflows for long ones.
Root cause. Unpinned template versions and unbounded field length let dynamic metadata exceed its bounding box, moving fixed-position scannable assets into the non-printable margin.
Fix steps
- Cap field length before render (
MAX_FIELD_LENGTH) and log a truncation warning rather than allowing silent overflow. - Pin the template version per event so a mid-event template edit cannot shift coordinates under an in-flight batch.
- Validate the finished page’s media box against the expected badge dimensions in points, and reject any PDF that drifts out of tolerance before it is ever wrapped for SES.
Label Printer Threshold Violation Link to this section
Symptoms
- Zebra/Dymo stations reject SES-delivered files at the spooler with a media or resolution error.
- Files print on desktop viewers but fail on thermal hardware.
- Grayscale badges emerge faint or with dropped elements.
Root cause. The PDF lacks correct DPI metadata or its page geometry does not match the printer’s expected media box and grayscale threshold.
Fix steps
- Assert the page geometry in points — a 4×6 in badge is
288 × 432 pt, not pixels; the 300 DPI figure governs rasterized image resolution inside the PDF, not the page size. - Reject any PDF outside the width/height tolerance window during pre-flight so a mis-sized file never reaches a print queue.
- Keep the fallback delivery path (presigned S3 link) available so an operator can re-fetch a spec-correct master if a station still refuses the attachment.
Minimal Working Implementation Link to this section
A single self-contained path: validate the badge PDF, build an RFC 2046-compliant MIME message with explicit base64 encoding and CRLF boundaries, dispatch through SES with backoff, and verify the returned MessageId. PDF dimensions are measured in points (1 pt = 1/72 inch), so a 4×6 in badge has a media box of 288 × 432 pt.
import io
import time
import base64
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.mime.text import MIMEText
from email.utils import formataddr
import boto3
from botocore.exceptions import ClientError
from pypdf import PdfReader
logger = logging.getLogger("badge.ses")
# --- Config -----------------------------------------------------------------
MAX_PDF_SIZE_BYTES = 8_000_000 # Conservative: SES allows 40 MB, but big
# attachments (plus ~33% base64 overhead) hurt
# deliverability. Cap well below the hard limit.
MAX_FIELD_LENGTH = 120 # Prevents dynamic-field layout overflow.
# Media box in points for a 4 in x 6 in badge (288 x 432 pt), with tolerance.
BADGE_WIDTH_PT = (280, 296)
BADGE_HEIGHT_PT = (424, 440)
ses_client = boto3.client("ses", region_name="us-east-1")
def validate_badge_pdf(pdf_bytes: bytes, attendee_name: str) -> None:
"""Pre-flight: reject oversized, multi-page, or mis-sized badge PDFs."""
if len(pdf_bytes) > MAX_PDF_SIZE_BYTES:
raise ValueError(f"PDF exceeds {MAX_PDF_SIZE_BYTES} bytes; SES will choke deliverability.")
reader = PdfReader(io.BytesIO(pdf_bytes))
if len(reader.pages) != 1:
raise ValueError("Badge PDF must contain exactly one page.")
if len(attendee_name) > MAX_FIELD_LENGTH:
logger.warning("Attendee name over %d chars; layout may overflow.", MAX_FIELD_LENGTH)
box = reader.pages[0].mediabox
width_pt, height_pt = float(box.width), float(box.height)
if not (BADGE_WIDTH_PT[0] <= width_pt <= BADGE_WIDTH_PT[1]
and BADGE_HEIGHT_PT[0] <= height_pt <= BADGE_HEIGHT_PT[1]):
raise ValueError(
f"Media box {width_pt:.1f} x {height_pt:.1f} pt is off the 288 x 432 pt badge spec."
)
def build_mime_badge_email(to_email, from_email, subject, pdf_bytes, filename) -> bytes:
"""RFC 2046-compliant raw MIME with explicit base64 + CRLF boundaries."""
msg = MIMEMultipart("mixed")
msg["From"] = formataddr(("Event Ops", from_email))
msg["To"] = to_email
msg["Subject"] = subject
msg.preamble = "This is a multi-part message in MIME format."
msg.attach(MIMEText("Please find your event badge attached.", "plain", "utf-8"))
pdf_part = MIMEApplication(pdf_bytes, Name=filename) # base64-encodes the body
pdf_part["Content-Disposition"] = f'attachment; filename="{filename}"'
pdf_part["Content-Transfer-Encoding"] = "base64" # explicit, guards middleware
msg.attach(pdf_part)
return msg.as_bytes() # emits \r\n line endings + a collision-free boundary
def send_badge_via_ses(raw_mime_bytes: bytes, max_retries: int = 3) -> dict:
"""Dispatch raw MIME to SES with exponential backoff on throttling."""
for attempt in range(max_retries):
try:
resp = ses_client.send_raw_email(RawMessage={"Data": raw_mime_bytes})
return {"status": "success", "message_id": resp["MessageId"]}
except ClientError as exc:
code = exc.response["Error"]["Code"]
if code == "Throttling":
time.sleep((2 ** attempt) * 0.5)
continue
if code == "InvalidParameterValue":
raise RuntimeError(f"SES rejected payload: {exc.response['Error']['Message']}")
raise
raise RuntimeError("SES delivery failed after max retries due to throttling.")
if __name__ == "__main__":
pdf = open("badge.pdf", "rb").read()
validate_badge_pdf(pdf, "Ada Lovelace")
raw = build_mime_badge_email(
"[email protected]", "[email protected]",
"Your event badge", pdf, "badge.pdf",
)
result = send_badge_via_ses(raw)
# Verification: a real MessageId, and the attachment survives a base64 round-trip.
assert result["message_id"], "SES returned no MessageId"
del pdf # free the raw bytes before the next high-concurrency iteration
print("Delivered:", result["message_id"])
Memory & Performance Constraints Link to this section
| Resource | Constraint | Mitigation |
|---|---|---|
| Attachment size | Base64 inflates the PDF ~33%; oversized mail hurts inbox acceptance | Cap raw PDF at 8 MB even though SES permits 40 MB per message |
| Per-badge memory | Raw bytes + base64 copy + MIME buffer coexist during construction | Generate to bytes, encode once, del pdf_bytes after as_bytes(); keep peak under 15 MB/badge |
| SES send rate | Default sending quota is ~14 messages/second in most regions | Token-bucket rate limiting; publish bounce/complaint metrics via SES configuration sets to CloudWatch |
| Worker heap under load | High-concurrency workers fragment the heap during peak registration | Explicit del of raw bytes plus bounded worker concurrency; offload retries to async batch processing |
Incident Triage & Rollback Link to this section
When corruption or drift is detected in production, contain first and diagnose second — the target is under 15 minutes to a clean fallback.
- Circuit-break the SES path. Disable automated
SendRawEmaildispatch in the routing orchestrator and switch to the presigned-URL fallback so attendees keep receiving retrievable badges. - Confirm the fault class. Pull one failing message and check whether the attachment survives a base64 round-trip locally (
base64 -dthe part body and open it) — a truncated decode confirms MIME corruption rather than a bad source PDF. - Pin the template. Revert to the last known-good template version and disable dynamic field injection until bounding-box tolerances are re-validated.
- Serve the fallback link. Dispatch a plain-text email carrying a presigned S3 URL, bypassing MIME boundary risk entirely during the incident:
def fallback_delivery(s3_client, bucket: str, key: str) -> str:
"""Presigned GET URL — a link instead of an attachment, no MIME risk."""
return s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=86_400, # 24h
)
Rollback validation. Re-enable SES routing only after validate_badge_pdf() passes 100% of a test batch and send_badge_via_ses() returns consistent MessageId values with no InvalidParameterValue. Verify a sampled delivered attachment byte-for-byte against its source before restoring full throughput.
Frequently Asked Questions Link to this section
Why does SES report success while attendees get corrupt PDFs?
SES validates the message envelope and headers, not the integrity of an attachment payload. A boundary collision truncates the body after SES has already accepted the message, so the MessageId is genuine even though the delivered bytes are incomplete. Enforcing base64 encoding and CRLF boundaries removes the collision at the source.
Should I use SendRawEmail or SendEmail for badge attachments?
SendRawEmail (or the v2 SendEmail with a Raw content block) is required whenever you attach a file, because the templated APIs do not accept binary attachments. Raw sending is what gives you explicit control over the MIME structure that prevents boundary corruption in the first place.
Is the 4×6 badge 288 points or 1200 pixels?
Both, at different layers. The PDF page geometry is 288 × 432 pt (points, at 72 pt/inch), which is what the printer’s media box check reads. The 300 DPI figure governs the resolution of rasterized images placed inside the page — a 4-inch-wide image at 300 DPI is 1200 px — and never the page dimensions themselves. Validating the media box in points is what catches mis-sized files.
Related Link to this section
- PDF Routing Workflows — the parent stage that owns delivery contracts and fallback chains; this SES path is one of its transport targets.
- QR Code Generation — the upstream encoder whose output is exactly what drifts out of margin when dynamic fields overflow the layout.
- Barcode Threshold Tuning — the sibling stage that calibrates 1D barcode density so scannable elements survive the same printer thresholds referenced here.
- Async Batch Processing — owns the dead-letter queue and retry machinery that quarantines badges this path cannot deliver after backoff is exhausted.