---
title: Install Agent Gmail Service
slug: install-agent-gmail-service
category: Integrations
summary: Give your app a Gmail mailbox the agent can read and write — per-user OAuth, encrypted refresh tokens, a GmailAgent service (search/read/label/send), and a draft-first AgentGmailTools wrapper that will not mail a customer without an explicit confirm. Includes the reply-threading fix that stops your replies arriving as brand-new conversations.
tags: [gmail, google, oauth, email, agent, integrations, service]
status: stable
visibility: public
source_project: llamapress.ai
layers: [model, controller]
related:
  - title: Install Leo SMS Gateway
    url: /cookbook/install-leo-sms-gateway
    summary: The sibling inbox. Same agent loop reads both — set this guide up first if you want the loop to check email as well as text.
  - title: Outbound Email Safelist
    url: /cookbook/outbound-email-safelist
    summary: Stop a staging or seeded app from mailing real people while you build.
  - title: Gmail API — Users.messages reference
    url: https://developers.google.com/gmail/api/reference/rest/v1/users.messages
    summary: Official reference for the message, draft, thread and label endpoints used below.
---

# Install Agent Gmail Service

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

This recipe gives your app a real Gmail mailbox that an agent can work: search it, read a
message, label and archive, write a draft, and — only when a human says so — send a
threaded reply. Tokens are per user and encrypted at rest, so one app can hold several
mailboxes and pick one by name.

The important half of this guide is not the API calls. It is the two guardrails around
them: **draft-first sending** (an agent can write mail but cannot mail anyone until a
human passes `confirm: true`) and **correct reply threading** (a reply must carry
`In-Reply-To`, not just Gmail's `thread_id`, or the customer receives a brand-new
conversation).

> **When to use:** an app that must read a shared inbox, triage it, and answer from a
> real Gmail address — support desks, sales follow-up, an agent that watches for
> customer mail.
> **When not to:** transactional mail your app generates (order receipts, password
> resets). Use Action Mailer with SES or SMTP for those. Gmail is for a **conversation**
> with a human, not for a send-only pipe.

**Gem check — nothing to install.** All four gems this needs are already in the Leo base
image (verified on `llamapress-simple:0.7.2`): `google-apis-gmail_v1 0.51.0`,
`googleauth 1.17.0`, `signet 0.22.0`, and `mail`. You cannot add gems on a Leo box, and
you do not need to. Confirm on yours:

```bash
docker compose exec -T llamapress bash -c "bundle list | grep -iE 'gmail|googleauth|signet'"
```

---

## The 80/20 in one breath

1. In Google Cloud Console, create a project, enable the **Gmail API**, and create an
   **OAuth client ID** of type **Web application**. Add the redirect URI
   `https://<your-app-host>/google_oauth/callback` exactly.
2. Put `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the project `.env`, then
   **recreate** the web container (`docker compose up -d --force-recreate llamapress`).
   A plain `restart` does not reload `.env`.
3. Add the six `gmail_*` columns to `users` and run the migration immediately.
4. Copy `GmailAgent` into `app/services/gmail_agent.rb` and `AgentGmailTools` into
   `app/services/agent_gmail_tools.rb`.
5. Copy `GoogleOauthController` into `app/controllers/google_oauth_controller.rb` and add
   the three flat routes.
6. Sign in as the mailbox owner and visit `/google_oauth/connect`. Approve the consent
   screen. You now have a connected mailbox.

Verify in one line — this reads, it does not send:

```ruby
AgentGmailTools.connected_mailboxes
# => {:support=>"support@yourdomain.com"}
```

---

## Layer 1 — Migration & model

Six columns on `users`. Two of them hold secrets, so encrypt them.

```ruby
# db/migrate/20260820000001_add_gmail_oauth_to_users.rb
class AddGmailOauthToUsers < ActiveRecord::Migration[7.2]
  def change
    add_column :users, :gmail_access_token,  :text     # short-lived, refreshed for you
    add_column :users, :gmail_refresh_token, :text     # the long-lived secret
    add_column :users, :gmail_expires_at,    :datetime
    add_column :users, :gmail_email,         :string   # the address the tokens belong to
    add_column :users, :gmail_connected_at,  :datetime
    add_column :users, :gmail_scope,         :string
  end
