Monday, October 20, 2025

Sending Property Emails Through Gmail with OAuth 2.0

Paul (Founder)
Development
Developer implementing OAuth 2.0 authentication flow for Gmail integration

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.