feat: add email delivery checks to the super admin panel (#15989)

## Description

Adds an email delivery diagnostic to the super admin user page.

When an address hard-bounces or gets a spam complaint, SES adds it to
the account-level suppression list and silently drops every later email
to it. The app still logs the send, so nobody sees a failure. This adds
an **Email** menu on the super admin user page with:

- **Check email delivery**: looks up the address on the SES suppression
list and shows whether it is blocked, why (bounce or spam complaint) and
since when.
- **Unblock email**: removes a bounce entry after an in-app
confirmation. Disabled for spam complaints, which need engineering.
- **Send test email**: sends a plain-text email through the regular
mailer path to confirm delivery end to end.
- **Resend confirmation email**: the existing action, moved into the
menu for unconfirmed users.

Unblock and test email write a structured log line with the super admin
and target user. Results show as a toast under the header actions on
this page, so the page does not shift.

The feature is inert unless `SES_SUPPRESSION_ROLE_ARN` is set: the
actions return 404 and the page keeps the existing standalone resend
confirmation button. SES calls assume that role from the instance
profile, so the storage keys are never used. `SES_SUPPRESSION_REGION`
overrides the default `us-east-1`. Adds the `aws-sdk-sesv2` gem.


<img width="784" height="390" alt="image"
src="https://github.com/user-attachments/assets/65f87198-1bb7-4523-8d74-94d12e4e3003"
/>


Fixes https://linear.app/chatwoot/issue/CW-7953

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Service, mailer and super admin controller specs, with SES stubbed
through the SDK.
- Ran locally with SES responses mocked and checked each state (not
blocked, bounce, spam complaint, unblock, test email, unconfirmed user)
in the browser.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Vishnu Narayanan
2026-09-24 18:59:58 -07:00
committed by GitHub
parent f052f667c4
commit 2a65bede33
14 changed files with 797 additions and 10 deletions

View File

@@ -54,6 +54,7 @@ gem 'csv-safe'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-sesv2', require: false
gem 'azure-blob', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'

View File

@@ -134,6 +134,9 @@ GEM
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-sesv2 (1.92.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
@@ -1066,6 +1069,7 @@ DEPENDENCIES
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
aws-sdk-s3
aws-sdk-sesv2
azure-blob
barnes
bootsnap

View File

@@ -1,4 +1,7 @@
class SuperAdmin::UsersController < SuperAdmin::ApplicationController
SUPPRESSION_FLASH_TYPES = { not_suppressed: :notice, bounce: :alert, complaint: :error, unavailable: :alert }.freeze
before_action :ensure_ses_suppression_configured, only: [:check_email_suppression, :clear_email_suppression, :send_test_email]
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
@@ -57,11 +60,47 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
if user.confirmed?
redirect_back(fallback_location: super_admin_user_path(user), alert: I18n.t('super_admin.users.resend_confirmation.already_confirmed'))
else
return if redirect_if_email_blocked(user)
user.send_confirmation_instructions
redirect_back(fallback_location: super_admin_user_path(user), notice: I18n.t('super_admin.users.resend_confirmation.sent'))
end
end
def check_email_suppression
user = requested_resource
redirect_with_suppression_result(user, Email::SesSuppressionService.new.lookup(user.email))
end
def clear_email_suppression
user = requested_resource
service = Email::SesSuppressionService.new
result = service.lookup(user.email)
return redirect_with_suppression_result(user, result) unless result[:status] == :bounce
begin
service.clear!(user.email)
rescue StandardError => e
message = I18n.t('super_admin.users.email_suppression.clear.failed',
email: ERB::Util.html_escape(user.email), error: ERB::Util.html_escape(e.message))
return redirect_to super_admin_user_path(user), flash: { error: message }
end
log_email_diagnostic('ses_suppression_cleared', user)
redirect_to super_admin_user_path(user),
notice: I18n.t('super_admin.users.email_suppression.clear.success', email: ERB::Util.html_escape(user.email))
end
def send_test_email
user = requested_resource
return if redirect_if_email_blocked(user)
EmailDeliveryTestMailer.delivery_test(user.email, user.name).deliver_later
log_email_diagnostic('ses_test_email_sent', user)
redirect_to super_admin_user_path(user),
notice: I18n.t('super_admin.users.email_suppression.test_email.queued', email: ERB::Util.html_escape(user.email))
end
def scoped_resource
resource_class.with_attached_avatar
end
@@ -77,4 +116,43 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
def find_resource(param)
super.becomes(User)
end
private
def redirect_if_email_blocked(user)
return false unless Email::SesSuppressionService.configured?
result = Email::SesSuppressionService.new.lookup(user.email)
return false unless result[:status].in?(%i[bounce complaint])
redirect_with_suppression_result(user, result)
true
end
def redirect_with_suppression_result(user, result)
message = I18n.t("super_admin.users.email_suppression.check.#{result[:status]}",
email: ERB::Util.html_escape(user.email), since: suppressed_since(result[:since]))
redirect_to super_admin_user_path(user, suppression: result[:status]), flash: { SUPPRESSION_FLASH_TYPES.fetch(result[:status]) => message }
end
def suppressed_since(time)
return if time.blank?
"#{time.utc.strftime('%-d %b %Y')} (#{helpers.time_ago_in_words(time)} ago)"
end
def ensure_ses_suppression_configured
head :not_found unless Email::SesSuppressionService.configured?
end
def log_email_diagnostic(event, user)
Rails.logger.info({
event: event,
super_admin_id: current_super_admin.id,
super_admin_email: current_super_admin.email,
user_id: user.id,
email: user.email,
at: Time.current.utc.iso8601
}.to_json)
end
end

View File

@@ -36,3 +36,69 @@ const initializeAccountSuspensionForm = () => {
};
document.addEventListener('DOMContentLoaded', initializeAccountSuspensionForm);
const SPINNER_SVG =
'<svg class="animate-spin shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>';
const initializePendingActions = () => {
document.addEventListener('submit', event => {
const button = event.submitter;
if (!button?.dataset.pendingLabel) return;
setTimeout(() => {
if (event.defaultPrevented) return;
const label = document.createElement('span');
label.textContent = button.dataset.pendingLabel;
button.innerHTML = SPINNER_SVG;
button.append(label);
button.classList.add('flex', 'items-center', 'gap-2');
button.disabled = true;
button.setAttribute('aria-busy', 'true');
});
});
};
document.addEventListener('DOMContentLoaded', initializePendingActions);
const closeDropdowns = except => {
document.querySelectorAll('details[data-dropdown][open]').forEach(menu => {
if (menu !== except) menu.removeAttribute('open');
});
};
document.addEventListener('click', event => {
const trigger = event.target.closest('[data-dialog-open]');
if (trigger) {
closeDropdowns();
document.getElementById(trigger.dataset.dialogOpen)?.showModal();
return;
}
closeDropdowns(event.target.closest('details[data-dropdown]'));
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') closeDropdowns();
});
document.addEventListener('DOMContentLoaded', () => {
const url = new URL(window.location.href);
if (!url.searchParams.has('suppression')) return;
url.searchParams.delete('suppression');
window.history.replaceState(window.history.state, '', url);
});
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-toast-flashes] .flash').forEach(toast => {
let timer;
const hide = () => {
toast.classList.add('opacity-0');
setTimeout(() => toast.classList.add('invisible'), 300);
};
const schedule = () => {
timer = setTimeout(hide, 8000);
};
toast.addEventListener('mouseenter', () => clearTimeout(timer));
toast.addEventListener('mouseleave', schedule);
schedule();
});
});