end
```

Run it **immediately** after writing it. A pending migration blocks every request in this
stack:

```bash
docker compose exec -T llamapress bin/rails db:migrate
```

```ruby
# app/models/user.rb
class User < ApplicationRecord
  # Encrypted at rest. Requires Active Record encryption keys — see Gotchas.
  encrypts :gmail_access_token
  encrypts :gmail_refresh_token

  def gmail_connected?
    gmail_refresh_token.present? && gmail_connected_at.present?
  end

  def disconnect_gmail!
    update!(gmail_access_token: nil, gmail_refresh_token: nil, gmail_expires_at: nil,
            gmail_email: nil, gmail_connected_at: nil, gmail_scope: nil)
  end
end
```

---

## Layer 2 — The OAuth controller & routes

```ruby
# app/controllers/google_oauth_controller.rb
require 'googleauth'
require 'google/apis/gmail_v1'
require 'securerandom'

class GoogleOauthController < ApplicationController
  before_action :authenticate_user!

  # gmail.modify is the broad read/write scope: read, compose, send, labels, archive,
  # star, mark read/important, and trash. It does NOT permit permanent deletion that
  # bypasses Trash — that needs 'https://mail.google.com/', which you should not ask for.
  SCOPES = [
    'https://www.googleapis.com/auth/gmail.modify',
    'https://www.googleapis.com/auth/userinfo.email',
    'openid'
  ].freeze

  # GET /google_oauth/connect
  def connect
    state = SecureRandom.hex(24)
    session[:gmail_oauth_state] = state

    redirect_to user_authorizer.get_authorization_url(
      state: state,
      access_type: 'offline',   # ask for a refresh token
      prompt: 'consent',        # force consent so the refresh token is actually returned
      include_granted_scopes: 'true'
    ), allow_other_host: true
  end

  # GET /google_oauth/callback
  def callback
    if params[:state].blank? || params[:state] != session.delete(:gmail_oauth_state)
      flash[:error] = "Invalid state parameter. Please try connecting Gmail again."
      return redirect_to root_path
    end
    if params[:error].present?
      flash[:error] = "Gmail authorization failed: #{params[:error]}"
      return redirect_to root_path
    end

    creds = user_authorizer.get_credentials_from_code(code: params[:code])
    attrs = {
      gmail_access_token: creds.access_token,
      gmail_expires_at:   creds.expires_at,
      gmail_scope:        SCOPES.join(' '),
      gmail_connected_at: Time.current,
      gmail_email:        fetch_gmail_address(creds)
    }
    # Google OMITS the refresh token on a silent re-consent. Never clobber a good one.
    attrs[:gmail_refresh_token] = creds.refresh_token if creds.refresh_token.present?

    current_user.update!(attrs)
    flash[:success] = "Gmail connected as #{current_user.gmail_email}."
    redirect_to root_path
  rescue => e
    Rails.logger.error "Gmail OAuth error: #{e.class}: #{e.message}"
    flash[:error] = "Failed to connect Gmail: #{e.message}"
    redirect_to root_path
  end

  # DELETE /google_oauth/disconnect
  def disconnect
    current_user.disconnect_gmail!
    redirect_to root_path, notice: "Gmail disconnected."
  end

  private

  def client_id
    Google::Auth::ClientId.new(ENV.fetch('GOOGLE_CLIENT_ID'), ENV.fetch('GOOGLE_CLIENT_SECRET'))
  end

  def user_authorizer
    Google::Auth::UserAuthorizer.new(client_id, SCOPES, nil, google_oauth_callback_url)
  end

  def fetch_gmail_address(creds)
    svc = Google::Apis::GmailV1::GmailService.new
    svc.authorization = creds
    svc.get_user_profile('me').email_address
  rescue => e
    Rails.logger.warn "Could not fetch Gmail address: #{e.message}"
    nil
  end
end
```

```ruby
# config/routes.rb
# Flat paths on purpose: google_oauth_callback_url must stay byte-identical to the
# redirect URI you registered in Google Cloud Console. Nesting it in a namespace later
# changes the URL and breaks every connect with redirect_uri_mismatch.
get    'google_oauth/connect',    to: 'google_oauth#connect',    as: :google_oauth_connect
get    'google_oauth/callback',   to: 'google_oauth#callback',   as: :google_oauth_callback
delete 'google_oauth/disconnect', to: 'google_oauth#disconnect', as: :google_oauth_disconnect
```

---

## Layer 3 — GmailAgent (the raw service)

One connected user, one object. This is the layer that talks to Google.

```ruby
# app/services/gmail_agent.rb
require 'google/apis/gmail_v1'
require 'googleauth'
require 'mail'

