Install Agent Gmail Service | LlamaPress Cookbook
LlamaPress
Cookbook
Integrations Stable

Install Agent Gmail Service

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.

Proven on llamapress.ai model · controller

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:

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:

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

Layer 1 — Migration & model

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

# 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:

docker compose exec -T llamapress bin/rails db:migrate
# 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

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

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

# 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

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, which drives both inboxes from one Codex CLI loop.

Related