feat: Add automatic favicon fetching for companies (#13013)

## Summary

This Enterprise-only feature automatically fetches a favicon for
companies created with a domain, and adds a batch task to backfill
missing avatars for existing companies. The flow only targets companies
that do not already have an attached avatar, so existing avatars are
left untouched.


## Demo 



https://github.com/user-attachments/assets/d050334e-769f-4e46-b6e7-f7423727a192



## What changed

- Added `Avatar::AvatarFromFaviconJob` to build a Google favicon URL
from the company domain and fetch it through `Avatar::AvatarFromUrlJob`
- Triggered favicon fetching from `Company` with `after_create_commit`
- Added `Companies::FetchAvatarsJob` to batch existing companies that
are missing avatars
- Added `companies:fetch_missing_avatars` under `enterprise/lib/tasks`
- Kept the company-specific implementation inside the Enterprise
boundary
- Stubbed the new favicon request in unrelated specs that now hit this
callback indirectly
- Updated a couple of CI-sensitive specs that were failing due to
callback side effects / reload-safe exception assertions

## How to verify

1. Create a company in Enterprise with a valid domain and no avatar.
2. Confirm that a favicon-based avatar gets attached shortly after
creation.
3. Create another company with a domain and an avatar already attached.
4. Confirm that the existing avatar is not replaced.
5. Run `companies:fetch_missing_avatars`.
6. Confirm that existing companies without avatars get one, while
companies that already have avatars remain unchanged.

## Notes

- This change does not refresh or overwrite existing company avatars
- Favicon fetching only runs for companies with a present domain
- The branch includes the latest `develop`

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Vinay Keerthi
2026-03-06 08:21:28 +05:30
committed by GitHub
parent 397b0bcc9d
commit 059506b1db
11 changed files with 140 additions and 30 deletions

View File

@@ -0,0 +1,11 @@
class Avatar::AvatarFromFaviconJob < ApplicationJob
queue_as :purgable
def perform(company)
return if company.domain.blank?
return if company.avatar.attached?
favicon_url = "https://www.google.com/s2/favicons?domain=#{company.domain}&sz=256"
Avatar::AvatarFromUrlJob.perform_now(company, favicon_url)
end
end

View File

@@ -0,0 +1,17 @@
class Companies::FetchAvatarsJob < ApplicationJob
queue_as :low
def perform(account_id)
account = Account.find(account_id)
companies = account.companies.where.not(domain: [nil, ''])
.left_joins(:avatar_attachment)
.where(active_storage_attachments: { id: nil })
total_companies = companies.count
companies.find_each do |company|
Avatar::AvatarFromFaviconJob.perform_later(company)
end
Rails.logger.info "Queued #{total_companies} companies from account #{account_id} for favicon fetch"
end
end

View File

@@ -30,11 +30,13 @@ class Company < ApplicationRecord
belongs_to :account
has_many :contacts, dependent: :nullify
after_create_commit :fetch_favicon, if: -> { domain.present? }
scope :ordered_by_name, -> { order(:name) }
scope :search_by_name_or_domain, lambda { |query|
where('name ILIKE :search OR domain ILIKE :search', search: "%#{query.strip}%")
}
scope :order_on_contacts_count, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
@@ -42,4 +44,10 @@ class Company < ApplicationRecord
)
)
}
private
def fetch_favicon
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
end
end

View File

@@ -0,0 +1,31 @@
namespace :companies do
desc 'Backfill companies from existing contact email domains'
task backfill: :environment do
puts 'Starting company backfill migration...'
puts 'This will process all accounts and create companies from contact email domains.'
puts 'The job will run in the background via Sidekiq'
puts ''
Migration::CompanyBackfillJob.perform_later
puts 'Company backfill job has been enqueued.'
puts 'Monitor progress in logs or Sidekiq dashboard.'
end
desc 'Fetch favicons for companies without avatars'
task fetch_missing_avatars: :environment do
account_ids = companies_without_avatars
account_ids.each do |account_id|
Companies::FetchAvatarsJob.perform_later(account_id)
end
puts "Queued #{account_ids.count} accounts for favicon fetch"
end
end
def companies_without_avatars
Company.left_joins(:avatar_attachment)
.where(active_storage_attachments: { id: nil })
.where.not(domain: [nil, ''])
.distinct
.pluck(:account_id)
end

View File