class GmailAgent
  class NotConnected < StandardError; end
  Gmail = Google::Apis::GmailV1

  attr_reader :user, :last_threading

  def initialize(user)
    @user = user
    raise NotConnected, "User #{user&.id} has not connected Gmail" unless user&.gmail_connected?
  end

  # Gmail query syntax. Add in:anywhere to include spam and trash — a plain search
  # EXCLUDES both, and real customer mail lands in spam.
  def search(query, max_results: 20)
    (service.list_user_messages('me', q: query, max_results: max_results).messages || [])
      .map { |m| { id: m.id, thread_id: m.thread_id } }
  end

  def read(message_id)
    m = service.get_user_message('me', message_id, format: 'full')
    {
      id: m.id, thread_id: m.thread_id, snippet: m.snippet,
      subject: header(m, 'Subject'), from: header(m, 'From'),
      to: header(m, 'To'), cc: header(m, 'Cc'), date: header(m, 'Date'),
      # The RFC 5322 Message-ID — what a reply must cite so the RECIPIENT threads it.
      message_id_header: header(m, 'Message-ID') || header(m, 'Message-Id'),
      references: header(m, 'References'),
      body: extract_body(m.payload)
    }
  end

  # The threading facts needed to reply INTO a conversation. Accepts a message id or a
  # thread id.
  #
  # WHY THIS EXISTS: Gmail's thread_id only groups the copy in YOUR mailbox. Every other
  # mail client threads on In-Reply-To / References. A reply sent with thread_id and
  # nothing else looks perfectly threaded to you and arrives as a NEW conversation for
  # the customer. Derive the headers; never hand-pass thread_id alone.
  #
  # Skips unsent DRAFTs: a draft's Message-ID never reached anyone.
  def reply_context(message_or_thread_id)
    thread_id = resolve_thread_id(message_or_thread_id)
    return nil if thread_id.blank?

    thread = service.get_user_thread('me', thread_id, format: 'metadata',
                                     metadata_headers: %w[Subject Message-ID References])
    parent = (thread.messages || []).reject { |m| Array(m.label_ids).include?('DRAFT') }.last
    return { thread_id: thread_id, in_reply_to: nil, references: nil, subject: nil } if parent.nil?

    pid = header(parent, 'Message-ID') || header(parent, 'Message-Id')
    { thread_id: thread_id,
      in_reply_to: pid,
      references: self.class.build_references(header(parent, 'References'), pid),
      subject: self.class.reply_subject(header(parent, 'Subject')) }
  end

  # References = the parent's chain + the parent's own Message-ID (RFC 5322 3.6.4).
  def self.build_references(parent_references, parent_message_id)
    chain = "#{parent_references} #{parent_message_id}".split.uniq
    chain.empty? ? nil : chain.join(' ')
  end

  # "Re: " exactly once, however the original was capitalised.
  def self.reply_subject(subject)
    s = subject.to_s.strip
    return nil if s.empty?
    s.match?(/\A\s*re\s*:/i) ? s : "Re: #{s}"
  end

  def send_email(to:, body:, subject: nil, from: nil, cc: nil, bcc: nil, thread_id: nil,
                 in_reply_to: nil, references: nil, html_body: nil, reply_to_message_id: nil)
    t = threading(thread_id:, in_reply_to:, references:, subject:, reply_to_message_id:)
    mail = build_mail(to:, subject: t[:subject], body:, from:, cc:, bcc:,
                      in_reply_to: t[:in_reply_to], references: t[:references], html_body:)
    service.send_user_message('me', Gmail::Message.new(raw: raw_for(mail), thread_id: t[:thread_id]))
  end

  def create_draft(to:, body:, subject: nil, from: nil, cc: nil, bcc: nil, thread_id: nil,
                   in_reply_to: nil, references: nil, html_body: nil, reply_to_message_id: nil)
    t = threading(thread_id:, in_reply_to:, references:, subject:, reply_to_message_id:)
    mail = build_mail(to:, subject: t[:subject], body:, from:, cc:, bcc:,
                      in_reply_to: t[:in_reply_to], references: t[:references], html_body:)
    message = Gmail::Message.new(raw: raw_for(mail), thread_id: t[:thread_id])
    service.create_user_draft('me', Gmail::Draft.new(message: message))
  end

  # PAGINATES. Gmail caps a page at 500 and a busy mailbox holds hundreds of drafts, so
  # a single un-paged call silently truncates and a pending reply falls off the end.
  def drafts(max_results: 1_000)
    collected, page_token = [], nil
    loop do
      page = service.list_user_drafts('me', max_results: [max_results - collected.size, 500].min,
                                            page_token: page_token)
      (page.drafts || []).each do |d|
        collected << { draft_id: d.id, message_id: d.message&.id, thread_id: d.message&.thread_id }
      end
      page_token = page.next_page_token
      break if page_token.blank? || collected.size >= max_results
    end
    collected
  end

  # ---- triage (all reversible) --------------------------------------------
  def labels
    (service.list_user_labels('me').labels || []).map { |l| { id: l.id, name: l.name, type: l.type } }
  end

  def create_label(name)
    created = service.create_user_label('me', Gmail::Label.new(
      name: name, label_list_visibility: 'labelShow', message_list_visibility: 'show'))
    @label_index = nil
    created
  end

  def modify_labels(message_id, add: [], remove: [], create_missing: false)
    service.modify_message('me', message_id, Gmail::ModifyMessageRequest.new(
      add_label_ids:    Array(add).map    { |l| resolve_label_id(l, create: create_missing) }.compact,
      remove_label_ids: Array(remove).map { |l| resolve_label_id(l, create: false) }.compact))
  end

  def archive(message_id);     modify_labels(message_id, remove: ['INBOX']); end
  def mark_read(message_id);   modify_labels(message_id, remove: ['UNREAD']); end
  def star(message_id);        modify_labels(message_id, add: ['STARRED']); end
  def trash(message_id);       service.trash_user_message('me', message_id); end
  def untrash(message_id);     service.untrash_user_message('me', message_id); end

  # A true "move to folder": apply a label and drop it out of the inbox.
  def apply_label(message_id, label_name, archive: false, create_missing: true)
    modify_labels(message_id, add: [label_name], remove: archive ? ['INBOX'] : [],
                              create_missing: create_missing)
  end

  private

  # THE BACKSTOP. A caller who passes thread_id but no in_reply_to gets the headers
  # derived automatically, because thread_id is the only threading field most search and
  # read tools ever surface — so that broken call shape is the DEFAULT mistake. Explicit
  # values always win; a derivation failure never blocks the send.
  def threading(thread_id:, in_reply_to:, references:, subject:, reply_to_message_id:)
    source = reply_to_message_id.presence
    source ||= thread_id.presence if in_reply_to.blank?
    ctx = source ? (reply_context(source) || {}) : {}

    @last_threading = {
      thread_id:   thread_id.presence   || ctx[:thread_id],
      in_reply_to: in_reply_to.presence || ctx[:in_reply_to],
      references:  references.presence  || ctx[:references],
      subject:     subject.presence     || ctx[:subject]
    }
  rescue Google::Apis::Error => e
    Rails.logger.warn("GmailAgent: could not derive threading headers: #{e.message}")
    @last_threading = { thread_id:, in_reply_to:, references:, subject: }
  end

  # Message ids and thread ids share a namespace. Try it as a message first — that is
  # what the tools hand out — and fall back to treating it as a thread id.
  def resolve_thread_id(id)
    return nil if id.blank?
    service.get_user_message('me', id, format: 'minimal').thread_id
  rescue Google::Apis::ClientError
    id
  end

  SYSTEM_LABELS = %w[INBOX SENT DRAFT TRASH SPAM UNREAD STARRED IMPORTANT CHAT].freeze

  def resolve_label_id(label, create: false)
    s = label.to_s
    return s.upcase if SYSTEM_LABELS.include?(s.upcase)
    return s if s.start_with?('CATEGORY_', 'Label_')
    label_index[s.downcase] || (create ? create_label(s).id : nil)
  end

  def label_index
    @label_index ||= (service.list_user_labels('me').labels || []).each_with_object({}) do |l, h|
      h[l.name.downcase] = l.id
    end
  end

  def service
    @service ||= Gmail::GmailService.new.tap { |s| s.authorization = credentials }
  end

  def credentials
    @credentials ||= begin
      creds = Google::Auth::UserRefreshCredentials.new(
        client_id:     ENV.fetch('GOOGLE_CLIENT_ID'),
        client_secret: ENV.fetch('GOOGLE_CLIENT_SECRET'),
        refresh_token: user.gmail_refresh_token,
        access_token:  user.gmail_access_token,
        expires_at:    user.gmail_expires_at&.to_i,
        scope:         user.gmail_scope
      )
      refresh_if_needed!(creds)
      creds
    end
  end

  def refresh_if_needed!(creds)
    return unless user.gmail_access_token.blank? || creds.expired?
    creds.fetch_access_token!   # the refresh token itself does not rotate
    user.update!(gmail_access_token: creds.access_token, gmail_expires_at: creds.expires_at)
  rescue Signet::AuthorizationError => e
    # The refresh token is dead: revoked, expired, or the app was un-consented.
    Rails.logger.warn("GmailAgent: token refresh failed for #{user.gmail_email}: #{e.message}")
    raise
  end

  def build_mail(to:, subject:, body:, from:, cc:, bcc:, in_reply_to: nil, references: nil,
                 html_body: nil)
    mail = Mail.new
    mail.to      = to
    mail.from    = from.presence || user.gmail_email
    mail.subject = subject
    if html_body.present?
      text = Mail::Part.new.tap { |p| p.content_type = 'text/plain; charset=UTF-8'; p.body = body }
      html = Mail::Part.new.tap { |p| p.content_type = 'text/html; charset=UTF-8';  p.body = html_body }
      mail.text_part = text
      mail.html_part = html
    else
      mail.body = body
    end
    mail.cc  = cc  if cc.present?
    mail.bcc = bcc if bcc.present?
    mail.in_reply_to = in_reply_to if in_reply_to.present?
    mail.references  = references  if references.present?
    mail
  end

  # The `raw` field must be the PLAIN RFC822 string. The google-apis client base64url
  # encodes `raw` itself during serialization, so do NOT pre-encode here. This one line
  # is the whole fix — see Gotchas.
  def raw_for(mail)
    mail.to_s
  end

  def header(message, name)
    (message.payload&.headers || []).find { |h| h.name.casecmp?(name) }&.value
  end

  def extract_body(payload)
    return '' unless payload
    return payload.body.data if payload.body&.data.present?
    part = find_part(payload.parts || [], 'text/plain') || find_part(payload.parts || [], 'text/html')
    part&.body&.data.to_s
  end

  def find_part(parts, mime_type)
    parts.each do |part|
      return part if part.mime_type == mime_type
      nested = find_part(part.parts || [], mime_type)
      return nested if nested
    end
    nil
  end
