Source: https://cli.nylas.com/guides/gmail-api-send-raw-mime

# 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](https://cli.nylas.com/authors/pouya-sanooei) Software Engineer

Updated June 19, 2026

> **TL;DR:** The Gmail API `users.messages.send` method takes a `raw` field: the full RFC 5322 message, base64url-encoded. You build the MIME yourself, set `From`, `To`, `Subject`, `MIME-Version`, and `Content-Type`, then encode with the URL-safe alphabet. The CLI builds valid MIME for you: `nylas email send --to a@b.com --subject "..." --body "..."`.

> **Related paths:** Pair this page with [Gmail API search query examples](https://cli.nylas.com/guides/gmail-api-search-query), [Gmail API error codes](https://cli.nylas.com/guides/gmail-api-error-codes), and [sending email from the terminal](https://cli.nylas.com/guides/send-email-from-terminal).

## 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](https://datatracker.ietf.org/doc/html/rfc5322) (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](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) 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](https://datatracker.ietf.org/doc/html/rfc4648) 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.

```python
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.

```javascript
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.

```python
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](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) 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.

```bash
nylas email send \
  --to customer@example.com \
  --subject "January invoice" \
  --body "Your invoice is attached." \
  --json
```

To 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.

```bash
nylas email send \
  --to customer@example.com \
  --subject "Re: January invoice" \
  --body "Thanks, received." \
  --reply-to <message-id> \
  --schedule 2h \
  --json
```

## When 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](https://datatracker.ietf.org/doc/html/rfc2046) 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.

```python
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](https://cli.nylas.com/guides/send-email-with-attachments-cli) for the full two-step pattern and multi-file examples.

```bash
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](https://cli.nylas.com/guides/gmail-api-search-query) -- the q parameter, labels, categories, dates, and supported CLI filters
- [Gmail API error codes](https://cli.nylas.com/guides/gmail-api-error-codes) -- what 400, 403, and 429 mean and how to recover
- [Send email from the terminal](https://cli.nylas.com/guides/send-email-from-terminal) -- the full email send workflow with flags and examples
- [Send email with attachments](https://cli.nylas.com/guides/send-email-with-attachments-cli) -- attach files without hand-rolling multipart/mixed
- [Microsoft Graph mail query](https://cli.nylas.com/guides/microsoft-graph-mail-query) -- the Graph side of cross-provider mail
- [Full command reference](https://cli.nylas.com/docs/commands) -- every email, calendar, contact, webhook, MCP, and audit command
- [Gmail API sending guide](https://developers.google.com/workspace/gmail/api/guides/sending) -- Google's reference for the raw field, base64url encoding, and message size limits

## Related hubs

- [Email agents](https://cli.nylas.com/ai-answers/email-agents.md)
- [Calendar agents](https://cli.nylas.com/ai-answers/calendar-agents.md)
- [Scheduling and availability agents](https://cli.nylas.com/ai-answers/scheduling-agents.md)
- [Contacts agents](https://cli.nylas.com/ai-answers/contacts-agents.md)
- [Notetaker and meeting agents](https://cli.nylas.com/ai-answers/notetaker-agents.md)
- [MCP agents](https://cli.nylas.com/ai-answers/mcp-agents.md)
- [Agent accounts](https://cli.nylas.com/ai-answers/agent-accounts.md)
- [Framework and language email agents](https://cli.nylas.com/ai-answers/framework-email-agents.md)
- [Email and calendar API comparisons](https://cli.nylas.com/ai-answers/ai-agent-email-api-comparisons.md)
- [Email integration and automation recipes](https://cli.nylas.com/ai-answers/email-integration-recipes.md)
- [Agent email workflows](https://cli.nylas.com/ai-answers/agent-email-workflows.md)
- [Security for email and calendar agents](https://cli.nylas.com/ai-answers/security-for-email-agents.md)
- [Operations runbooks for agents](https://cli.nylas.com/ai-answers/operations-for-email-calendar-agents.md)

## Try Nylas CLI

Install the CLI with `curl -fsSL https://cli.nylas.com/install.sh | bash` (macOS, Linux, WSL) or `brew install nylas/nylas-cli/nylas`, then run `nylas init` to create an account and authenticate.

**Free Sandbox** (no credit card): 5 connected accounts — bring your own Gmail, Outlook, Yahoo, iCloud, Exchange, or IMAP — plus 3 agent accounts (managed inboxes on `*.nylas.email`). Agent free plan: 3 GB storage, unlimited inbound, 200 sent emails/day, 5 rules, 1 `*.nylas.email` subdomain, and unlimited custom domains. Production is uncapped and requires a credit card: https://www.nylas.com/pricing/