@@ -1,12 +0,0 @@
namespace :companies do
desc 'Backfill companies from existing contact email domains'
task backfill: :environment do
puts 'Starting company backfill migration...'
puts 'This will process all accounts and create companies from contact email domains.'
puts 'The job will run in the background via Sidekiq'
puts ''
Migration::CompanyBackfillJob.perform_later
puts 'Company backfill job has been enqueued.'
puts 'Monitor progress in logs or Sidekiq dashboard.'
end
end

View File

@@ -16,7 +16,9 @@ describe V2::ReportBuilder do
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
favicon_url = 'https://www.google.com/s2/favicons'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
stub_request(:get, /#{Regexp.escape(favicon_url)}.*/).to_return(status: 404)
perform_enqueued_jobs do
10.times do

View File

@@ -18,6 +18,11 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
end
let(:builder) { described_class.new(account: account, params: params) }
def stub_avatar_requests
stub_request(:get, %r{\Ahttps://www\.gravatar\.com.*}).to_return(status: 404)
stub_request(:get, %r{\Ahttps://www\.google\.com/s2/favicons.*}).to_return(status: 404)
end
describe '#initialize' do
let(:business_hours) { false }
@@ -85,8 +90,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
stub_avatar_requests
perform_enqueued_jobs do
# Create conversations with label_1
@@ -223,8 +227,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
stub_avatar_requests
perform_enqueued_jobs do
# Conversation within range
@@ -281,8 +284,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
stub_avatar_requests
perform_enqueued_jobs do
conversation = create(:conversation, account: account,
@@ -338,8 +340,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
inbox = create(:inbox, account: account2)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
stub_avatar_requests
perform_enqueued_jobs do
conversation = create(:conversation, account: account2,
@@ -349,13 +350,8 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do
conversation.label_list
conversation.save!
# First resolution
conversation.resolved!
# Reopen conversation
conversation.open!
# Second resolution
conversation.resolved!
end
end

View File

@@ -0,0 +1,27 @@
require 'rails_helper'
RSpec.describe Avatar::AvatarFromFaviconJob do
let(:company) { create(:company, domain: 'wikipedia.org') }
let(:favicon_url) { 'https://www.google.com/s2/favicons?domain=wikipedia.org&sz=256' }
it 'calls AvatarFromUrlJob with Google Favicon URL' do
expect(Avatar::AvatarFromUrlJob).to receive(:perform_now).with(company, favicon_url)
described_class.perform_now(company)
end
it 'does not call AvatarFromUrlJob when domain is blank' do
company.update(domain: '')
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_now)
described_class.perform_now(company)
end
it 'does not call AvatarFromUrlJob when avatar is already attached' do
company.avatar.attach(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_now)
described_class.perform_now(company)
end
end

View File

@@ -0,0 +1,25 @@
require 'rails_helper'
RSpec.describe Companies::FetchAvatarsJob do
let(:account) { create(:account) }
let!(:company_with_avatar) { create(:company, account: account, domain: 'example.com') }
let!(:company_without_avatar) { create(:company, account: account, domain: 'wikipedia.org') }
let!(:company_no_domain) { create(:company, account: account, domain: nil) }
before do
# Attach avatar to first company
company_with_avatar.avatar.attach(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
end
it 'queues Avatar::AvatarFromFaviconJob only for companies without avatars' do
expect(Avatar::AvatarFromFaviconJob).to receive(:perform_later).with(company_without_avatar).once
expect(Avatar::AvatarFromFaviconJob).not_to receive(:perform_later).with(company_with_avatar)
expect(Avatar::AvatarFromFaviconJob).not_to receive(:perform_later).with(company_no_domain)
described_class.perform_now(account.id)
end
end

View File

@@ -6,9 +6,14 @@ RSpec.describe Conversation, type: :model do
end
describe 'SLA policy updates' do
let!(:conversation) { create(:conversation) }
let(:conversation) { create(:conversation) }
let!(:sla_policy) { create(:sla_policy, account: conversation.account) }
before do
stub_request(:get, %r{\Ahttps://www\.gravatar\.com.*}).to_return(status: 404)
stub_request(:get, %r{\Ahttps://www\.google\.com/s2/favicons.*}).to_return(status: 404)
end
it 'generates an activity message when the SLA policy is updated' do
conversation.update!(sla_policy_id: sla_policy.id)

View File

@@ -20,7 +20,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
it 'raises InvalidEmail with invalid message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.invalid_email'))
end
end
@@ -32,7 +32,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
it 'raises InvalidEmail with blocked domain message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
end
end
@@ -44,7 +44,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
it 'raises InvalidEmail with blocked domain message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
end
end
@@ -56,7 +56,7 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
it 'raises InvalidEmail with disposable message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.disposable_email'))
end
end