View File

@@ -0,0 +1,18 @@
class EmailDeliveryTestMailer < ApplicationMailer
def delivery_test(email, name)
return unless smtp_config_set_or_development?
@recipient_name = name.presence || email
send_mail_with_liquid(to: email, subject: I18n.t('super_admin.users.email_suppression.test_email.subject', brand_name: brand_name))
end
private
def brand_name
GlobalConfig.get_value('BRAND_NAME').presence || 'Chatwoot'
end
def liquid_locals
super.merge(brand_name: brand_name, recipient_name: @recipient_name)
end
end

View File

@@ -0,0 +1,43 @@
require 'aws-sdk-sesv2'
class Email::SesSuppressionService
def self.configured?
ENV['SES_SUPPRESSION_ROLE_ARN'].present?
end
def initialize(client: nil)
@client = client
end
def lookup(email)
destination = client.get_suppressed_destination(email_address: email).suppressed_destination
{ status: destination.reason.downcase.to_sym, since: destination.last_update_time }
rescue Aws::SESV2::Errors::NotFoundException
{ status: :not_suppressed }
rescue StandardError => e
Rails.logger.warn("[SesSuppression] lookup failed: #{e.class} #{e.message}")
{ status: :unavailable }
end
def clear!(email)
client.delete_suppressed_destination(email_address: email)
end
private
# Pinned to the instance role: the default chain would pick up the S3-scoped AWS_ACCESS_KEY_ID.
def client
@client ||= Aws::SESV2::Client.new(
**aws_options,
credentials: Aws::AssumeRoleCredentials.new(
client: Aws::STS::Client.new(**aws_options, credentials: Aws::InstanceProfileCredentials.new),
role_arn: ENV.fetch('SES_SUPPRESSION_ROLE_ARN'),
role_session_name: 'chatwoot-superadmin-ses'
)
)
end
def aws_options
{ region: ENV['SES_SUPPRESSION_REGION'].presence || 'us-east-1', http_open_timeout: 3, http_read_timeout: 3 }
end
end

