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
- 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/callbackexactly. - Put
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETin the project.env, then recreate the web container (docker compose up -d --force-recreate llamapress). A plainrestartdoes not reload.env. - Add the six
gmail_*columns tousersand run the migration immediately. - Copy
GmailAgentintoapp/services/gmail_agent.rbandAgentGmailToolsintoapp/services/agent_gmail_tools.rb. - Copy
GoogleOauthControllerintoapp/controllers/google_oauth_controller.rband add the three flat routes. - 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 encodesrawitself. Gmail then rejects every send withinvalidArgument: Recipient address requiredand every draft comes out with a blank To and Subject.raw_forreturning plainmail.to_sis the entire fix. thread_idalone is not threading. It groups the copy in your mailbox only. Outlook, Apple Mail and Yahoo thread onIn-Reply-To/References. A reply withthread_idand 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. Passreply_to_message_id:and read thethreaded:flag back.- Gmail search excludes spam and trash by default. Real customer mail lands in spam.
Add
in:anywhere(orin: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
draftsand subtract those ids first. list_user_draftscaps 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'andprompt: 'consent'you get an access token that dies in an hour and no way to renew it. And on re-connect, only overwritegmail_refresh_tokenwhen Google actually sent one — the code above guards this. encryptsneeds Active Record encryption keys. Withoutprimary_key/deterministic_key/key_derivation_saltconfigured, every read of a token raises. Set them inconfig/initializers/active_record_encryption.rbfrom.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.
httpsvshttp, a trailing slash, awww.— any difference isredirect_uri_mismatch. This is why the routes are flat: moving them into a namespace later changes the generated URL. .envchanges need a container recreate, not a restart:docker compose up -d --force-recreate llamapress.- 🛑 Never re-run a script that contains a
confirm: truesend. 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, searchin:sentfor 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
- No
Usermodel? The sixgmail_*columns can live on any model — aMailboxtable is arguably cleaner. ChangeAgentGmailTools.resolve_userto look them up there.GmailAgentonly needs an object answeringgmail_connected?, the four token accessors, andupdate!. - Rename the mailboxes.
MAILBOXESandOWNER_CCare the only two constants that carry your addresses. Everything else is generic. - Want send enabled for a background job? Keep the two-key rule. Build the tools with
allow_send: trueonly inside the code path a human triggered, and keep the agent's own loop on the draft-only default. - Safe to drop: the label and triage helpers, the HTML-part branch of
build_mail, anddraftsif nothing in your app reasons about pending replies. Do not dropreply_context,threading, orraw_for— those three are the bug fixes. - 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.