errno

Gmail throws away your <style> block: generating HTML mail that survives the client

· in APIs and automation · tested on Gmail API (drafts.create), Python, MIME multipart/alternative

Symptom

The job was small: assemble a short report from data I already had, put the numbers in a table, and leave the result in Drafts so a human could read it before it went anywhere. The HTML part looked right when I opened the generated file in a browser — bordered table, sane padding, header row picked out. Then I created the draft through the Gmail API, opened it in the Gmail web UI, and got something else entirely:

Region    Count   Errors
region-a  <n>     <n>
region-b  <n>     <n>

No borders. No cell padding. The header row indistinguishable from the body. The table had collapsed into an undifferentiated block of text where the only thing separating the columns was whatever whitespace the renderer felt like keeping. Nothing else was wrong: the draft existed, the subject and recipient were right, the HTML source in Show original was byte-for-byte what I had generated, and the markup itself was valid. The message was delivered intact and rendered wrong.

What it is not

The first instinct is to assume the CSS is losing a specificity fight, and that is the wrong road.

It is not a selector problem. I had written a perfectly ordinary stylesheet in the document head:

<style>
  table { border-collapse: collapse; }
  td, th { border: 1px solid #dadce0; padding: 6px 10px; }
  th { background: #f1f3f4; text-align: left; }
</style>

Making those selectors more specific does not help. Neither does !important, neither does moving the <style> element around, neither does switching to <link rel="stylesheet"> — that last one is worse, because now you are also asking the client to fetch a remote asset it will happily refuse. Gmail discards <style> blocks in a large part of its rendering paths and applies only inline style attributes. Anything that lives in a stylesheet, including one embedded in the head of the document you just sent, is not guaranteed to survive. You cannot win a cascade argument with a renderer that never received the cascade.

It is also not a MIME or transport problem, and it is worth ruling that out before you start rewriting markup. If the HTML part had been mangled or misdeclared, Show original would show it: a wrong Content-Type, a broken boundary, quoted-printable soft breaks landing inside a tag. Mine showed clean text/html; charset="utf-8" with the full document inside. The bytes arrived; the stylesheet was thrown away at display time.

What I measured

The test that settles it takes two minutes and no tooling. Generate two versions of the same table from the same data — one styled by the <style> block, one with every declaration written onto the elements — and create two drafts. Then open both in the Gmail web UI.

<!-- survives -->
<table style="border-collapse:collapse">
  <tr>
    <th style="border:1px solid #dadce0;padding:6px 10px;background:#f1f3f4;text-align:left">Region</th>
    <th style="border:1px solid #dadce0;padding:6px 10px;background:#f1f3f4;text-align:left">Count</th>
  </tr>
  <tr>
    <td style="border:1px solid #dadce0;padding:6px 10px">region-a</td>
    <td style="border:1px solid #dadce0;padding:6px 10px">41</td>
  </tr>
</table>

The inlined version has borders and padding in Gmail. The stylesheet version does not. Same data, same message structure, same account — the only variable is where the declarations live.

The important half of that measurement is the environment it has to run in. A local browser render of the same HTML shows both versions as correct, because a browser is precisely the thing that keeps the <style> block you are about to lose. Checking your mail HTML in Chrome tells you nothing about how it will look in mail. If the table has borders in the actual Gmail UI, the inlining is complete; that is the only test that means anything.

The fix

Inline every declaration that matters, on the element that needs it. Practically, that means generated mail should be produced by a function that writes attributes, not by a template with a stylesheet:

CELL = "border:1px solid #dadce0;padding:6px 10px"
HEAD = CELL + ";background:#f1f3f4;text-align:left"

def html_table(headers, rows):
    out = ['<table style="border-collapse:collapse">', "<tr>"]
    out += [f'<th style="{HEAD}">{h}</th>' for h in headers]
    out.append("</tr>")
    for row in rows:
        out.append("<tr>")
        out += [f'<td style="{CELL}">{c}</td>' for c in row]
        out.append("</tr>")
    out.append("</table>")
    return "".join(out)

Two string constants and a loop. There is no point maintaining a CSS file for an e-mail template — the file’s whole purpose is to keep declarations away from the elements, and mail needs them on the elements. Keep the constants next to the generator and stop pretending this is web work.

The second half of the recipe is the message itself. Build multipart/alternative with the plain-text part first and the HTML part second:

import base64
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

msg = MIMEMultipart("alternative")
msg["To"] = "[email protected]"
msg["Subject"] = "Daily report"
msg.attach(MIMEText(text_body, "plain", "utf-8"))   # fallback first
msg.attach(MIMEText(html_body, "html", "utf-8"))    # preferred last

body = {"message": {"raw": base64.urlsafe_b64encode(msg.as_bytes()).decode()}}
service.users().drafts().create(userId="me", body=body).execute()

Ordering is not cosmetic. A client picks the last part it can render, so a text part placed after the HTML demotes your HTML to decoration. The text part is also what shows up in notification previews and in clients running with HTML and images disabled, so it is worth generating properly: build it from the same data, not by stripping tags out of the HTML. Stripped-tag output is exactly where table rows turn into an unreadable run-on line — every <td> boundary you delete is a column separator you no longer have.

Note the encoding: base64.urlsafe_b64encode, not base64.b64encode. Standard base64 emits + and /, and the API rejects them. That one is easy to get right by accident and hard to debug once wrong, because it only fails for messages whose encoded form happens to contain those characters.

Scope, auth and why drafts

Creating a draft needs the gmail.modify scope (or gmail.compose). gmail.readonly is not enough, and this is a trap in the shape of a working program: a token minted for reading authorises cleanly, lists your labels happily, and then fails at the create call with an insufficient-permission error. The failure is at the write, not at authorisation time, so a script that does read work first looks healthy right up to the last step. If you widen the scope, delete the cached token and re-consent — a stored token keeps the scopes it was issued with.

The draft is always created as the account that owns the token. Sending from an alias is a separate account setting; you cannot fake it by putting a different address in the From header of the draft.

Drafts rather than sends is a deliberate working practice, not timidity. A message assembled from data lands in Drafts, a human reads it, and a human presses send. Automation writes the mail; a person accepts responsibility for it. For anything that goes to people rather than to a log, that is the right default, and it costs one click.

What to remember

E-mail is not the web, and the sooner you stop treating it as a rendering target with a CSS engine, the less time you lose. Treat the HTML you generate for mail as a separate, dumber dialect: inline styles, tables for layout, no stylesheet, no external assets whose absence would hurt. Write it from code that emits attributes, because that is the only form the client is guaranteed to honour.

Every renderer subsets the spec differently, and “it looks fine in my browser” is not evidence about any of them. The only verification that counts is opening the artefact in the client your reader uses — and for generated mail, the Gmail draft you just created is exactly that artefact, already sitting there waiting to be looked at.

gmail email html python