diff --git a/app/controllers/admin/announcements/distributions_controller.rb b/app/controllers/admin/announcements/distributions_controller.rb
new file mode 100644
index 0000000000..138c700e78
--- /dev/null
+++ b/app/controllers/admin/announcements/distributions_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Admin::Announcements::DistributionsController < Admin::BaseController
+ before_action :set_announcement
+
+ def create
+ authorize @announcement, :distribute?
+ @terms_of_service.touch(:notification_sent_at)
+ Admin::DistributeAnnouncementNotificationWorker.perform_async(@announcement.id)
+ redirect_to admin_announcements_index_path
+ end
+
+ private
+
+ def set_announcement
+ @announcement = Announcement.find(params[:announcement_id])
+ end
+end
diff --git a/app/controllers/admin/announcements/previews_controller.rb b/app/controllers/admin/announcements/previews_controller.rb
new file mode 100644
index 0000000000..d77f931a7f
--- /dev/null
+++ b/app/controllers/admin/announcements/previews_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class Admin::Announcements::PreviewsController < Admin::BaseController
+ before_action :set_announcement
+
+ def show
+ authorize @announcement, :distribute?
+ @user_count = @announcement.scope_for_notification.count
+ end
+
+ private
+
+ def set_announcement
+ @announcement = Announcement.find(params[:announcement_id])
+ end
+end
diff --git a/app/controllers/admin/announcements/tests_controller.rb b/app/controllers/admin/announcements/tests_controller.rb
new file mode 100644
index 0000000000..f2457eb23a
--- /dev/null
+++ b/app/controllers/admin/announcements/tests_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Admin::Announcements::TestsController < Admin::BaseController
+ before_action :set_announcement
+
+ def create
+ authorize @announcement, :distribute?
+ UserMailer.announcement_published(current_user, @announcement).deliver_later!
+ redirect_to admin_announcements_path
+ end
+
+ private
+
+ def set_announcement
+ @announcement = Announcement.find(params[:announcement_id])
+ end
+end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index b02c462217..0eef9b90da 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -219,6 +219,15 @@ class UserMailer < Devise::Mailer
end
end
+ def announcement_published(user, announcement)
+ @resource = user
+ @announcement = announcement
+
+ I18n.with_locale(locale) do
+ mail subject: default_i18n_subject
+ end
+ end
+
private
def default_devise_subject
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
index 54923ed081..aab55055a9 100644
--- a/app/models/announcement.rb
+++ b/app/models/announcement.rb
@@ -4,17 +4,18 @@
#
# Table name: announcements
#
-# id :bigint(8) not null, primary key
-# text :text default(""), not null
-# published :boolean default(FALSE), not null
-# all_day :boolean default(FALSE), not null
-# scheduled_at :datetime
-# starts_at :datetime
-# ends_at :datetime
-# created_at :datetime not null
-# updated_at :datetime not null
-# published_at :datetime
-# status_ids :bigint(8) is an Array
+# id :bigint(8) not null, primary key
+# all_day :boolean default(FALSE), not null
+# ends_at :datetime
+# notification_sent_at :datetime
+# published :boolean default(FALSE), not null
+# published_at :datetime
+# scheduled_at :datetime
+# starts_at :datetime
+# status_ids :bigint(8) is an Array
+# text :text default(""), not null
+# created_at :datetime not null
+# updated_at :datetime not null
#
class Announcement < ApplicationRecord
@@ -54,6 +55,10 @@ class Announcement < ApplicationRecord
update!(published: false, scheduled_at: nil)
end
+ def notification_sent?
+ notification_sent_at.present?
+ end
+
def mentions
@mentions ||= Account.from_text(text)
end
@@ -86,6 +91,10 @@ class Announcement < ApplicationRecord
end
end
+ def scope_for_notification
+ User.confirmed.joins(:account).merge(Account.without_suspended)
+ end
+
private
def grouped_ordered_announcement_reactions
diff --git a/app/policies/announcement_policy.rb b/app/policies/announcement_policy.rb
index b5dc6a18af..907a3b1a86 100644
--- a/app/policies/announcement_policy.rb
+++ b/app/policies/announcement_policy.rb
@@ -16,4 +16,8 @@ class AnnouncementPolicy < ApplicationPolicy
def destroy?
role.can?(:manage_announcements)
end
+
+ def distribute?
+ record.published? && !record.notification_sent? && role.can?(:manage_settings)
+ end
end
diff --git a/app/views/admin/announcements/_announcement.html.haml b/app/views/admin/announcements/_announcement.html.haml
index 8190f87d2f..87ae97cf48 100644
--- a/app/views/admin/announcements/_announcement.html.haml
+++ b/app/views/admin/announcements/_announcement.html.haml
@@ -10,6 +10,8 @@
= l(announcement.created_at)
%div
+ - if can?(:distribute, announcement)
+ = table_link_to 'mail', t('admin.terms_of_service.notify_users'), admin_announcement_preview_path(announcement)
- if can?(:update, announcement)
- if announcement.published?
= table_link_to 'toggle_off', t('admin.announcements.unpublish'), unpublish_admin_announcement_path(announcement), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/admin/announcements/previews/show.html.haml b/app/views/admin/announcements/previews/show.html.haml
new file mode 100644
index 0000000000..fdfbf598b5
--- /dev/null
+++ b/app/views/admin/announcements/previews/show.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+ = t('admin.announcements.preview.title')
+
+- content_for :heading_actions do
+ .back-link
+ = link_to admin_announcements_path do
+ = material_symbol 'chevron_left'
+ = t('admin.announcements.back')
+
+%p.lead
+ = t('admin.announcements.preview.explanation_html', count: @user_count, display_count: number_with_delimiter(@user_count))
+
+.prose
+ = linkify(@announcement.text)
+
+%hr.spacer/
+
+.content__heading__actions
+ = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), admin_announcement_test_path(@announcement), method: :post, class: 'button button-secondary'
+ = link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), admin_announcement_distribution_path(@announcement), method: :post, class: 'button', data: { confirm: t('admin.reports.are_you_sure') }
diff --git a/app/views/user_mailer/announcement_published.html.haml b/app/views/user_mailer/announcement_published.html.haml
new file mode 100644
index 0000000000..1a879e47c9
--- /dev/null
+++ b/app/views/user_mailer/announcement_published.html.haml
@@ -0,0 +1,12 @@
+= content_for :heading do
+ = render 'application/mailer/heading',
+ image_url: frontend_asset_url('images/mailer-new/heading/user.png'),
+ title: t('user_mailer.announcement_published.title', domain: site_hostname)
+%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
+ %tr
+ %td.email-body-padding-td
+ %table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
+ %tr
+ %td.email-inner-card-td.email-prose
+ %p= t('user_mailer.announcement_published.description', domain: site_hostname)
+ = linkify(@announcement.text)
diff --git a/app/views/user_mailer/announcement_published.text.erb b/app/views/user_mailer/announcement_published.text.erb
new file mode 100644
index 0000000000..94bb4029dc
--- /dev/null
+++ b/app/views/user_mailer/announcement_published.text.erb
@@ -0,0 +1,7 @@
+<%= t('user_mailer.announcement_published.title') %>
+
+===
+
+<%= t('user_mailer.announcement_published.description', domain: site_hostname) %>
+
+<%= @announcement.text %>
diff --git a/app/workers/admin/distribute_announcement_notification_worker.rb b/app/workers/admin/distribute_announcement_notification_worker.rb
new file mode 100644
index 0000000000..a8b9a0bd94
--- /dev/null
+++ b/app/workers/admin/distribute_announcement_notification_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Admin::DistributeAnnouncementNotificationWorker
+ include Sidekiq::Worker
+
+ def perform(announcement_id)
+ announcement = Announcement.find(announcement_id)
+
+ announcement.scope_for_notification.find_each do |user|
+ UserMailer.announcement_published(user, announcement).deliver_later!
+ end
+ rescue ActiveRecord::RecordNotFound
+ true
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index fdf73bad9e..e0a93cc625 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -309,6 +309,7 @@ en:
title: Audit log
unavailable_instance: "(domain name unavailable)"
announcements:
+ back: Back to announcements
destroyed_msg: Announcement successfully deleted!
edit:
title: Edit announcement
@@ -317,6 +318,9 @@ en:
new:
create: Create announcement
title: New announcement
+ preview:
+ explanation_html: 'The email will be sent to %{display_count} users. The following text will be included in the e-mail:'
+ title: Preview announcement notification
publish: Publish
published_msg: Announcement successfully published!
scheduled_for: Scheduled for %{time}
@@ -1905,6 +1909,10 @@ en:
recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. Keep the recovery codes safe. For example, you may print them and store them with other important documents.
webauthn: Security keys
user_mailer:
+ announcement_published:
+ description: 'The administrators of %{domain} are making an announcement:'
+ subject: Service announcement
+ title: "%{domain} service announcement"
appeal_approved:
action: Account Settings
explanation: The appeal of the strike against your account on %{strike_date} that you submitted on %{appeal_date} has been approved. Your account is once again in good standing.
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index fd03b0d66d..d5459a2735 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -50,6 +50,10 @@ namespace :admin do
post :publish
post :unpublish
end
+
+ resource :preview, only: [:show], module: :announcements
+ resource :test, only: [:create], module: :announcements
+ resource :distribution, only: [:create], module: :announcements
end
with_options to: redirect('/admin/settings/branding') do
diff --git a/db/migrate/20250221143646_add_notification_sent_at_to_announcements.rb b/db/migrate/20250221143646_add_notification_sent_at_to_announcements.rb
new file mode 100644
index 0000000000..9cf1521a82
--- /dev/null
+++ b/db/migrate/20250221143646_add_notification_sent_at_to_announcements.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddNotificationSentAtToAnnouncements < ActiveRecord::Migration[8.0]
+ def change
+ add_column :announcements, :notification_sent_at, :datetime
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6c4af6aa19..625d9c0c21 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_01_29_144813) do
+ActiveRecord::Schema[8.0].define(version: 2025_02_21_143646) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -258,6 +258,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_29_144813) do
t.datetime "updated_at", precision: nil, null: false
t.datetime "published_at", precision: nil
t.bigint "status_ids", array: true
+ t.datetime "notification_sent_at"
end
create_table "annual_report_statuses_per_account_counts", force: :cascade do |t|
diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb
index 533aa2e624..3f40e24c8b 100644
--- a/spec/mailers/user_mailer_spec.rb
+++ b/spec/mailers/user_mailer_spec.rb
@@ -317,4 +317,16 @@ RSpec.describe UserMailer do
.and(have_body_text(I18n.t('user_mailer.terms_of_service_changed.changelog')))
end
end
+
+ describe '#announcement_published' do
+ let(:announcement) { Fabricate :announcement }
+ let(:mail) { described_class.announcement_published(receiver, announcement) }
+
+ it 'renders announcement_published mail' do
+ expect(mail)
+ .to be_present
+ .and(have_subject(I18n.t('user_mailer.announcement_published.subject')))
+ .and(have_body_text(I18n.t('user_mailer.announcement_published.description', domain: Rails.configuration.x.local_domain)))
+ end
+ end
end
diff --git a/spec/workers/admin/distribute_announcement_notification_worker_spec.rb b/spec/workers/admin/distribute_announcement_notification_worker_spec.rb
new file mode 100644
index 0000000000..0e618418b0
--- /dev/null
+++ b/spec/workers/admin/distribute_announcement_notification_worker_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Admin::DistributeAnnouncementNotificationWorker do
+ let(:worker) { described_class.new }
+
+ describe '#perform' do
+ context 'with missing record' do
+ it 'runs without error' do
+ expect { worker.perform(nil) }.to_not raise_error
+ end
+ end
+
+ context 'with valid announcement' do
+ let(:announcement) { Fabricate(:announcement) }
+ let!(:user) { Fabricate :user, confirmed_at: 3.days.ago }
+
+ it 'sends the announcement via email', :inline_jobs do
+ emails = capture_emails { worker.perform(announcement.id) }
+
+ expect(emails.size)
+ .to eq(1)
+ expect(emails.first)
+ .to have_attributes(
+ to: [user.email],
+ subject: I18n.t('user_mailer.announcement_published.subject')
+ )
+ end
+ end
+ end
+end