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:
1
Gemfile
1
Gemfile
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
18
app/mailers/email_delivery_test_mailer.rb
Normal file
18
app/mailers/email_delivery_test_mailer.rb
Normal 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
|
||||
43
app/services/email/ses_suppression_service.rb
Normal file
43
app/services/email/ses_suppression_service.rb
Normal 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
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
34
spec/mailers/email_delivery_test_mailer_spec.rb
Normal file
34
spec/mailers/email_delivery_test_mailer_spec.rb
Normal 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
|
||||
110
spec/services/email/ses_suppression_service_spec.rb
Normal file
110
spec/services/email/ses_suppression_service_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user