{"slug":"install-agent-gmail-service","meta":{"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."}]},"body":"# Install Agent Gmail Service\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nThis recipe gives your app a real Gmail mailbox that an agent can work: search it, read a\nmessage, label and archive, write a draft, and — only when a human says so — send a\nthreaded reply. Tokens are per user and encrypted at rest, so one app can hold several\nmailboxes and pick one by name.\n\nThe important half of this guide is not the API calls. It is the two guardrails around\nthem: **draft-first sending** (an agent can write mail but cannot mail anyone until a\nhuman passes `confirm: true`) and **correct reply threading** (a reply must carry\n`In-Reply-To`, not just Gmail's `thread_id`, or the customer receives a brand-new\nconversation).\n\n\u003e **When to use:** an app that must read a shared inbox, triage it, and answer from a\n\u003e real Gmail address — support desks, sales follow-up, an agent that watches for\n\u003e customer mail.\n\u003e **When not to:** transactional mail your app generates (order receipts, password\n\u003e resets). Use Action Mailer with SES or SMTP for those. Gmail is for a **conversation**\n\u003e with a human, not for a send-only pipe.\n\n**Gem check — nothing to install.** All four gems this needs are already in the Leo base\nimage (verified on `llamapress-simple:0.7.2`): `google-apis-gmail_v1 0.51.0`,\n`googleauth 1.17.0`, `signet 0.22.0`, and `mail`. You cannot add gems on a Leo box, and\nyou do not need to. Confirm on yours:\n\n```bash\ndocker compose exec -T llamapress bash -c \"bundle list | grep -iE 'gmail|googleauth|signet'\"\n```\n\n---\n\n## The 80/20 in one breath\n\n1. In Google Cloud Console, create a project, enable the **Gmail API**, and create an\n   **OAuth client ID** of type **Web application**. Add the redirect URI\n   `https://\u003cyour-app-host\u003e/google_oauth/callback` exactly.\n2. Put `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the project `.env`, then\n   **recreate** the web container (`docker compose up -d --force-recreate llamapress`).\n   A plain `restart` does not reload `.env`.\n3. Add the six `gmail_*` columns to `users` and run the migration immediately.\n4. Copy `GmailAgent` into `app/services/gmail_agent.rb` and `AgentGmailTools` into\n   `app/services/agent_gmail_tools.rb`.\n5. Copy `GoogleOauthController` into `app/controllers/google_oauth_controller.rb` and add\n   the three flat routes.\n6. Sign in as the mailbox owner and visit `/google_oauth/connect`. Approve the consent\n   screen. You now have a connected mailbox.\n\nVerify in one line — this reads, it does not send:\n\n```ruby\nAgentGmailTools.connected_mailboxes\n# =\u003e {:support=\u003e\"support@yourdomain.com\"}\n```\n\n---\n\n## Layer 1 — Migration \u0026 model\n\nSix columns on `users`. Two of them hold secrets, so encrypt them.\n\n```ruby\n# db/migrate/20260820000001_add_gmail_oauth_to_users.rb\nclass AddGmailOauthToUsers \u003c ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :gmail_access_token,  :text     # short-lived, refreshed for you\n    add_column :users, :gmail_refresh_token, :text     # the long-lived secret\n    add_column :users, :gmail_expires_at,    :datetime\n    add_column :users, :gmail_email,         :string   # the address the tokens belong to\n    add_column :users, :gmail_connected_at,  :datetime\n    add_column :users, :gmail_scope,         :string\n  end\nend\n```\n\nRun it **immediately** after writing it. A pending migration blocks every request in this\nstack:\n\n```bash\ndocker compose exec -T llamapress bin/rails db:migrate\n```\n\n```ruby\n# app/models/user.rb\nclass User \u003c ApplicationRecord\n  # Encrypted at rest. Requires Active Record encryption keys — see Gotchas.\n  encrypts :gmail_access_token\n  encrypts :gmail_refresh_token\n\n  def gmail_connected?\n    gmail_refresh_token.present? \u0026\u0026 gmail_connected_at.present?\n  end\n\n  def disconnect_gmail!\n    update!(gmail_access_token: nil, gmail_refresh_token: nil, gmail_expires_at: nil,\n            gmail_email: nil, gmail_connected_at: nil, gmail_scope: nil)\n  end\nend\n```\n\n---\n\n## Layer 2 — The OAuth controller \u0026 routes\n\n```ruby\n# app/controllers/google_oauth_controller.rb\nrequire 'googleauth'\nrequire 'google/apis/gmail_v1'\nrequire 'securerandom'\n\nclass GoogleOauthController \u003c ApplicationController\n  before_action :authenticate_user!\n\n  # gmail.modify is the broad read/write scope: read, compose, send, labels, archive,\n  # star, mark read/important, and trash. It does NOT permit permanent deletion that\n  # bypasses Trash — that needs 'https://mail.google.com/', which you should not ask for.\n  SCOPES = [\n    'https://www.googleapis.com/auth/gmail.modify',\n    'https://www.googleapis.com/auth/userinfo.email',\n    'openid'\n  ].freeze\n\n  # GET /google_oauth/connect\n  def connect\n    state = SecureRandom.hex(24)\n    session[:gmail_oauth_state] = state\n\n    redirect_to user_authorizer.get_authorization_url(\n      state: state,\n      access_type: 'offline',   # ask for a refresh token\n      prompt: 'consent',        # force consent so the refresh token is actually returned\n      include_granted_scopes: 'true'\n    ), allow_other_host: true\n  end\n\n  # GET /google_oauth/callback\n  def callback\n    if params[:state].blank? || params[:state] != session.delete(:gmail_oauth_state)\n      flash[:error] = \"Invalid state parameter. Please try connecting Gmail again.\"\n      return redirect_to root_path\n    end\n    if params[:error].present?\n      flash[:error] = \"Gmail authorization failed: #{params[:error]}\"\n      return redirect_to root_path\n    end\n\n    creds = user_authorizer.get_credentials_from_code(code: params[:code])\n    attrs = {\n      gmail_access_token: creds.access_token,\n      gmail_expires_at:   creds.expires_at,\n      gmail_scope:        SCOPES.join(' '),\n      gmail_connected_at: Time.current,\n      gmail_email:        fetch_gmail_address(creds)\n    }\n    # Google OMITS the refresh token on a silent re-consent. Never clobber a good one.\n    attrs[:gmail_refresh_token] = creds.refresh_token if creds.refresh_token.present?\n\n    current_user.update!(attrs)\n    flash[:success] = \"Gmail connected as #{current_user.gmail_email}.\"\n    redirect_to root_path\n  rescue =\u003e e\n    Rails.logger.error \"Gmail OAuth error: #{e.class}: #{e.message}\"\n    flash[:error] = \"Failed to connect Gmail: #{e.message}\"\n    redirect_to root_path\n  end\n\n  # DELETE /google_oauth/disconnect\n  def disconnect\n    current_user.disconnect_gmail!\n    redirect_to root_path, notice: \"Gmail disconnected.\"\n  end\n\n  private\n\n  def client_id\n    Google::Auth::ClientId.new(ENV.fetch('GOOGLE_CLIENT_ID'), ENV.fetch('GOOGLE_CLIENT_SECRET'))\n  end\n\n  def user_authorizer\n    Google::Auth::UserAuthorizer.new(client_id, SCOPES, nil, google_oauth_callback_url)\n  end\n\n  def fetch_gmail_address(creds)\n    svc = Google::Apis::GmailV1::GmailService.new\n    svc.authorization = creds\n    svc.get_user_profile('me').email_address\n  rescue =\u003e e\n    Rails.logger.warn \"Could not fetch Gmail address: #{e.message}\"\n    nil\n  end\nend\n```\n\n```ruby\n# config/routes.rb\n# Flat paths on purpose: google_oauth_callback_url must stay byte-identical to the\n# redirect URI you registered in Google Cloud Console. Nesting it in a namespace later\n# changes the URL and breaks every connect with redirect_uri_mismatch.\nget    'google_oauth/connect',    to: 'google_oauth#connect',    as: :google_oauth_connect\nget    'google_oauth/callback',   to: 'google_oauth#callback',   as: :google_oauth_callback\ndelete 'google_oauth/disconnect', to: 'google_oauth#disconnect', as: :google_oauth_disconnect\n```\n\n---\n\n## Layer 3 — GmailAgent (the raw service)\n\nOne connected user, one object. This is the layer that talks to Google.\n\n```ruby\n# app/services/gmail_agent.rb\nrequire 'google/apis/gmail_v1'\nrequire 'googleauth'\nrequire 'mail'\n\nclass GmailAgent\n  class NotConnected \u003c StandardError; end\n  Gmail = Google::Apis::GmailV1\n\n  attr_reader :user, :last_threading\n\n  def initialize(user)\n    @user = user\n    raise NotConnected, \"User #{user\u0026.id} has not connected Gmail\" unless user\u0026.gmail_connected?\n  end\n\n  # Gmail query syntax. Add in:anywhere to include spam and trash — a plain search\n  # EXCLUDES both, and real customer mail lands in spam.\n  def search(query, max_results: 20)\n    (service.list_user_messages('me', q: query, max_results: max_results).messages || [])\n      .map { |m| { id: m.id, thread_id: m.thread_id } }\n  end\n\n  def read(message_id)\n    m = service.get_user_message('me', message_id, format: 'full')\n    {\n      id: m.id, thread_id: m.thread_id, snippet: m.snippet,\n      subject: header(m, 'Subject'), from: header(m, 'From'),\n      to: header(m, 'To'), cc: header(m, 'Cc'), date: header(m, 'Date'),\n      # The RFC 5322 Message-ID — what a reply must cite so the RECIPIENT threads it.\n      message_id_header: header(m, 'Message-ID') || header(m, 'Message-Id'),\n      references: header(m, 'References'),\n      body: extract_body(m.payload)\n    }\n  end\n\n  # The threading facts needed to reply INTO a conversation. Accepts a message id or a\n  # thread id.\n  #\n  # WHY THIS EXISTS: Gmail's thread_id only groups the copy in YOUR mailbox. Every other\n  # mail client threads on In-Reply-To / References. A reply sent with thread_id and\n  # nothing else looks perfectly threaded to you and arrives as a NEW conversation for\n  # the customer. Derive the headers; never hand-pass thread_id alone.\n  #\n  # Skips unsent DRAFTs: a draft's Message-ID never reached anyone.\n  def reply_context(message_or_thread_id)\n    thread_id = resolve_thread_id(message_or_thread_id)\n    return nil if thread_id.blank?\n\n    thread = service.get_user_thread('me', thread_id, format: 'metadata',\n                                     metadata_headers: %w[Subject Message-ID References])\n    parent = (thread.messages || []).reject { |m| Array(m.label_ids).include?('DRAFT') }.last\n    return { thread_id: thread_id, in_reply_to: nil, references: nil, subject: nil } if parent.nil?\n\n    pid = header(parent, 'Message-ID') || header(parent, 'Message-Id')\n    { thread_id: thread_id,\n      in_reply_to: pid,\n      references: self.class.build_references(header(parent, 'References'), pid),\n      subject: self.class.reply_subject(header(parent, 'Subject')) }\n  end\n\n  # References = the parent's chain + the parent's own Message-ID (RFC 5322 3.6.4).\n  def self.build_references(parent_references, parent_message_id)\n    chain = \"#{parent_references} #{parent_message_id}\".split.uniq\n    chain.empty? ? nil : chain.join(' ')\n  end\n\n  # \"Re: \" exactly once, however the original was capitalised.\n  def self.reply_subject(subject)\n    s = subject.to_s.strip\n    return nil if s.empty?\n    s.match?(/\\A\\s*re\\s*:/i) ? s : \"Re: #{s}\"\n  end\n\n  def send_email(to:, body:, subject: nil, from: nil, cc: nil, bcc: nil, thread_id: nil,\n                 in_reply_to: nil, references: nil, html_body: nil, reply_to_message_id: nil)\n    t = threading(thread_id:, in_reply_to:, references:, subject:, reply_to_message_id:)\n    mail = build_mail(to:, subject: t[:subject], body:, from:, cc:, bcc:,\n                      in_reply_to: t[:in_reply_to], references: t[:references], html_body:)\n    service.send_user_message('me', Gmail::Message.new(raw: raw_for(mail), thread_id: t[:thread_id]))\n  end\n\n  def create_draft(to:, body:, subject: nil, from: nil, cc: nil, bcc: nil, thread_id: nil,\n                   in_reply_to: nil, references: nil, html_body: nil, reply_to_message_id: nil)\n    t = threading(thread_id:, in_reply_to:, references:, subject:, reply_to_message_id:)\n    mail = build_mail(to:, subject: t[:subject], body:, from:, cc:, bcc:,\n                      in_reply_to: t[:in_reply_to], references: t[:references], html_body:)\n    message = Gmail::Message.new(raw: raw_for(mail), thread_id: t[:thread_id])\n    service.create_user_draft('me', Gmail::Draft.new(message: message))\n  end\n\n  # PAGINATES. Gmail caps a page at 500 and a busy mailbox holds hundreds of drafts, so\n  # a single un-paged call silently truncates and a pending reply falls off the end.\n  def drafts(max_results: 1_000)\n    collected, page_token = [], nil\n    loop do\n      page = service.list_user_drafts('me', max_results: [max_results - collected.size, 500].min,\n                                            page_token: page_token)\n      (page.drafts || []).each do |d|\n        collected \u003c\u003c { draft_id: d.id, message_id: d.message\u0026.id, thread_id: d.message\u0026.thread_id }\n      end\n      page_token = page.next_page_token\n      break if page_token.blank? || collected.size \u003e= max_results\n    end\n    collected\n  end\n\n  # ---- triage (all reversible) --------------------------------------------\n  def labels\n    (service.list_user_labels('me').labels || []).map { |l| { id: l.id, name: l.name, type: l.type } }\n  end\n\n  def create_label(name)\n    created = service.create_user_label('me', Gmail::Label.new(\n      name: name, label_list_visibility: 'labelShow', message_list_visibility: 'show'))\n    @label_index = nil\n    created\n  end\n\n  def modify_labels(message_id, add: [], remove: [], create_missing: false)\n    service.modify_message('me', message_id, Gmail::ModifyMessageRequest.new(\n      add_label_ids:    Array(add).map    { |l| resolve_label_id(l, create: create_missing) }.compact,\n      remove_label_ids: Array(remove).map { |l| resolve_label_id(l, create: false) }.compact))\n  end\n\n  def archive(message_id);     modify_labels(message_id, remove: ['INBOX']); end\n  def mark_read(message_id);   modify_labels(message_id, remove: ['UNREAD']); end\n  def star(message_id);        modify_labels(message_id, add: ['STARRED']); end\n  def trash(message_id);       service.trash_user_message('me', message_id); end\n  def untrash(message_id);     service.untrash_user_message('me', message_id); end\n\n  # A true \"move to folder\": apply a label and drop it out of the inbox.\n  def apply_label(message_id, label_name, archive: false, create_missing: true)\n    modify_labels(message_id, add: [label_name], remove: archive ? ['INBOX'] : [],\n                              create_missing: create_missing)\n  end\n\n  private\n\n  # THE BACKSTOP. A caller who passes thread_id but no in_reply_to gets the headers\n  # derived automatically, because thread_id is the only threading field most search and\n  # read tools ever surface — so that broken call shape is the DEFAULT mistake. Explicit\n  # values always win; a derivation failure never blocks the send.\n  def threading(thread_id:, in_reply_to:, references:, subject:, reply_to_message_id:)\n    source = reply_to_message_id.presence\n    source ||= thread_id.presence if in_reply_to.blank?\n    ctx = source ? (reply_context(source) || {}) : {}\n\n    @last_threading = {\n      thread_id:   thread_id.presence   || ctx[:thread_id],\n      in_reply_to: in_reply_to.presence || ctx[:in_reply_to],\n      references:  references.presence  || ctx[:references],\n      subject:     subject.presence     || ctx[:subject]\n    }\n  rescue Google::Apis::Error =\u003e e\n    Rails.logger.warn(\"GmailAgent: could not derive threading headers: #{e.message}\")\n    @last_threading = { thread_id:, in_reply_to:, references:, subject: }\n  end\n\n  # Message ids and thread ids share a namespace. Try it as a message first — that is\n  # what the tools hand out — and fall back to treating it as a thread id.\n  def resolve_thread_id(id)\n    return nil if id.blank?\n    service.get_user_message('me', id, format: 'minimal').thread_id\n  rescue Google::Apis::ClientError\n    id\n  end\n\n  SYSTEM_LABELS = %w[INBOX SENT DRAFT TRASH SPAM UNREAD STARRED IMPORTANT CHAT].freeze\n\n  def resolve_label_id(label, create: false)\n    s = label.to_s\n    return s.upcase if SYSTEM_LABELS.include?(s.upcase)\n    return s if s.start_with?('CATEGORY_', 'Label_')\n    label_index[s.downcase] || (create ? create_label(s).id : nil)\n  end\n\n  def label_index\n    @label_index ||= (service.list_user_labels('me').labels || []).each_with_object({}) do |l, h|\n      h[l.name.downcase] = l.id\n    end\n  end\n\n  def service\n    @service ||= Gmail::GmailService.new.tap { |s| s.authorization = credentials }\n  end\n\n  def credentials\n    @credentials ||= begin\n      creds = Google::Auth::UserRefreshCredentials.new(\n        client_id:     ENV.fetch('GOOGLE_CLIENT_ID'),\n        client_secret: ENV.fetch('GOOGLE_CLIENT_SECRET'),\n        refresh_token: user.gmail_refresh_token,\n        access_token:  user.gmail_access_token,\n        expires_at:    user.gmail_expires_at\u0026.to_i,\n        scope:         user.gmail_scope\n      )\n      refresh_if_needed!(creds)\n      creds\n    end\n  end\n\n  def refresh_if_needed!(creds)\n    return unless user.gmail_access_token.blank? || creds.expired?\n    creds.fetch_access_token!   # the refresh token itself does not rotate\n    user.update!(gmail_access_token: creds.access_token, gmail_expires_at: creds.expires_at)\n  rescue Signet::AuthorizationError =\u003e e\n    # The refresh token is dead: revoked, expired, or the app was un-consented.\n    Rails.logger.warn(\"GmailAgent: token refresh failed for #{user.gmail_email}: #{e.message}\")\n    raise\n  end\n\n  def build_mail(to:, subject:, body:, from:, cc:, bcc:, in_reply_to: nil, references: nil,\n                 html_body: nil)\n    mail = Mail.new\n    mail.to      = to\n    mail.from    = from.presence || user.gmail_email\n    mail.subject = subject\n    if html_body.present?\n      text = Mail::Part.new.tap { |p| p.content_type = 'text/plain; charset=UTF-8'; p.body = body }\n      html = Mail::Part.new.tap { |p| p.content_type = 'text/html; charset=UTF-8';  p.body = html_body }\n      mail.text_part = text\n      mail.html_part = html\n    else\n      mail.body = body\n    end\n    mail.cc  = cc  if cc.present?\n    mail.bcc = bcc if bcc.present?\n    mail.in_reply_to = in_reply_to if in_reply_to.present?\n    mail.references  = references  if references.present?\n    mail\n  end\n\n  # The `raw` field must be the PLAIN RFC822 string. The google-apis client base64url\n  # encodes `raw` itself during serialization, so do NOT pre-encode here. This one line\n  # is the whole fix — see Gotchas.\n  def raw_for(mail)\n    mail.to_s\n  end\n\n  def header(message, name)\n    (message.payload\u0026.headers || []).find { |h| h.name.casecmp?(name) }\u0026.value\n  end\n\n  def extract_body(payload)\n    return '' unless payload\n    return payload.body.data if payload.body\u0026.data.present?\n    part = find_part(payload.parts || [], 'text/plain') || find_part(payload.parts || [], 'text/html')\n    part\u0026.body\u0026.data.to_s\n  end\n\n  def find_part(parts, mime_type)\n    parts.each do |part|\n      return part if part.mime_type == mime_type\n      nested = find_part(part.parts || [], mime_type)\n      return nested if nested\n    end\n    nil\n  end\nend\n```\n\n---\n\n## Layer 4 — AgentGmailTools (the draft-first wrapper the agent calls)\n\nThis is the class your agent loop uses. Reading is free. **Sending requires two separate\nyeses**: `allow_send: true` when the object is built, and `confirm: true` at the call.\nMiss either and you get a Gmail draft for a human to review.\n\n```ruby\n# app/services/agent_gmail_tools.rb\nclass AgentGmailTools\n  class UnknownMailbox \u003c StandardError; end\n\n  # Named, Gmail-connected mailboxes -\u003e the address that owns the OAuth tokens.\n  # Resolution is by the CONNECTED address first, so it survives a user-id change.\n  MAILBOXES = {\n    support: \"support@yourdomain.com\",\n    owner:   \"you@yourdomain.com\"\n  }.freeze\n\n  DEFAULT_MAILBOX = :support\n\n  # Every send and draft CCs these, so a human always sees what the agent mailed.\n  # Merged in #send_email; an address already in to: is not duplicated.\n  OWNER_CC = [\"you@yourdomain.com\"].freeze\n\n  attr_reader :mailbox_email\n\n  def self.for(mailbox = DEFAULT_MAILBOX, allow_send: false)\n    new(resolve_user(mailbox), allow_send: allow_send)\n  end\n\n  def self.resolve_user(mailbox)\n    return mailbox if mailbox.is_a?(User)\n    key   = mailbox.to_s.downcase.strip\n    email = MAILBOXES[key.to_sym] || (key.include?(\"@\") ? key : nil)\n    raise UnknownMailbox, \"Unknown mailbox #{mailbox.inspect}\" if email.nil?\n    User.find_by(gmail_email: email) || User.find_by(email: email) ||\n      raise(UnknownMailbox, \"No user row for mailbox #{email}\")\n  end\n\n  # Which mailboxes are actually live right now. Call this before claiming\n  # \"nobody answered\" — you can only see the mailboxes you are connected to.\n  def self.connected_mailboxes\n    MAILBOXES.select do |_key, email|\n      (User.find_by(gmail_email: email) || User.find_by(email: email))\u0026.gmail_connected?\n    end\n  end\n\n  def initialize(user, allow_send: false)\n    @agent = GmailAgent.new(user)\n    @allow_send = allow_send\n    @mailbox_email = user.gmail_email.presence || user.email\n  end\n\n  def search_emails(query:, max_results: 20) = @agent.search(query, max_results: max_results)\n  def read_email(message_id:)                = @agent.read(message_id)\n\n  # REPLYING? Pass reply_to_message_id: (any message id from their thread) and nothing\n  # else — thread_id, In-Reply-To, References and a \"Re: \" subject are all derived.\n  # Then CHECK the returned `threaded:` flag. Do not assume a reply threaded.\n  def send_email(to:, body:, subject: nil, cc: nil, bcc: nil, confirm: false,\n                 thread_id: nil, in_reply_to: nil, references: nil, html_body: nil,\n                 reply_to_message_id: nil)\n    cc = with_owner_cc(to, cc)\n    args = { to:, subject:, body:, cc:, bcc:, thread_id:, in_reply_to:, references:,\n             html_body:, reply_to_message_id: }\n\n    if @allow_send \u0026\u0026 confirm\n      m = @agent.send_email(**args)\n      { status: \"sent\", id: m.id, thread_id: m.thread_id }.merge(threading_report)\n    else\n      d = @agent.create_draft(**args)\n      { status: \"draft_created\", draft_id: d.id,\n        note: \"Draft created, not sent. Re-call with allow_send: true and confirm: true to deliver.\"\n      }.merge(threading_report)\n    end\n  end\n\n  # Everything needed to answer \"will the recipient see this as a reply?\".\n  def threading_report\n    t = @agent.last_threading || {}\n    return { threaded: false } if t[:thread_id].blank? \u0026\u0026 t[:in_reply_to].blank?\n\n    { threaded: t[:in_reply_to].present?,\n      thread_id: t[:thread_id],\n      in_reply_to: t[:in_reply_to],\n      subject_sent: t[:subject],\n      warning: t[:in_reply_to].present? ? nil :\n        \"No In-Reply-To header — this lands as a NEW conversation in the recipient's inbox.\"\n    }.compact\n  end\n\n  # ---- triage: reversible, so not gated like sending -----------------------\n  def label_email(message_id:, add: [], remove: [], create_missing: true)\n    @agent.modify_labels(message_id, add:, remove:, create_missing:)\n    { status: \"labeled\", message_id:, added: Array(add), removed: Array(remove) }\n  end\n\n  def move_to_folder(message_id:, label:, archive: false)\n    @agent.apply_label(message_id, label, archive: archive)\n    { status: \"moved\", message_id:, label:, archived: archive }\n  end\n\n  def archive_email(message_id:); @agent.archive(message_id);   { status: \"archived\",  message_id: }; end\n  def mark_read(message_id:);     @agent.mark_read(message_id); { status: \"read\",      message_id: }; end\n  def star_email(message_id:);    @agent.star(message_id);      { status: \"starred\",   message_id: }; end\n  def trash_email(message_id:);   @agent.trash(message_id);     { status: \"trashed\",   message_id: }; end\n  def untrash_email(message_id:); @agent.untrash(message_id);   { status: \"untrashed\", message_id: }; end\n\n  private\n\n  def with_owner_cc(to, cc)\n    to_list = Array(to).flat_map { |a| a.to_s.split(\",\") }.map { |a| a.strip.downcase }\n    cc_list = Array(cc).flat_map { |a| a.to_s.split(\",\") }.map(\u0026:strip).reject(\u0026:empty?)\n    (cc_list + OWNER_CC.reject { |a| to_list.include?(a) }).uniq(\u0026:downcase)\n  end\nend\n```\n\n### Using it\n\n```ruby\ntools = AgentGmailTools.for(:support)                       # read + draft only\ntools.search_emails(query: \"in:anywhere newer_than:1h -in:sent\")\nmsg = tools.read_email(message_id: \"1a01ca8900382df3\")\n\n# Draft a threaded reply. No mail leaves the building.\ntools.send_email(to: msg[:from], body: \"...\", reply_to_message_id: msg[:id])\n# =\u003e {status: \"draft_created\", draft_id: \"r-123\", threaded: true, ...}\n\n# Only after a human approves:\nAgentGmailTools.for(:support, allow_send: true)\n               .send_email(to: msg[:from], body: \"...\", reply_to_message_id: msg[:id],\n                           confirm: true)\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Never pre-encode `raw`.** `Base64.urlsafe_encode64(mail.to_s)` double-encodes,\n  because the google-apis client encodes `raw` itself. Gmail then rejects every send with\n  `invalidArgument: Recipient address required` and every draft comes out with a blank\n  To and Subject. `raw_for` returning plain `mail.to_s` is the entire fix.\n- **`thread_id` alone is not threading.** It groups the copy in *your* mailbox only.\n  Outlook, Apple Mail and Yahoo thread on `In-Reply-To` / `References`. A reply with\n  `thread_id` and no headers looks correct to you and arrives as a brand-new conversation\n  for the customer — a real support ticket got re-forwarded because of exactly this. Pass\n  `reply_to_message_id:` and read the `threaded:` flag back.\n- **Gmail search excludes spam and trash by default.** Real customer mail lands in spam.\n  Add `in:anywhere` (or `in:spam`) or you will report \"no new mail\" while a customer waits.\n- **A thread's message list includes UNSENT drafts, and nothing marks them as drafts.**\n  A draft looks exactly like a message you sent. Any code that infers \"we already replied\"\n  from a thread listing will count a draft nobody sent as a sent reply. Cross-reference\n  `drafts` and subtract those ids first.\n- **`list_user_drafts` caps at 500 per page.** Page it (the code above does). An un-paged\n  call silently truncates, and the reply you are looking for is the one that fell off.\n- **Google omits the refresh token on silent re-consent.** Without\n  `access_type: 'offline'` **and** `prompt: 'consent'` you get an access token that dies in\n  an hour and no way to renew it. And on re-connect, only overwrite\n  `gmail_refresh_token` when Google actually sent one — the code above guards this.\n- **`encrypts` needs Active Record encryption keys.** Without\n  `primary_key` / `deterministic_key` / `key_derivation_salt` configured, every read of a\n  token raises. Set them in `config/initializers/active_record_encryption.rb` from\n  `.env`, and know that **rotating them makes existing tokens unreadable** — the mailbox\n  silently disconnects and must be re-consented.\n- **The redirect URI must match byte for byte.** `https` vs `http`, a trailing slash, a\n  `www.` — any difference is `redirect_uri_mismatch`. This is why the routes are flat:\n  moving them into a namespace later changes the generated URL.\n- **`.env` changes need a container recreate**, not a restart:\n  `docker compose up -d --force-recreate llamapress`.\n- **🛑 Never re-run a script that contains a `confirm: true` send.** The mail goes out on\n  the FIRST run. If a line *after* the send raises — a typo in your logging, a method that\n  does not exist — the mail is already gone and only your result reporting died. Re-running\n  \"to see the error\" sends a second copy. Rules: put the send **last**, build every other\n  string before it, and if a script fails, **search `in:sent` for the subject before\n  re-running anything**. Assume it sent until you have proven it did not.\n\n---\n\n## Files this pattern touches\n\n```\ndb/migrate/20260820000001_add_gmail_oauth_to_users.rb\napp/models/user.rb\napp/controllers/google_oauth_controller.rb\napp/services/gmail_agent.rb\napp/services/agent_gmail_tools.rb\nconfig/routes.rb\n.env                       # GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET\n```\n\n---\n\n## How to adapt to your schema\n\n1. **No `User` model?** The six `gmail_*` columns can live on any model — a `Mailbox`\n   table is arguably cleaner. Change `AgentGmailTools.resolve_user` to look them up there.\n   `GmailAgent` only needs an object answering `gmail_connected?`, the four token\n   accessors, and `update!`.\n2. **Rename the mailboxes.** `MAILBOXES` and `OWNER_CC` are the only two constants that\n   carry your addresses. Everything else is generic.\n3. **Want send enabled for a background job?** Keep the two-key rule. Build the tools with\n   `allow_send: true` only inside the code path a human triggered, and keep the agent's own\n   loop on the draft-only default.\n4. **Safe to drop:** the label and triage helpers, the HTML-part branch of `build_mail`,\n   and `drafts` if nothing in your app reasons about pending replies. **Do not drop**\n   `reply_context`, `threading`, or `raw_for` — those three are the bug fixes.\n5. **Next step:** wire this into an agent that checks the inbox on a schedule. That is\n   [Install Leo SMS Gateway](/cookbook/install-leo-sms-gateway), which drives both\n   inboxes from one Codex CLI loop.\n"}