Email deliverability determines whether landlords and tenants receive property communications or never see them filtered to spam. Sending from generic "@yourpropertysystem.com" addresses triggers spam filters, breaks email threading, and loses established domain reputation. Letting agencies want property emails sent from staff Gmail accounts—maintaining sender identity, leveraging domain reputation, and keeping conversations in familiar inboxes.
Week 43 implemented Gmail OAuth 2.0 integration allowing LetAdmin to send emails through authenticated staff Gmail accounts. This approach provides proper SPF/DKIM authentication, maintains Gmail conversation threading, preserves sender identity, and ensures emails appear in staff Sent folders. This article explores OAuth flow implementation, secure token storage, Gmail API integration, and handling the authentication complexity across multi-tenant deployments.
What Your Team Will Notice
The email settings now include "Connect Gmail Account" buttons. Staff click to authorize LetAdmin accessing their Gmail for sending, Google displays a standard OAuth permission screen ("LetAdmin wants to send email on your behalf"), and after approval the connection completes automatically.
Once connected, all property emails—landlord reports, tenant communications, maintenance notifications—send through the connected Gmail account. Recipients see emails from "jane.smith@lettin gagency.co.uk" not "noreply@system.com", email threading works correctly in Gmail showing complete conversation history, and sent emails appear in the staff member's Gmail Sent folder maintaining complete records.
Disconnecting is equally straightforward: a "Disconnect Gmail" button in settings removes authorization immediately. No passwords are ever stored—OAuth tokens provide temporary delegated access Google can revoke anytime through account security settings.
For agencies managing multiple staff accounts, individual Gmail connections ensure emails send with correct sender identity: Jane's inspection reports come from her account, Tom's maintenance follow-ups from his. This personalization improves communication whilst maintaining central management through LetAdmin.
Under the Bonnet: OAuth 2.0 Flow
OAuth 2.0 provides secure authorization without password exposure:
# config/initializers/google_oauth.rb
require 'googleauth'
GOOGLE_CLIENT_ID = ENV['GOOGLE_CLIENT_ID']
GOOGLE_CLIENT_SECRET = ENV['GOOGLE_CLIENT_SECRET']
GOOGLE_REDIRECT_URI = ENV['GOOGLE_REDIRECT_URI'] || 'https://app.example.com/auth/google/callback'
GOOGLE_OAUTH_SCOPE = [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.readonly'
].freeze
# app/services/google_oauth_service.rb
class GoogleOauthService
def initialize(user)
@user = user
end
def authorization_url
client = create_client
client.authorization_uri.to_s
end
def exchange_code_for_tokens(authorization_code)
client = create_client
client.code = authorization_code
client.fetch_access_token!
store_tokens(
access_token: client.access_token,
refresh_token: client.refresh_token,
expires_at: Time.current + client.expires_in.seconds
)
end
def refresh_access_token!
return unless @user.google_refresh_token.present?
client = create_client
client.refresh_token = @user.google_refresh_token
client.fetch_access_token!
store_tokens(
access_token: client.access_token,
expires_at: Time.current + client.expires_in.seconds,
refresh_token: client.refresh_token || @user.google_refresh_token
)
end
def valid_token?
return false unless @user.google_access_token.present?
return false unless @user.google_token_expires_at.present?
@user.google_token_expires_at > Time.current
end
def ensure_valid_token!
return @user.google_access_token if valid_token?
refresh_access_token!
@user.google_access_token
end
private
def create_client
Google::Auth::UserAuthorizer.new(
Google::Auth::ClientId.new(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET),
GOOGLE_OAUTH_SCOPE,
token_store
)
end
def store_tokens(access_token:, expires_at:, refresh_token: nil)
@user.update!(
google_access_token: access_token,
google_token_expires_at: expires_at,
google_refresh_token: refresh_token || @user.google_refresh_token
)
end
def token_store
# Custom token store implementation
GoogleTokenStore.new(@user)
end
end
This service handles the complete OAuth lifecycle: generating authorization URLs, exchanging authorization codes for tokens, refreshing expired tokens, and validating token status.
OAuth Controller Flow
The controller manages the OAuth redirect dance:
# app/controllers/auth/google_controller.rb
class Auth::GoogleController < ApplicationController
def authorize
service = GoogleOauthService.new(current_user)
authorization_url = service.authorization_url
redirect_to authorization_url, allow_other_host: true
end
def callback
if params[:error].present?
redirect_to settings_path,
alert: "Gmail authorization failed: #{params[:error]}"
return
end
service = GoogleOauthService.new(current_user)
begin
service.exchange_code_for_tokens(params[:code])
redirect_to settings_path, notice: 'Gmail account connected successfully'
rescue StandardError => e
redirect_to settings_path,
alert: "Failed to connect Gmail: #{e.message}"
end
end
def disconnect
current_user.update!(
google_access_token: nil,
google_refresh_token: nil,
google_token_expires_at: nil
)
redirect_to settings_path, notice: 'Gmail account disconnected'
end
end
This controller orchestrates authorization initiation, callback handling with error management, and token cleanup on disconnection.
Gmail API Email Sending
Sending emails through Gmail API requires properly formatted MIME messages:
# app/services/gmail_sender_service.rb
require 'google/apis/gmail_v1'
require 'mail'
class GmailSenderService
def initialize(user)
@user = user
@oauth_service = GoogleOauthService.new(user)
end
def send_email(to:, subject:, body:, attachments: [])
# Ensure valid access token
access_token = @oauth_service.ensure_valid_token!
# Create Gmail service
gmail = Google::Apis::GmailV1::GmailService.new
gmail.authorization = access_token
# Build MIME message
message = build_message(to: to, subject: subject, body: body, attachments: attachments)
# Send through Gmail API
gmail.send_user_message(
'me',
upload_source: StringIO.new(message.to_s),
content_type: 'message/rfc822'
)
end
private
def build_message(to:, subject:, body:, attachments:)
mail = Mail.new do
from @user.email
to to
subject subject
# Support both plain text and HTML
text_part do
body strip_html(body)
end
html_part do
content_type 'text/html; charset=UTF-8'
body body
end
end
# Add attachments if present
attachments.each do |attachment|
mail.add_file(
filename: attachment[:filename],
content: attachment[:content]
)
end
mail
end
def strip_html(html)
# Simple HTML stripping for plain text version
html.gsub(/<[^>]*>/, '').gsub(/\s+/, ' ').strip
end
end
This service handles token refresh automatically, constructs properly formatted multipart MIME messages, and manages attachment encoding.
Mailer Integration
ActionMailer integrates with the Gmail sender:
# app/mailers/property_mailer.rb
class PropertyMailer < ApplicationMailer
def landlord_inspection_report(inspection, user)
@inspection = inspection
@property = inspection.property
@landlord = @property.primary_landlord
if user.google_access_token.present?
# Send via Gmail
send_via_gmail(user)
else
# Fallback to SMTP
send_via_smtp
end
end
private
def send_via_gmail(user)
gmail_service = GmailSenderService.new(user)
# Get PDF attachment
pdf_attachment = generate_inspection_pdf(@inspection)
gmail_service.send_email(
to: @landlord.email,
subject: "Property Inspection Report - #{@property.display_address}",
body: render_to_string('property_mailer/landlord_inspection_report'),
attachments: [
{
filename: "inspection-#{@property.reference}.pdf",
content: pdf_attachment
}
]
)
end
def send_via_smtp
mail(
to: @landlord.email,
subject: "Property Inspection Report - #{@property.display_address}"
)
end
def generate_inspection_pdf(inspection)
# Generate PDF using Grover (from Week 42)
html = render_to_string(
template: 'inspections/report',
layout: false,
locals: { inspection: inspection }
)
Grover.new(html).to_pdf
end
end
This mailer automatically uses Gmail when available, falling back to SMTP for users without Gmail connections.
Multi-Tenant OAuth Redirect Configuration
Multi-tenant deployments complicate OAuth redirects:
# config/routes.rb
namespace :auth do
namespace :google do
get :authorize
get :callback
delete :disconnect
end
end
# app/controllers/auth/google_controller.rb
class Auth::GoogleController < ApplicationController
private
def redirect_uri
# Build tenant-specific redirect URI
uri = URI.parse(GOOGLE_REDIRECT_URI)
# Preserve subdomain for multi-tenant callback
if ActsAsTenant.current_tenant.present?
host_parts = request.host.split('.')
if host_parts.length > 2
subdomain = host_parts.first
uri.host = "#{subdomain}.#{uri.host}"
end
end
uri.to_s
end
end
This configuration ensures OAuth callbacks route correctly to the originating tenant subdomain.
Testing Gmail Integration
Testing OAuth flows requires mocking external API calls:
RSpec.describe GmailSenderService do
let(:user) { create(:user, :with_gmail_token) }
let(:service) { described_class.new(user) }
before do
stub_request(:post, %r{https://oauth2.googleapis.com/token})
.to_return(
status: 200,
body: {
access_token: 'new_token',
expires_in: 3600
}.to_json
)
stub_request(:post, %r{https://gmail.googleapis.com/gmail/v1/users/me/messages/send})
.to_return(status: 200, body: { id: '12345' }.to_json)
end
it "sends email through Gmail API" do
service.send_email(
to: 'landlord@example.com',
subject: 'Test email',
body: '<p>Email body</p>'
)
expect(WebMock).to have_requested(:post, %r{gmail.googleapis.com})
end
it "refreshes token when expired" do
user.update!(google_token_expires_at: 1.hour.ago)
service.send_email(
to: 'landlord@example.com',
subject: 'Test',
body: 'Body'
)
expect(WebMock).to have_requested(:post, %r{oauth2.googleapis.com/token})
expect(user.reload.google_access_token).to eq('new_token')
end
end
These tests verify OAuth token refresh and Gmail API integration without external dependencies.
What's Next
The Gmail OAuth foundation enables sophisticated features: conversation threading tracking complete email histories, email templates with merge fields for personalized communications, scheduled sending queuing emails for optimal delivery times, and bounce handling detecting undeliverable addresses.
Future enhancements might include Gmail label management automatically categorizing property emails, draft synchronization allowing email composition in Gmail UI, attachment management saving property documents to Google Drive automatically, and analytics tracking email open rates and landlord engagement.
By implementing secure Gmail OAuth integration, LetAdmin provides letting agencies with email sending that maintains sender identity, leverages established deliverability reputation, and integrates seamlessly with existing Gmail workflows staff already use daily.
