Guide
Gmail API: Send Raw MIME Messages
Construct and send raw RFC 5322 messages through the Gmail API: base64url encode the MIME body, set the required headers, thread replies, handle the 35 MB limit, and compare the manual path with a single Nylas CLI command.
Written by Pouya Sanooei Software Engineer
What is a raw MIME message in the Gmail API?
A raw MIME message is the complete RFC 5322 email text, headers and body together, placed in the raw field of a users.messages.send request. Gmail does not parse separate fields. It expects one base64url-encoded string that represents the entire message, so your code owns header construction and encoding.
RFC 5322 (which obsoleted RFC 2822 in 2008) defines the message format: a header block, a blank line, then the body. Gmail accepts messages up to 35 MB through this method. The users.messages.send reference defines the raw field as "The entire email message in an RFC 2822 formatted and base64url encoded string." That single sentence sets every constraint below.
Why must the raw field use base64url, not standard base64?
The raw field requires base64url, the URL-safe alphabet from RFC 4648 section 5. It replaces the two characters that break in URLs: + becomes - and / becomes _. Standard base64 emits + and /, so feeding it to Gmail returns a 400 error before delivery.
Encoding also adds roughly 33% to the payload size, because base64 maps every 3 bytes to 4 characters. A 1 MB MIME message becomes about 1.33 MB on the wire. RFC 4648 names this variant "Base 64 Encoding with URL and Filename Safe Alphabet." In Python the standard library exposes it directly as base64.urlsafe_b64encode, and Node exposes it as the "base64url" encoding on Buffer. Use those built-ins instead of post-processing standard base64 by hand.
Which headers are required in the MIME?
A deliverable Gmail message needs 5 headers in the MIME block: From, To, Subject, MIME-Version: 1.0, and Content-Type. Omit MIME-Version or Content-Type and Gmail still sends the message, but clients render it garbled because they cannot tell text from HTML or detect the character set.
For a plain-text note, Content-Type: text/plain; charset="UTF-8" is enough. A message with both text and HTML uses multipart/alternative; a message with attachments uses multipart/mixed. Each of those 2 multipart types declares a unique boundary string that separates its parts. Python's email.mime classes generate compliant headers and boundaries automatically in about 5 lines, which removes the most common hand-rolled MIME bug: a boundary that appears inside a part's content.
How do you build and send raw MIME in Python?
The Python path uses email.mime.text.MIMEText to assemble headers and body, then base64.urlsafe_b64encode to produce the raw string. This is the minimum that satisfies all 5 required headers in roughly 10 lines. The standard library handles boundary generation and header folding, so the only manual step is the URL-safe encode before the API call.
import base64
from email.mime.text import MIMEText
message = MIMEText("Your invoice is attached.", "plain", "utf-8")
message["From"] = "billing@example.com"
message["To"] = "customer@example.com"
message["Subject"] = "January invoice"
# MIME-Version and Content-Type are set by MIMEText automatically.
raw = base64.urlsafe_b64encode(message.as_bytes()).decode("ascii")
service.users().messages().send(
userId="me",
body={"raw": raw},
).execute()Note as_bytes() before encoding, not as_string(). Base64url operates on bytes, and passing a str forces an extra encode step that hides charset bugs. The decode at the end turns the base64url bytes back into the ASCII string the JSON body expects.
How do you build and send raw MIME in Node?
Node has no built-in MIME composer, so you assemble the 5 header lines yourself and let Buffer do the base64url encode. The header block ends with a blank line (\\r\\n\\r\\n) before the body, per RFC 5322. The "base64url" encoding argument, available since Node 14.18, produces the URL-safe output Gmail requires in 1 call, applied to the 6 lines you assemble by hand.
const lines = [
"From: billing@example.com",
"To: customer@example.com",
"Subject: January invoice",
"MIME-Version: 1.0",
'Content-Type: text/plain; charset="UTF-8"',
"",
"Your invoice is attached.",
];
const raw = Buffer.from(lines.join("\r\n")).toString("base64url");
await gmail.users.messages.send({
userId: "me",
requestBody: { raw },
});The five header lines map one-to-one to the requirements above. Skip the blank line between headers and body and Gmail treats the body text as a malformed header, which surfaces as a delivered but empty-looking message rather than a clear 400. Keep the blank line explicit so the parser knows where the body starts.
How do you thread a reply with raw MIME?
Threading a reply takes 3 coordinated pieces across 2 layers: set the request's threadId to the conversation you are replying into, and include In-Reply-To and References headers in the MIME pointing at the original message's Message-ID. Gmail uses threadId for its own grouping; other clients rely on the two RFC 5322 headers.
Set only one of those 2 layers and the thread breaks somewhere. With threadId alone, Gmail groups the message but a recipient on a client that ignores threadId sees a detached reply. The Gmail sending guide notes the Subject headers of the reply and the original must also match for Gmail to thread reliably, so a threaded reply carries 2 headers plus the matching subject. The example below adds both headers and the threadId field, about 12 lines, so the reply threads across clients.
original_id = "<CAabc123@mail.gmail.com>" # Message-ID of the email you reply to
reply = MIMEText("Thanks, received.", "plain", "utf-8")
reply["From"] = "billing@example.com"
reply["To"] = "customer@example.com"
reply["Subject"] = "Re: January invoice"
reply["In-Reply-To"] = original_id
reply["References"] = original_id
raw = base64.urlsafe_b64encode(reply.as_bytes()).decode("ascii")
service.users().messages().send(
userId="me",
body={"raw": raw, "threadId": existing_thread_id},
).execute()What changes for messages over 5 MB?
The standard JSON path works for messages up to 5 MB. Above that threshold, and up to the 35 MB hard ceiling, you switch to the upload endpoint at /upload/gmail/v1/users/me/messages/send and send the MIME as a multipart or resumable upload rather than a base64url JSON field. This avoids inflating an already-large payload by the 33% base64 overhead inside a JSON string.
The cause is practical: a 30 MB attachment base64url-encoded inside JSON balloons past 40 MB and exceeds the simple request limit. Two upload styles exist. Multipart upload sends the metadata and raw bytes in a single request, which suits files in the 5-to-15 MB range on a stable link. Resumable upload splits the transfer into chunks, streams the raw bytes, and survives interrupted connections, which matters for attachments near the 35 MB ceiling on unstable networks. Google's users.messages.send reference documents both the metadata-only JSON form and the media upload form for this reason.
What are the common failures when sending raw MIME?
Two mistakes cause most failures. The first is using standard base64 instead of base64url: the 2 swapped characters, + and /, produce a 400 error from the API. The second is omitting MIME-Version or Content-Type, which delivers the message but renders it as garbled raw text in the recipient's client.
A third, quieter failure is a broken multipart boundary. If the boundary string accidentally appears inside a part's content, the parser splits the message in the wrong place and an attachment vanishes. The fix is to let a MIME library pick boundaries, which adds 0 lines of manual boundary code, rather than hard-coding one. The Gmail sending guide is explicit that the message must be base64url encoded, so verify the encoding step first whenever a send returns 400.
How does the Nylas CLI replace manual MIME construction?
The nylas email send command builds a valid MIME message, base64url-encodes it, and sends it for you. There is no manual header block, no boundary string, and no encoding step. One command replaces the roughly 15 lines of Python above, and the same command works across Gmail, Microsoft, and IMAP backends without provider-specific MIME quirks.
Authentication uses OAuth2 tokens stored in your system keyring, which the tool refreshes automatically. The verified flags are --to, --subject, --body, --cc, --bcc, --reply-to, --schedule, --metadata, and --track-opens. The --body value can be plain text or HTML, and the tool sets the matching Content-Type so you never touch MIME-Version by hand.
nylas email send \
--to customer@example.com \
--subject "January invoice" \
--body "Your invoice is attached." \
--jsonTo thread a reply, pass --reply-to with the message ID you are replying to; the command sets In-Reply-To, References, and the thread association for you. This example threads a reply and schedules it for two hours later, which would otherwise require building the headers and a separate scheduling layer.
nylas email send \
--to customer@example.com \
--subject "Re: January invoice" \
--body "Thanks, received." \
--reply-to <message-id> \
--schedule 2h \
--jsonWhen should you build raw MIME instead of using the CLI?
Build raw MIME directly when you need byte-level control Gmail's structured fields do not expose: custom headers, a specific multipart layout, S/MIME signing, or exact Message-ID values for a sync system. The 35 MB ceiling and the upload endpoint are also Gmail-specific decisions you own when you construct the message yourself.
For terminal workflows, cron jobs, agent tools, and multi-provider scripts, the CLI path removes the OAuth client setup, the base64url step, and the per-provider MIME differences. Start with nylas email send, and drop to raw users.messages.send only when you can name the byte-level feature you need. That keeps one-off scripts from becoming long-lived Google Cloud projects.
How do you attach a file to a raw MIME message?
Attaching a file to a raw MIME message means wrapping the body and each file in a multipart/mixed structure. That structure has 3 parts: a boundary string that separates the parts, the body part (text/plain or text/html), and one part per attachment carrying Content-Type, Content-Transfer-Encoding: base64, and a Content-Disposition: attachment; filename="..." header.
The whole multipart message then gets base64url-encoded into the raw field, exactly as the no-attachment path does. RFC 2046 defines multipart/mixed as the type for "a set of independent body parts" bundled into one message. Each attachment is encoded with base64 inside its part, which inflates the file by about 33% before the outer base64url pass runs. Mind the 35 MB total ceiling: a 26 MB file already exceeds it once encoded.
Python's email.mime.multipart.MIMEMultipart builds the container and picks a safe boundary, while MIMEBase plus encoders.encode_base64 handle the file part. The example below attaches a PDF and sends it through users.messages.send in about 18 lines, with the standard library managing every boundary so no part can collide with the separator.
import base64
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
message = MIMEMultipart("mixed")
message["From"] = "billing@example.com"
message["To"] = "customer@example.com"
message["Subject"] = "January invoice"
message.attach(MIMEText("Your invoice is attached.", "plain", "utf-8"))
part = MIMEBase("application", "pdf")
with open("invoice.pdf", "rb") as f:
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment", filename="invoice.pdf")
message.attach(part)
raw = base64.urlsafe_b64encode(message.as_bytes()).decode("ascii")
service.users().messages().send(
userId="me",
body={"raw": raw},
).execute()The CLI route skips the multipart assembly entirely. The nylas email drafts create command takes a --attach flag that reads the file from disk, builds the multipart/mixed payload, and detects the content type; a second command sends the draft. See send email with attachments for the full two-step pattern and multi-file examples.
nylas email drafts create \
--to customer@example.com \
--subject "January invoice" \
--body "Your invoice is attached." \
--attach ./invoice.pdf
nylas email drafts send <draft-id>Next steps
- Gmail API search query examples -- the q parameter, labels, categories, dates, and supported CLI filters
- Gmail API error codes -- what 400, 403, and 429 mean and how to recover
- Send email from the terminal -- the full email send workflow with flags and examples
- Send email with attachments -- attach files without hand-rolling multipart/mixed
- Microsoft Graph mail query -- the Graph side of cross-provider mail
- Full command reference -- every email, calendar, contact, webhook, MCP, and audit command
- Gmail API sending guide -- Google's reference for the raw field, base64url encoding, and message size limits