View File

@@ -0,0 +1,20 @@
<tr>
<td style="padding: 0 0 12px;">
<h1 style="margin: 0; font-size: 32px; line-height: 38px; font-weight: 700; color: #0F172A;">Test email from {{brand_name}}</h1>
</td>
</tr>
<tr>
<td style="padding: 0 0 8px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #334155;">Hi {{recipient_name | escape}},</p>
</td>
</tr>
<tr>
<td style="padding: 0 0 16px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;">Our support team sent this to check that emails from {{brand_name}} reach your inbox. It arrived, so you're all set. No action is needed.</p>
</td>
</tr>
<tr>
<td style="padding: 8px 0 0; border-top: 1px solid #E2E8F0;">
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #64748B;">You're receiving this because a {{brand_name}} support agent sent a test email to this address.</p>
</td>
</tr>

View File

@@ -12,9 +12,10 @@ This partial renders flash messages on every page.
%>
<% if flash.any? %>
<div class="flashes">
<% toast = content_for?(:toast_flashes) %>
<div class="flashes <%= "fixed top-[4.5rem] end-6 z-50 w-[calc(100%-2rem)] max-w-[26rem] pointer-events-none" if toast %>" <%= "data-toast-flashes" if toast %>>
<% flash.each do |key, value| -%>
<div class="flash flash-<%= key %>"><%= value.to_s.html_safe %></div>
<div class="flash flash-<%= key %> <%= "pointer-events-auto rounded shadow-lg !text-start transition-opacity duration-300" if toast %>"><%= value.to_s.html_safe %></div>
<% end -%>
</div>
<% end %>

View File