end
```

---

## Layer 4 — AgentGmailTools (the draft-first wrapper the agent calls)

This is the class your agent loop uses. Reading is free. **Sending requires two separate
yeses**: `allow_send: true` when the object is built, and `confirm: true` at the call.
Miss either and you get a Gmail draft for a human to review.

```ruby
# app/services/agent_gmail_tools.rb
class AgentGmailTools
  class UnknownMailbox < StandardError; end

  # Named, Gmail-connected mailboxes -> the address that owns the OAuth tokens.
  # Resolution is by the CONNECTED address first, so it survives a user-id change.
  MAILBOXES = {
    support: "support@yourdomain.com",
    owner:   "you@yourdomain.com"
  }.freeze

  DEFAULT_MAILBOX = :support

  # Every send and draft CCs these, so a human always sees what the agent mailed.
  # Merged in #send_email; an address already in to: is not duplicated.
  OWNER_CC = ["you@yourdomain.com"].freeze

  attr_reader :mailbox_email

  def self.for(mailbox = DEFAULT_MAILBOX, allow_send: false)
    new(resolve_user(mailbox), allow_send: allow_send)
  end

  def self.resolve_user(mailbox)
    return mailbox if mailbox.is_a?(User)
    key   = mailbox.to_s.downcase.strip
    email = MAILBOXES[key.to_sym] || (key.include?("@") ? key : nil)
    raise UnknownMailbox, "Unknown mailbox #{mailbox.inspect}" if email.nil?
    User.find_by(gmail_email: email) || User.find_by(email: email) ||
      raise(UnknownMailbox, "No user row for mailbox #{email}")
  end

  # Which mailboxes are actually live right now. Call this before claiming
  # "nobody answered" — you can only see the mailboxes you are connected to.
  def self.connected_mailboxes
    MAILBOXES.select do |_key, email|
      (User.find_by(gmail_email: email) || User.find_by(email: email))&.gmail_connected?
    end
  end

  def initialize(user, allow_send: false)
    @agent = GmailAgent.new(user)
    @allow_send = allow_send
    @mailbox_email = user.gmail_email.presence || user.email
  end

  def search_emails(query:, max_results: 20) = @agent.search(query, max_results: max_results)
  def read_email(message_id:)                = @agent.read(message_id)

  # REPLYING? Pass reply_to_message_id: (any message id from their thread) and nothing
  # else — thread_id, In-Reply-To, References and a "Re: " subject are all derived.
  # Then CHECK the returned `threaded:` flag. Do not assume a reply threaded.
  def send_email(to:, body:, subject: nil, cc: nil, bcc: nil, confirm: false,
                 thread_id: nil, in_reply_to: nil, references: nil, html_body: nil,
                 reply_to_message_id: nil)
    cc = with_owner_cc(to, cc)
    args = { to:, subject:, body:, cc:, bcc:, thread_id:, in_reply_to:, references:,
             html_body:, reply_to_message_id: }

    if @allow_send && confirm
      m = @agent.send_email(**args)
      { status: "sent", id: m.id, thread_id: m.thread_id }.merge(threading_report)
    else
      d = @agent.create_draft(**args)
      { status: "draft_created", draft_id: d.id,
        note: "Draft created, not sent. Re-call with allow_send: true and confirm: true to deliver."
      }.merge(threading_report)
    end
  end

  # Everything needed to answer "will the recipient see this as a reply?".
  def threading_report
    t = @agent.last_threading || {}
    return { threaded: false } if t[:thread_id].blank? && t[:in_reply_to].blank?

    { threaded: t[:in_reply_to].present?,
      thread_id: t[:thread_id],
      in_reply_to: t[:in_reply_to],
      subject_sent: t[:subject],
      warning: t[:in_reply_to].present? ? nil :
        "No In-Reply-To header — this lands as a NEW conversation in the recipient's inbox."
    }.compact
  end

  # ---- triage: reversible, so not gated like sending -----------------------
  def label_email(message_id:, add: [], remove: [], create_missing: true)
    @agent.modify_labels(message_id, add:, remove:, create_missing:)
    { status: "labeled", message_id:, added: Array(add), removed: Array(remove) }
  end

  def move_to_folder(message_id:, label:, archive: false)
    @agent.apply_label(message_id, label, archive: archive)
    { status: "moved", message_id:, label:, archived: archive }
  end

  def archive_email(message_id:); @agent.archive(message_id);   { status: "archived",  message_id: }; end
  def mark_read(message_id:);     @agent.mark_read(message_id); { status: "read",      message_id: }; end
  def star_email(message_id:);    @agent.star(message_id);      { status: "starred",   message_id: }; end
  def trash_email(message_id:);   @agent.trash(message_id);     { status: "trashed",   message_id: }; end
  def untrash_email(message_id:); @agent.untrash(message_id);   { status: "untrashed", message_id: }; end

  private

  def with_owner_cc(to, cc)
    to_list = Array(to).flat_map { |a| a.to_s.split(",") }.map { |a| a.strip.downcase }
    cc_list = Array(cc).flat_map { |a| a.to_s.split(",") }.map(&:strip).reject(&:empty?)
    (cc_list + OWNER_CC.reject { |a| to_list.include?(a) }).uniq(&:downcase)
  end
end
```

### Using it

```ruby
tools = AgentGmailTools.for(:support)                       # read + draft only
tools.search_emails(query: "in:anywhere newer_than:1h -in:sent")
msg = tools.read_email(message_id: "1a01ca8900382df3")

# Draft a threaded reply. No mail leaves the building.
tools.send_email(to: msg[:from], body: "...", reply_to_message_id: msg[:id])
# => {status: "draft_created", draft_id: "r-123", threaded: true, ...}

# Only after a human approves:
AgentGmailTools.for(:support, allow_send: true)
               .send_email(to: msg[:from], body: "...", reply_to_message_id: msg[:id],
                           confirm: true)
```

---

## Gotchas (the hard-won stuff)

- **Never pre-encode `raw`.** `Base64.urlsafe_encode64(mail.to_s)` double-encodes,
  because the google-apis client encodes `raw` itself. Gmail then rejects every send with
  `invalidArgument: Recipient address required` and every draft comes out with a blank
  To and Subject. `raw_for` returning plain `mail.to_s` is the entire fix.
- **`thread_id` alone is not threading.** It groups the copy in *your* mailbox only.
  Outlook, Apple Mail and Yahoo thread on `In-Reply-To` / `References`. A reply with
  `thread_id` and no headers looks correct to you and arrives as a brand-new conversation
  for the customer — a real support ticket got re-forwarded because of exactly this. Pass
  `reply_to_message_id:` and read the `threaded:` flag back.
- **Gmail search excludes spam and trash by default.** Real customer mail lands in spam.
  Add `in:anywhere` (or `in:spam`) or you will report "no new mail" while a customer waits.
- **A thread's message list includes UNSENT drafts, and nothing marks them as drafts.**
  A draft looks exactly like a message you sent. Any code that infers "we already replied"
  from a thread listing will count a draft nobody sent as a sent reply. Cross-reference
  `drafts` and subtract those ids first.
- **`list_user_drafts` caps at 500 per page.** Page it (the code above does). An un-paged
  call silently truncates, and the reply you are looking for is the one that fell off.
- **Google omits the refresh token on silent re-consent.** Without
  `access_type: 'offline'` **and** `prompt: 'consent'` you get an access token that dies in
  an hour and no way to renew it. And on re-connect, only overwrite
  `gmail_refresh_token` when Google actually sent one — the code above guards this.
- **`encrypts` needs Active Record encryption keys.** Without
  `primary_key` / `deterministic_key` / `key_derivation_salt` configured, every read of a
  token raises. Set them in `config/initializers/active_record_encryption.rb` from
  `.env`, and know that **rotating them makes existing tokens unreadable** — the mailbox
  silently disconnects and must be re-consented.
- **The redirect URI must match byte for byte.** `https` vs `http`, a trailing slash, a
  `www.` — any difference is `redirect_uri_mismatch`. This is why the routes are flat:
  moving them into a namespace later changes the generated URL.
- **`.env` changes need a container recreate**, not a restart:
  `docker compose up -d --force-recreate llamapress`.
- **🛑 Never re-run a script that contains a `confirm: true` send.** The mail goes out on
  the FIRST run. If a line *after* the send raises — a typo in your logging, a method that
  does not exist — the mail is already gone and only your result reporting died. Re-running
  "to see the error" sends a second copy. Rules: put the send **last**, build every other
  string before it, and if a script fails, **search `in:sent` for the subject before
  re-running anything**. Assume it sent until you have proven it did not.

---

## Files this pattern touches

```
db/migrate/20260820000001_add_gmail_oauth_to_users.rb
app/models/user.rb
app/controllers/google_oauth_controller.rb
app/services/gmail_agent.rb
app/services/agent_gmail_tools.rb
config/routes.rb
.env                       # GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
```

---

## How to adapt to your schema

1. **No `User` model?** The six `gmail_*` columns can live on any model — a `Mailbox`
   table is arguably cleaner. Change `AgentGmailTools.resolve_user` to look them up there.
   `GmailAgent` only needs an object answering `gmail_connected?`, the four token
   accessors, and `update!`.
2. **Rename the mailboxes.** `MAILBOXES` and `OWNER_CC` are the only two constants that
   carry your addresses. Everything else is generic.
3. **Want send enabled for a background job?** Keep the two-key rule. Build the tools with
   `allow_send: true` only inside the code path a human triggered, and keep the agent's own
   loop on the draft-only default.
4. **Safe to drop:** the label and triage helpers, the HTML-part branch of `build_mail`,
   and `drafts` if nothing in your app reasons about pending replies. **Do not drop**
   `reply_context`, `threading`, or `raw_for` — those three are the bug fixes.
5. **Next step:** wire this into an agent that checks the inbox on a schedule. That is
   [Install Leo SMS Gateway](/cookbook/install-leo-sms-gateway), which drives both
   inboxes from one Codex CLI loop.