@@ -17,25 +17,118 @@ as well as a link to its edit page.
%>
<% content_for(:title) { t("administrate.actions.show_resource", name: page.page_title) } %>
<% content_for(:toast_flashes, "true") %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title">
<%= content_for(:title) %>
</h1>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center justify-end gap-2">
<%= link_to(
t("administrate.actions.edit"),
[:edit, namespace, page.resource.becomes(User)],
class: "button",
) if authorized_action? page.resource, :edit %>
<%= button_to(
t("super_admin.users.resend_confirmation.button"),
[:resend_confirmation, namespace, page.resource.becomes(User)],
method: :post,
class: "button",
form_class: "button_to contents",
) unless page.resource.confirmed? %>
<% if Email::SesSuppressionService.configured? %>
<% item_class = "reset-base w-full text-start px-3 py-2 text-sm text-slate-800 cursor-pointer hover:bg-slate-50 active:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent aria-busy:opacity-100 aria-busy:cursor-wait aria-busy:bg-slate-50" %>
<details class="relative" data-dropdown>
<summary class="button list-none [&::-webkit-details-marker]:hidden">
<%= t("super_admin.users.email_suppression.menu") %>
<svg class="ms-1 w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</summary>
<div class="absolute end-0 top-full z-[60] mt-1 min-w-56 py-1 bg-white border border-slate-100 rounded-lg shadow-lg">
<%= button_to(
t("super_admin.users.email_suppression.check_button"),
[:check_email_suppression, namespace, page.resource.becomes(User)],
method: :post,
class: item_class,
form_class: "block",
data: { pending_label: t("super_admin.users.email_suppression.pending.check") },
) %>
<% if params[:suppression] == "bounce" %>
<button type="button" class="<%= item_class %>" data-dialog-open="unblock-email-dialog">
<%= t("super_admin.users.email_suppression.clear_button") %>
</button>
<% else %>
<% unblock_hint = case params[:suppression]
when "complaint" then t("super_admin.users.email_suppression.complaint_hint")
when "not_suppressed" then t("super_admin.users.email_suppression.not_blocked_hint")
else t("super_admin.users.email_suppression.check_first_hint")
end %>
<span class="block cursor-not-allowed" title="<%= unblock_hint %>">
<button type="button" class="<%= item_class %> pointer-events-none" disabled>
<%= t("super_admin.users.email_suppression.clear_button") %>
</button>
</span>
<% end %>
<% if params[:suppression].in?(%w[bounce complaint]) %>
<span class="block cursor-not-allowed" title="<%= t("super_admin.users.email_suppression.test_email_blocked_hint.#{params[:suppression]}") %>">
<button type="button" class="<%= item_class %> pointer-events-none" disabled>
<%= t("super_admin.users.email_suppression.test_email_button") %>
</button>
</span>
<% else %>
<%= button_to(
t("super_admin.users.email_suppression.test_email_button"),
[:send_test_email, namespace, page.resource.becomes(User)],
method: :post,
class: item_class,
form_class: "block",
data: { pending_label: t("super_admin.users.email_suppression.pending.send") },
) %>
<% end %>
<% unless page.resource.confirmed? %>
<% if params[:suppression].in?(%w[bounce complaint]) %>
<span class="block cursor-not-allowed" title="<%= t("super_admin.users.email_suppression.test_email_blocked_hint.#{params[:suppression]}") %>">
<button type="button" class="<%= item_class %> pointer-events-none" disabled>
<%= t("super_admin.users.resend_confirmation.button") %>
</button>
</span>
<% else %>
<%= button_to(
t("super_admin.users.resend_confirmation.button"),
[:resend_confirmation, namespace, page.resource.becomes(User)],
method: :post,
class: item_class,
form_class: "block",
data: { pending_label: t("super_admin.users.email_suppression.pending.send") },
) %>
<% end %>
<% end %>
</div>
</details>
<% if params[:suppression] == "bounce" %>
<dialog id="unblock-email-dialog" class="w-full max-w-md p-6 rounded-lg border border-slate-100 shadow-xl backdrop:bg-black/40">
<h2 class="text-base font-medium text-slate-900">
<%= t("super_admin.users.email_suppression.clear_dialog.title", email: page.resource.email) %>
</h2>
<p class="mt-2 text-sm text-slate-600"><%= t("super_admin.users.email_suppression.clear_dialog.body", brand_name: GlobalConfig.get_value("BRAND_NAME").presence || "Chatwoot") %></p>
<div class="mt-6 flex justify-end gap-2">
<form method="dialog">
<button class="reset-base px-4 py-2 text-sm font-medium text-slate-700 border border-slate-200 rounded-md cursor-pointer hover:bg-slate-50">
<%= t("super_admin.users.email_suppression.clear_dialog.cancel") %>
</button>
</form>
<%= button_to(
t("super_admin.users.email_suppression.clear_dialog.confirm"),
[:clear_email_suppression, namespace, page.resource.becomes(User)],
method: :post,
class: "button",
data: { pending_label: t("super_admin.users.email_suppression.pending.clear") },
) %>
</div>
</dialog>
<% end %>
<% else %>
<%= button_to(
t("super_admin.users.resend_confirmation.button"),
[:resend_confirmation, namespace, page.resource.becomes(User)],
method: :post,
class: "button",
form_class: "button_to contents",
) unless page.resource.confirmed? %>
<% end %>
</div>
</header>

View File

@@ -706,3 +706,34 @@ en:
button: 'Resend confirmation email'
sent: 'Confirmation email sent.'
already_confirmed: 'User is already confirmed.'
email_suppression:
menu: 'Email'
check_button: 'Check email delivery'
clear_button: 'Unblock email'
clear_dialog:
title: 'Unblock %{email}?'
body: '%{brand_name} will send emails to this address again. If the next one bounces, it gets blocked again.'
cancel: 'Cancel'
confirm: 'Unblock'
complaint_hint: 'Blocked after a spam complaint. Escalate to engineering.'
check_first_hint: 'Check email delivery first.'
not_blocked_hint: 'Emails to this address are not blocked.'
test_email_blocked_hint:
bounce: 'Unblock the address first. Emails to blocked addresses are dropped.'
complaint: 'Blocked after a spam complaint. Emails to this address are dropped.'
test_email_button: 'Send test email'
pending:
check: 'Checking…'
clear: 'Unblocking…'
send: 'Sending…'
check:
not_suppressed: 'Emails to %{email} are not blocked.'
bounce: 'Emails to %{email} have been blocked since %{since} because the address bounced. Unblock it, then ask the user to try again.'
complaint: 'Emails to %{email} have been blocked since %{since} because of a spam complaint. Escalate to engineering.'
unavailable: "Couldn't check delivery for %{email}. Try again, or escalate to engineering."
clear:
success: 'Unblocked %{email}. Ask the user to try again, or send a test email to check delivery.'
failed: "Couldn't unblock %{email} (%{error}). Escalate to engineering."
test_email:
queued: 'Test email on its way to %{email}. Ask the user to check their inbox and spam folder.'
subject: 'Test email from %{brand_name} support'

View File

@@ -761,6 +761,9 @@ Rails.application.routes.draw do
resources :users, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
delete :avatar, on: :member, action: :destroy_avatar
post :resend_confirmation, on: :member
post :check_email_suppression, on: :member
post :clear_email_suppression, on: :member
post :send_test_email, on: :member
end
resources :access_tokens, only: [:index, :show]

View File

@@ -217,4 +217,289 @@ RSpec.describe 'Super Admin Users API', type: :request do
expect(response.body).to include(CGI.escapeHTML(user.name))
end
end
describe 'SES suppression diagnostic' do
let!(:user) { create(:user, email: 'bounced@example.com') }
let(:suppression) { instance_double(Email::SesSuppressionService) }
before do
sign_in(super_admin, scope: :super_admin)
allow(Email::SesSuppressionService).to receive(:new).and_return(suppression)
end
context 'when SES suppression is not configured' do
it 'returns not found for every action' do
%w[check_email_suppression clear_email_suppression send_test_email].each do |action|
post "/super_admin/users/#{user.id}/#{action}"
expect(response).to have_http_status(:not_found)
end
end
it 'hides the diagnostic buttons' do
get "/super_admin/users/#{user.id}"
expect(response.body).not_to include('Check email delivery')
expect(response.body).not_to include('Send test email')
end
it 'keeps resend confirmation as a standalone button for unconfirmed users' do
unconfirmed = create(:user, skip_confirmation: false)
get "/super_admin/users/#{unconfirmed.id}"
doc = Nokogiri::HTML(response.body)
expect(doc.at_css('.main-content__header details')).to be_nil
expect(doc.at_css('button:contains("Resend confirmation email")')).to be_present
end
end
context 'when SES suppression is configured' do
around do |example|
with_modified_env(SES_SUPPRESSION_ROLE_ARN: 'arn:aws:iam::123456789012:role/test') { example.run }
end
it 'shows a disabled unblock with a hint before a check' do
get "/super_admin/users/#{user.id}"
doc = Nokogiri::HTML(response.body)
button = doc.at_css('.main-content__header button:contains("Unblock email")')
expect(button['disabled']).to be_present
expect(button.parent['title']).to eq('Check email delivery first.')
expect(response.body).to include('Check email delivery')
expect(response.body).to include('Send test email')
end
it 'moves resend confirmation into the email menu for unconfirmed users' do
unconfirmed = create(:user, skip_confirmation: false)
get "/super_admin/users/#{unconfirmed.id}"
expect(Nokogiri::HTML(response.body).at_css('.main-content__header details button:contains("Resend confirmation email")')).to be_present
end
it 'shows an active clear button after a bounce check' do
get "/super_admin/users/#{user.id}", params: { suppression: 'bounce' }
button = Nokogiri::HTML(response.body).at_css('button:contains("Unblock email")')
expect(button).to be_present
expect(button['disabled']).to be_nil
end
it 'uses the installation brand in the unblock dialog' do
allow(GlobalConfig).to receive(:get_value).and_call_original
allow(GlobalConfig).to receive(:get_value).with('BRAND_NAME').and_return('Acme')
get "/super_admin/users/#{user.id}", params: { suppression: 'bounce' }
expect(Nokogiri::HTML(response.body).at_css('dialog#unblock-email-dialog').text).to include('Acme will send emails')
end
it 'confirms the unblock in an in-app dialog' do
get "/super_admin/users/#{user.id}", params: { suppression: 'bounce' }
dialog = Nokogiri::HTML(response.body).at_css('dialog#unblock-email-dialog')
expect(dialog.text).to include('Unblock bounced@example.com?')
expect(dialog.text).to include('Chatwoot will send emails to this address again.')
expect(dialog.at_css("form[action='/super_admin/users/#{user.id}/clear_email_suppression']")).to be_present
end
it 'disables resend confirmation while the address is blocked' do
unconfirmed = create(:user, skip_confirmation: false)
get "/super_admin/users/#{unconfirmed.id}", params: { suppression: 'bounce' }
button = Nokogiri::HTML(response.body).at_css('.main-content__header button:contains("Resend confirmation email")')
expect(button['disabled']).to be_present
end
it 'refuses to resend confirmation to a blocked address' do
unconfirmed = create(:user, skip_confirmation: false)
allow(suppression).to receive(:lookup).with(unconfirmed.email).and_return(status: :bounce, since: 1.day.ago)
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
post "/super_admin/users/#{unconfirmed.id}/resend_confirmation"
expect(ActiveJob::Base.queue_adapter.enqueued_jobs.count { |job| job[:job].to_s == 'ActionMailer::MailDeliveryJob' }).to eq(0)
expect(flash[:alert]).to include('because the address bounced')
end
it 'resends confirmation when the address is not blocked' do
unconfirmed = create(:user, skip_confirmation: false)
allow(suppression).to receive(:lookup).with(unconfirmed.email).and_return(status: :not_suppressed)
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
post "/super_admin/users/#{unconfirmed.id}/resend_confirmation"
expect(ActiveJob::Base.queue_adapter.enqueued_jobs.count { |job| job[:job].to_s == 'ActionMailer::MailDeliveryJob' }).to be >= 1
end
it 'disables the test email while the address is blocked' do
get "/super_admin/users/#{user.id}", params: { suppression: 'complaint' }
button = Nokogiri::HTML(response.body).at_css('.main-content__header button:contains("Send test email")')
expect(button['disabled']).to be_present
expect(button.parent['title']).to eq('Blocked after a spam complaint. Emails to this address are dropped.')
end
it 'keeps the test email available when the address is not blocked' do
get "/super_admin/users/#{user.id}", params: { suppression: 'not_suppressed' }
button = Nokogiri::HTML(response.body).at_css('.main-content__header button:contains("Send test email")')
expect(button['disabled']).to be_nil
end
it 'explains why unblock is disabled when the address is not blocked' do
get "/super_admin/users/#{user.id}", params: { suppression: 'not_suppressed' }
button = Nokogiri::HTML(response.body).at_css('.main-content__header button:contains("Unblock email")')
expect(button['disabled']).to be_present
expect(button.parent['title']).to eq('Emails to this address are not blocked.')
end
it 'shows a disabled clear button after a complaint check' do
get "/super_admin/users/#{user.id}", params: { suppression: 'complaint' }
button = Nokogiri::HTML(response.body).at_css('button:contains("Unblock email")')
expect(button['disabled']).to be_present
expect(button.parent['title']).to eq('Blocked after a spam complaint. Escalate to engineering.')
end
it 'reports an address that is not suppressed' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :not_suppressed)
post "/super_admin/users/#{user.id}/check_email_suppression"
expect(response).to redirect_to("/super_admin/users/#{user.id}?suppression=not_suppressed")
expect(flash[:notice]).to eq('Emails to bounced@example.com are not blocked.')
end
it 'shows the result as a toast on the user page only' do
allow(suppression).to receive(:lookup).and_return(status: :not_suppressed)
post "/super_admin/users/#{user.id}/check_email_suppression"
follow_redirect!
expect(Nokogiri::HTML(response.body).at_css('.flashes[data-toast-flashes]')).to be_present
post '/super_admin/users', params: { user: { email: '' } }
follow_redirect!
flashes = Nokogiri::HTML(response.body).at_css('.flashes')
expect(flashes).to be_present
expect(flashes.key?('data-toast-flashes')).to be(false)
end
it 'reports a bounce with its date' do
travel_to Time.utc(2026, 9, 6, 10, 0, 0)
since = Time.utc(2026, 9, 1, 10, 0, 0)
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :bounce, since: since)
post "/super_admin/users/#{user.id}/check_email_suppression"
expect(response).to redirect_to("/super_admin/users/#{user.id}?suppression=bounce")
expect(flash[:alert]).to eq('Emails to bounced@example.com have been blocked since 1 Sep 2026 (5 days ago) because the address bounced. ' \
'Unblock it, then ask the user to try again.')
end
it 'reports a complaint as an error with an escalation note' do
travel_to Time.utc(2026, 9, 6, 10, 0, 0)
since = Time.utc(2026, 9, 1, 10, 0, 0)
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :complaint, since: since)
post "/super_admin/users/#{user.id}/check_email_suppression"
expect(response).to redirect_to("/super_admin/users/#{user.id}?suppression=complaint")
expect(flash[:error]).to eq('Emails to bounced@example.com have been blocked since 1 Sep 2026 (5 days ago) because of a spam complaint. ' \
'Escalate to engineering.')
end
it 'reports a failed lookup' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :unavailable)
post "/super_admin/users/#{user.id}/check_email_suppression"
expect(response).to redirect_to("/super_admin/users/#{user.id}?suppression=unavailable")
expect(flash[:alert]).to eq("Couldn't check delivery for bounced@example.com. Try again, or escalate to engineering.")
end
it 'clears the suppression and logs who did it' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :bounce, since: 1.day.ago)
allow(suppression).to receive(:clear!)
allow(Rails.logger).to receive(:info)
post "/super_admin/users/#{user.id}/clear_email_suppression"
expect(suppression).to have_received(:clear!).with(user.email)
expect(Rails.logger).to have_received(:info).with(a_string_including('ses_suppression_cleared', super_admin.email, user.email))
expect(flash[:notice]).to eq('Unblocked bounced@example.com. Ask the user to try again, or send a test email to check delivery.')
end
it 'refuses to clear a complaint suppression' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :complaint, since: 1.day.ago)
allow(suppression).to receive(:clear!)
post "/super_admin/users/#{user.id}/clear_email_suppression"
expect(suppression).not_to have_received(:clear!)
expect(flash[:error]).to include('because of a spam complaint. Escalate to engineering.')
end
it 'reports an address that is no longer blocked without clearing it' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :not_suppressed)
allow(suppression).to receive(:clear!)
post "/super_admin/users/#{user.id}/clear_email_suppression"
expect(suppression).not_to have_received(:clear!)
expect(flash[:notice]).to eq('Emails to bounced@example.com are not blocked.')
end
it 'does not clear when the lookup fails' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :unavailable)
allow(suppression).to receive(:clear!)
post "/super_admin/users/#{user.id}/clear_email_suppression"
expect(suppression).not_to have_received(:clear!)
expect(flash[:alert]).to eq("Couldn't check delivery for bounced@example.com. Try again, or escalate to engineering.")
end
it 'reports a failed clear' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :bounce, since: 1.day.ago)
allow(suppression).to receive(:clear!).and_raise(StandardError, 'boom')
post "/super_admin/users/#{user.id}/clear_email_suppression"
expect(flash[:error]).to eq("Couldn't unblock bounced@example.com (boom). Escalate to engineering.")
end
it 'does not queue a test email to a blocked address' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :complaint, since: 1.day.ago)
expect do
post "/super_admin/users/#{user.id}/send_test_email"
end.not_to have_enqueued_mail(EmailDeliveryTestMailer, :delivery_test)
expect(flash[:error]).to include('because of a spam complaint')
end
it 'still queues the test email when the lookup fails' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :unavailable)
expect do
post "/super_admin/users/#{user.id}/send_test_email"
end.to have_enqueued_mail(EmailDeliveryTestMailer, :delivery_test)
end
it 'queues a test email and logs who sent it' do
allow(suppression).to receive(:lookup).with(user.email).and_return(status: :not_suppressed)
allow(Rails.logger).to receive(:info)
expect do
post "/super_admin/users/#{user.id}/send_test_email"
end.to have_enqueued_mail(EmailDeliveryTestMailer, :delivery_test).with(user.email, user.name)
expect(Rails.logger).to have_received(:info).with(a_string_including('ses_test_email_sent', super_admin.email, user.email))
expect(flash[:notice]).to eq('Test email on its way to bounced@example.com. Ask the user to check their inbox and spam folder.')
end
end
end
end

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe EmailDeliveryTestMailer do
include_context 'with smtp config'
let(:mail) { described_class.delivery_test('agent@example.com', 'Jane Agent').message }
it 'sends a branded test email to the user' do
expect(mail.to).to eq(['agent@example.com'])
expect(mail.from).to eq([Mail::Address.new(ApplicationMailer.default[:from]).address])
expect(mail.subject).to eq('Test email from Chatwoot support')
expect(mail.body.encoded).to include('Hi Jane Agent,')
expect(mail.body.encoded).to include('check that emails from Chatwoot reach your inbox')
end
it 'uses the installation brand name' do
allow(GlobalConfig).to receive(:get_value).and_call_original
allow(GlobalConfig).to receive(:get_value).with('BRAND_NAME').and_return('Acme')
expect(mail.subject).to eq('Test email from Acme support')
expect(mail.body.encoded).to include('Test email from Acme')
end
it 'greets by address when the name is blank' do
expect(described_class.delivery_test('agent@example.com', '').message.body.encoded).to include('Hi agent@example.com,')
end
it 'does not send when SMTP is not configured' do
with_modified_env('SMTP_ADDRESS' => nil) do
expect(mail).to be_a(ActionMailer::Base::NullMail)
end
end
end

View File

@@ -0,0 +1,110 @@
require 'rails_helper'
describe Email::SesSuppressionService do
subject(:service) { described_class.new(client: client) }
let(:client) { Aws::SESV2::Client.new(stub_responses: true) }
let(:email) { 'bounced@example.com' }
describe '.configured?' do
it 'is true when the role ARN is set' do
with_modified_env SES_SUPPRESSION_ROLE_ARN: 'arn:aws:iam::123456789012:role/test' do
expect(described_class.configured?).to be(true)
end
end
it 'is false when the role ARN is not set' do
with_modified_env SES_SUPPRESSION_ROLE_ARN: nil do
expect(described_class.configured?).to be(false)
end
end
end
describe 'region' do
before do
stubbed_client = client
allow(Aws::InstanceProfileCredentials).to receive(:new)
allow(Aws::AssumeRoleCredentials).to receive(:new)
allow(Aws::SESV2::Client).to receive(:new).and_return(stubbed_client)
end
around do |example|
with_modified_env(SES_SUPPRESSION_ROLE_ARN: 'arn:aws:iam::123456789012:role/test', SES_SUPPRESSION_REGION: region) { example.run }
end
context 'when SES_SUPPRESSION_REGION is set' do
let(:region) { 'eu-west-1' }
it 'queries the suppression list in that region' do
described_class.new.lookup(email)
expect(Aws::SESV2::Client).to have_received(:new).with(hash_including(region: 'eu-west-1'))
end
end
context 'when SES_SUPPRESSION_REGION is not set' do
let(:region) { nil }
it 'defaults to us-east-1' do
described_class.new.lookup(email)
expect(Aws::SESV2::Client).to have_received(:new).with(hash_including(region: 'us-east-1'))
end
end
end
describe '#lookup' do
it 'returns not_suppressed when SES has no entry' do
client.stub_responses(:get_suppressed_destination, 'NotFoundException')
expect(service.lookup(email)).to eq(status: :not_suppressed)
end
it 'returns bounce with the suppression time' do
since = Time.utc(2026, 9, 1, 10, 0, 0)
client.stub_responses(:get_suppressed_destination, {
suppressed_destination: { email_address: email, reason: 'BOUNCE', last_update_time: since }
})
expect(service.lookup(email)).to eq(status: :bounce, since: since)
end
it 'returns complaint with the suppression time' do
since = Time.utc(2026, 9, 2, 10, 0, 0)
client.stub_responses(:get_suppressed_destination, {
suppressed_destination: { email_address: email, reason: 'COMPLAINT', last_update_time: since }
})
expect(service.lookup(email)).to eq(status: :complaint, since: since)
end
it 'returns unavailable when SES denies access' do
client.stub_responses(:get_suppressed_destination, 'AccessDeniedException')
expect(service.lookup(email)).to eq(status: :unavailable)
end
it 'returns unavailable on a network timeout' do
client.stub_responses(:get_suppressed_destination, Seahorse::Client::NetworkingError.new(Net::ReadTimeout.new))
expect(service.lookup(email)).to eq(status: :unavailable)
end
end
describe '#clear!' do
it 'deletes the suppressed destination' do
client.stub_responses(:delete_suppressed_destination, {})
service.clear!(email)
expect(client.api_requests.last).to include(operation_name: :delete_suppressed_destination,
params: { email_address: email })
end
it 'raises when SES rejects the delete' do
client.stub_responses(:delete_suppressed_destination, 'NotFoundException')
expect { service.clear!(email) }.to raise_error(Aws::SESV2::Errors::NotFoundException)
end
end
end