Add admin UI for managing email subscriptions (#38741)

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Eugen Rochko 2026-05-04 15:56:04 +02:00 committed by GitHub
parent 46ccfa6e8d
commit ee88da4511
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 560 additions and 7 deletions

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::EmailSubscriptions::AdditionalFooterTextsController < Admin::SettingsController
private
def after_update_redirect_path
admin_email_subscriptions_path
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class Admin::EmailSubscriptions::SetupsController < Admin::BaseController
before_action :require_enabled!
def show
authorize :email_subscription, :enable?
@form = Form::EmailSubscriptionsConfirmation.new
end
def create
authorize :email_subscription, :enable?
@form = Form::EmailSubscriptionsConfirmation.new(resource_params)
if @form.valid?
Setting.email_subscriptions = true
redirect_to admin_email_subscriptions_path
else
render :show
end
end
private
def require_enabled!
raise ActionController::RoutingError, 'Feature disabled' unless Rails.application.config.x.email_subscriptions
end
def resource_params
params.expect(form_email_subscriptions_confirmation: [:agreement_email_volume, :agreement_privacy_and_terms])
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Admin::EmailSubscriptionsController < Admin::BaseController
def index
authorize :email_subscription, :index?
@enabled = Setting.email_subscriptions
@roles = UserRole.where('permissions & ? != 0', UserRole::FLAGS[:manage_email_subscriptions] | UserRole::FLAGS[:administrator])
@accounts = Account.local.joins(:email_subscriptions).where.associated(:email_subscriptions).includes(:user)
end
def disable
authorize :email_subscription, :disable?
Setting.email_subscriptions = false
redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.disabled_msg')
end
def purge
authorize :email_subscription, :purge?
Admin::EmailSubscriptionsPurgeWorker.perform_async
redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.purged_msg')
end
end

View File

@ -19,7 +19,7 @@ class Api::V1::Accounts::EmailSubscriptionsController < Api::BaseController
end
def require_feature_enabled!
head 404 unless Mastodon::Feature.email_subscriptions_enabled?
head 404 unless Rails.application.config.x.email_subscriptions && Setting.email_subscriptions
end
def require_account_permissions!

View File

@ -127,6 +127,10 @@ module ApplicationHelper
)
end
def emptyphaunt
inline_svg_tag 'elephant_ui.svg'
end
def check_icon
inline_svg_tag 'check.svg'
end

View File

@ -2223,3 +2223,117 @@ a.sparkline {
}
}
}
.subheading-with-action {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
&__subheading {
flex-grow: 1;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-size: 16px;
}
}
.actions-list {
&__item {
border-bottom: 1px solid var(--color-border-primary);
padding: 12px 0;
display: flex;
gap: 16px;
align-items: center;
&__label {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 2px;
&__label {
font-weight: 600;
font-size: 15px;
}
&__hint {
font-size: 13px;
color: var(--color-text-secondary);
}
}
&__action {
flex-shrink: 0;
}
}
}
.empty-state {
border: 1px solid var(--color-border-primary);
border-radius: 12px;
padding: 24px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
&__title-and-description {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
line-height: 21px;
font-size: 15px;
text-align: center;
&__title {
font-weight: 600;
}
}
svg {
width: 200px;
}
// FIXME: This is duplicated with empty_message.module.scss
[data-color-scheme='dark'] & svg {
--color-skin-1: #3a3a50;
--color-skin-2: #67678e;
--color-skin-3: #44445f;
--color-outline: #21212c;
--color-shadow: #181820;
--color-highlight: #b2b1c8;
}
}
.heading-with-lead {
display: flex;
flex-direction: column;
gap: 8px;
h1 {
font-size: 28px;
font-weight: 600;
}
.lead {
font-size: 18px;
color: var(--color-text-secondary);
}
}
.status-badge {
display: inline-flex;
padding: 3px 4px;
border-radius: 8px;
background: var(--color-bg-success-softest);
font-size: 13px;
font-weight: 600;
}

View File

@ -71,6 +71,24 @@ code {
}
.simple_form {
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.numbered-list {
list-style: decimal;
font-size: 16px;
line-height: 24px;
margin-bottom: 16px;
margin-inline-start: 28px;
li {
margin-bottom: 4px;
}
}
&.hidden {
display: none;
}

View File

@ -21,6 +21,24 @@
}
}
.align-end {
text-align: end;
}
.valign-middle {
vertical-align: middle;
}
.avatar-column {
width: 24px;
.avatar {
border-radius: 8px;
border: 1px solid var(--color-border-primary);
background: var(--color-background-secondary);
}
}
& > thead > tr > th {
vertical-align: bottom;
font-weight: 500;
@ -170,6 +188,14 @@ a.table-action-link {
}
}
.table-icon-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
aspect-ratio: 1;
}
.batch-table {
&--no-toolbar {
.batch-table__toolbar {

View File

@ -46,6 +46,7 @@ class Form::AdminSettings
remote_topic_feed_access
landing_page
wrapstodon
email_footer_text
).freeze
INTEGER_KEYS = %i(

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Form::EmailSubscriptionsConfirmation
include ActiveModel::Model
include ActiveModel::Attributes
attribute :agreement_email_volume, :boolean
attribute :agreement_privacy_and_terms, :boolean
validates :agreement_email_volume, :agreement_privacy_and_terms, acceptance: true
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class EmailSubscriptionPolicy < ApplicationPolicy
def index?
role.can?(:manage_settings)
end
alias enable? index?
alias disable? index?
alias purge? index?
end

View File

@ -22,7 +22,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
attribute :memorial, if: :memorial?
attribute :feature_approval, if: -> { Mastodon::Feature.collections_enabled? }
attribute :email_subscriptions, if: -> { Mastodon::Feature.email_subscriptions_enabled? }
attribute :email_subscriptions, if: -> { Rails.application.config.x.email_subscriptions && Setting.email_subscriptions }
class AccountDecorator < SimpleDelegator
def self.model_name

View File

@ -171,7 +171,7 @@ class PostStatusService < BaseService
end
def process_email_subscriptions!
return unless Mastodon::Feature.email_subscriptions_enabled? &&
return unless Rails.application.config.x.email_subscriptions && Setting.email_subscriptions &&
@status.public_visibility? && (!@status.reply? || @status.in_reply_to_account_id == @status.account_id) &&
@status.account.user_can?(:manage_email_subscriptions) &&
@status.account.user_email_subscriptions_enabled?

View File

@ -0,0 +1,44 @@
.subheading-with-action
.subheading-with-action__subheading
%h3= t('.title')
%p= t('.lead')
.table-wrapper
- if accounts.empty?
.empty-state
= emptyphaunt
.empty-state__title-and-description
.empty-state__title-and-description__title
= t('.empty.no_lists_yet')
.empty-state__title-and-description__description
= t('.empty.hint')
- else
%table.table
%thead
%tr
%th.avatar-column
%th= t('.account')
%th= t('.status')
%th= t('.subscribers')
%th= t('.last_email')
%th
%tbody
- accounts.each do |account|
%tr
%td.valign-middle.avatar-column
= image_tag(full_asset_url(account.avatar_original_url), width: 24, height: 24, alt: '', class: 'avatar')
%td.valign-middle
%strong
%bdi= display_name(account)
%td.valign-middle
- if account.user_can?(:manage_email_subscriptions) && account.user_email_subscriptions_enabled?
%span.status-badge.positive= t('.active')
- else
%span.status-badge.negative= t('.inactive')
%td.valign-middle
= account.email_subscriptions.count
%td.valign-middle
= l account.last_status_at
%td.valign-middle.align-end
= link_to material_symbol('chevron_right'), admin_account_path(account.id), class: 'table-icon-link'

View File

@ -0,0 +1,22 @@
.subheading-with-action
.subheading-with-action__subheading
%h3= t('.title')
%p= t('.lead')
.actions-list
.actions-list__item
.actions-list__item__label
.actions-list__item__label__label
= t('.privacy_policy.title')
.actions-list__item__label__hint
= t('.privacy_policy.hint')
.actions-list__item__action
= link_to t('.privacy_policy.action'), admin_settings_about_path, class: 'button button-secondary'
.actions-list__item
.actions-list__item__label
.actions-list__item__label__label
= t('.additional_footer_text.title')
.actions-list__item__label__hint
= t('.additional_footer_text.hint')
.actions-list__item__action
= link_to t('.additional_footer_text.action'), admin_email_subscriptions_additional_footer_text_path, class: 'button button-secondary'

View File

@ -0,0 +1,22 @@
.subheading-with-action
.subheading-with-action__subheading
%h3= t('.title')
.actions-list
.actions-list__item
.actions-list__item__label
.actions-list__item__label__label
= t('.disable_feature.title')
.actions-list__item__label__hint
= t('.disable_feature.hint')
.actions-list__item__action
= link_to t('.disable_feature.action'), disable_admin_email_subscriptions_path, class: 'button button-secondary button--destructive', data: { method: 'post', confirm: t('admin.accounts.are_you_sure') }
- unless accounts.empty?
.actions-list__item
.actions-list__item__label
.actions-list__item__label__label
= t('.erase_all_data.title')
.actions-list__item__label__hint
= t('.erase_all_data.hint')
.actions-list__item__action
= link_to t('.erase_all_data.action'), purge_admin_email_subscriptions_path, class: 'button button-secondary button--destructive', data: { method: 'post', confirm: t('admin.accounts.are_you_sure') }

View File

@ -0,0 +1,36 @@
.subheading-with-action
.subheading-with-action__subheading
%h3= t('.title')
%p= t('.lead')
- unless roles.empty?
.subheading-with-action__action
= link_to t('.manage_roles'), admin_roles_path, class: 'button button-secondary'
.table-wrapper
- if roles.empty?
.empty-state
= emptyphaunt
.empty-state__title-and-description
.empty-state__title-and-description__title
= t('.empty.no_roles_added')
.empty-state__title-and-description__description
= t('.empty.hint')
.empty-state__action
= link_to t('.manage_roles'), admin_roles_path, class: 'button'
- else
%table.table
%thead
%tr
%th= t('.role_name')
%th= t('.accounts')
%th
%tbody
- roles.each do |role|
%tr
%td
= role.name
%td
= role.users.count
%td.align-end
= link_to material_symbol('edit'), edit_admin_role_path(role), title: t('.edit_role'), class: 'table-icon-link' if can?(:update, role)

View File

@ -0,0 +1,16 @@
- content_for :page_title do
= t('.title')
= simple_form_for @admin_settings, url: admin_email_subscriptions_additional_footer_text_path do |form|
= render 'shared/error_messages', object: @admin_settings
.fields-group
= form.input :email_footer_text,
as: :text,
input_html: { rows: 8 },
wrapper: :with_block_label
.actions
= form.button :button,
t('generic.save_changes'),
type: :submit

View File

@ -0,0 +1,28 @@
- content_for :page_title do
= t('.title')
- content_for :heading do
.heading-with-lead
%h1= t('.title')
%p.lead= t('.lead')
%hr.spacer/
- if @enabled
= render 'roles', roles: @roles
%hr.spacer/
= render 'accounts', accounts: @accounts
%hr.spacer/
= render 'compliance_settings'
%hr.spacer/
= render 'danger_zone', accounts: @accounts
- else
.simple_form
%p.lead= t('.disabled.description')
- if Rails.application.config.x.email_subscriptions
= link_to t('.disabled.get_started'), admin_email_subscriptions_setup_path, class: 'button'
- else
%aside.callout
.content
.body= t('.disabled.cannot_be_enabled')

View File

@ -0,0 +1,25 @@
- content_for :page_title do
= t('admin.email_subscriptions.index.title')
- content_for :heading do
.heading-with-lead
%h1= t('admin.email_subscriptions.index.title')
%p.lead= t('admin.email_subscriptions.index.lead')
= simple_form_for @form, url: admin_email_subscriptions_setup_path, method: :post do |f|
= render 'shared/error_messages', object: @form
%h3= t('.important_information')
%ol.numbered-list
%li= t('.list.1_permission_explanation')
%li= t('.list.2_feature_explanation')
%li= t('.list.3_privacy_policy_warning')
%li= t('.list.4_cost_warning')
.fields-group
= f.input :agreement_email_volume, as: :boolean, wrapper: :with_label
= f.input :agreement_privacy_and_terms, as: :boolean, wrapper: :with_label
.actions
= f.button :button, t('.enable_feature'), type: :submit

View File

@ -18,3 +18,5 @@
- content_for :footer do
%p.email-footer-p= t('email_subscription_mailer.notification.footer.reason_for_email_html', name: display_name(@subscription.account), unsubscribe_path: @unsubscribe_url)
%p.email-footer-p= t('email_subscription_mailer.notification.footer.privacy_html', domain: @instance, privacy_policy_path: privacy_policy_path)
- if Setting.email_footer_text.present?
%p.email-footer-p= Setting.email_footer_text

View File

@ -34,3 +34,5 @@
- content_for :footer do
%p.email-footer-p= t('.footer.reason_for_email_html', name: display_name(@subscription.account), unsubscribe_path: @unsubscribe_url)
%p.email-footer-p= t('.footer.privacy_html', domain: @instance, privacy_policy_path: privacy_policy_path)
- if Setting.email_footer_text.present?
%p.email-footer-p= Setting.email_footer_text

View File

@ -38,7 +38,7 @@
.fields-group
= ff.input :show_application, wrapper: :with_label
- if Mastodon::Feature.email_subscriptions_enabled? && current_user.can?(:manage_email_subscriptions)
- if Rails.application.config.x.email_subscriptions && Setting.email_subscriptions && current_user.can?(:manage_email_subscriptions)
%h2= t('privacy.email_subscriptions')
%p.lead= t('privacy.email_subscriptions_hint_html')

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Admin::EmailSubscriptionsPurgeWorker
include Sidekiq::Worker
sidekiq_options lock: :until_executed, lock_ttl: 1.week.to_i
def perform
EmailSubscription.in_batches.delete_all
end
end

View File

@ -7,7 +7,7 @@ class EmailDistributionWorker
sidekiq_options lock: :until_executed, lock_ttl: 1.day.to_i
def perform(account_id)
return unless Mastodon::Feature.email_subscriptions_enabled?
return unless Rails.application.config.x.email_subscriptions && Setting.email_subscriptions
@account = Account.find(account_id)

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
Rails.application.configure do
config.x.email_subscriptions = ENV['DISABLE_EMAIL_SUBSCRIPTIONS'] != 'true'
end

View File

@ -487,6 +487,71 @@ en:
resolved_dns_records_hint_html: The domain name resolves to the following MX domains, which are ultimately responsible for accepting email. Blocking an MX domain will block sign-ups from any email address which uses the same MX domain, even if the visible domain name is different. <strong>Be careful not to block major email providers.</strong>
resolved_through_html: Resolved through %{domain}
title: Blocked email domains
email_subscriptions:
accounts:
account: Account
active: Active
empty:
hint: No accounts have subscribers yet.
no_lists_yet: No lists yet
inactive: Inactive
last_email: Last email
lead: Accounts who have enabled the feature and have subscribers will show below.
status: Status
subscribers: Subscribers
title: Mailing lists
additional_footer_texts:
show:
title: Additional footer text
compliance_settings:
additional_footer_text:
action: Manage
hint: Optional text that appears in the footer of newsletter emails only
title: Additional footer text
lead: Email newsletters may be considered marketing emails, depending on the jurisdictions where you operate.
privacy_policy:
action: Manage
hint: This policy is linked in the footer of every email
title: Privacy policy
title: Compliance settings
danger_zone:
disable_feature:
action: Disable
hint: Turn off feature for all accounts
title: Disable feature
erase_all_data:
action: Erase data
hint: Permanently deletes all emails in all mailing lists
title: Erase all data
title: Danger zone
disabled_msg: Email subscriptions have been successfully disabled.
index:
disabled:
cannot_be_enabled: Your technical provider has not enabled this feature for your server.
description: This feature allows specified accounts to add a widget to their profiles, enabling visitors without a Mastodon account to receive their posts via email.
get_started: Get started
lead: Allow visitors to receive posts via email from dedicated accounts on this server.
title: Email newsletters
purged_msg: All email subscriptions data is being erased.
roles:
accounts: Accounts
edit_role: Edit role
empty:
hint: No one has permission to use this feature.
no_roles_added: No roles added
lead: Accounts with the following roles can enable this feature on their profiles.
manage_roles: Manage roles
role_name: Role name
title: Roles
setups:
show:
enable_feature: Enable feature
important_information: Important information
list:
1_permission_explanation: When this feature is enabled, accounts with the designated permissions can add an email collection form to their profiles.
2_feature_explanation: When visitors sign up on an accounts profile page and confirm their subscription, theyll begin to receive email updates when the account creates new public posts.
3_privacy_policy_warning: Server admins will have access to PII (email addresses) collected. As such, the privacy policy and Terms of Service for the server must be updated before using this feature.
4_cost_warning: Emails may incur a fee depending on hosting setup. Discuss with your hosting provider before enabling, as this feature could drastically increase the amount of emails sent from your server.
export_domain_allows:
new:
title: Import domain allows
@ -792,7 +857,7 @@ en:
manage_custom_emojis: Manage Custom Emojis
manage_custom_emojis_description: Allows users to manage custom emojis on the server
manage_email_subscriptions: Manage Email Subscriptions
manage_email_subscriptions_description: Allow users to subscribe to users with this permission by email
manage_email_subscriptions_description: Allow users with this permission to enable the email newsletter feature for their account
manage_federation: Manage Federation
manage_federation_description: Allows users to block or allow federation with other domains, and control deliverability
manage_invites: Manage Invites

View File

@ -92,6 +92,7 @@ en:
closed_registrations_message: Displayed when sign-ups are closed
content_cache_retention_period: All posts from other servers (including boosts and replies) will be deleted after the specified number of days, without regard to any local user interaction with those posts. This includes posts where a local user has marked it as bookmarks or favorites. Private mentions between users from different instances will also be lost and impossible to restore. Use of this setting is intended for special purpose instances and breaks many user expectations when implemented for general purpose use.
custom_css: You can apply custom styles on the web version of Mastodon.
email_footer_text: Optional text that appears in the footer of newsletter emails only.
favicon: WEBP, PNG, GIF or JPG. Overrides the default Mastodon favicon with a custom icon.
landing_page: Selects what page new visitors see when they first arrive on your server. If you select "Trends", then trends needs to be enabled in the Discovery Settings. If you select "Local feed", then "Access to live feeds featuring local posts" needs to be set to "Everyone" in the Discovery Settings.
mascot: Overrides the illustration in the advanced web interface.
@ -294,6 +295,7 @@ en:
closed_registrations_message: Custom message when sign-ups are not available
content_cache_retention_period: Remote content retention period
custom_css: Custom CSS
email_footer_text: Additional footer text
favicon: Favicon
landing_page: Landing page for new visitors
local_live_feed_access: Access to live feeds featuring local posts
@ -322,6 +324,9 @@ en:
trendable_by_default: Allow trends without prior review
trends: Enable trends
wrapstodon: Enable Wrapstodon
form_email_subscriptions_confirmation:
agreement_email_volume: I understand that enabling this feature may significantly increase the volume of emails sent from the server and that I am solely responsible for any costs incurred.
agreement_privacy_and_terms: I have updated the Privacy Policy and Terms of Service.
interactions:
must_be_follower: Block notifications from non-followers
must_be_following: Block notifications from people you don't follow

View File

@ -74,6 +74,7 @@ SimpleNavigation::Configuration.run do |navigation|
s.item :warning_presets, safe_join([material_symbol('warning'), t('admin.warning_presets.title')]), admin_warning_presets_path, highlights_on: %r{/admin/warning_presets}, if: -> { current_user.can?(:manage_settings) }
s.item :roles, safe_join([material_symbol('contact_mail'), t('admin.roles.title')]), admin_roles_path, highlights_on: %r{/admin/roles}, if: -> { current_user.can?(:manage_roles) }
s.item :announcements, safe_join([material_symbol('campaign'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}, if: -> { current_user.can?(:manage_announcements) }
s.item :email_subscriptions, safe_join([material_symbol('mail'), t('admin.email_subscriptions.index.title')]), admin_email_subscriptions_path, highlights_on: %r{/admin/email_subscriptions}, if: -> { current_user.can?(:manage_settings) }
s.item :custom_emojis, safe_join([material_symbol('mood'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) }
s.item :webhooks, safe_join([material_symbol('inbox'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) }
s.item :fasp, safe_join([material_symbol('extension'), t('admin.fasp.title')]), admin_fasp_providers_path, highlights_on: %r{/admin/fasp}, if: -> { current_user.can?(:manage_federation) } if Mastodon::Feature.fasp_enabled?

View File

@ -26,6 +26,18 @@ namespace :admin do
resources :email_domain_blocks, only: [:index, :new, :create], concerns: :batch
resources :email_subscriptions, only: [:index] do
collection do
post :purge
post :disable
end
end
namespace :email_subscriptions do
resource :setup, only: [:show, :create]
resource :additional_footer_text, only: [:show, :update]
end
resources :action_logs, only: [:index]
resources :warning_presets, except: [:new, :show]

View File

@ -2,9 +2,13 @@
require 'rails_helper'
RSpec.describe 'Accounts Email Subscriptions API', feature: :email_subscriptions do
RSpec.describe 'Accounts Email Subscriptions API' do
let(:account) { Fabricate(:user).account }
before do
Setting.email_subscriptions = true
end
describe 'POST /api/v1/accounts/:id/email_subscriptions' do
context 'when the account has the permission' do
let(:role) { Fabricate(:user_role, permissions: UserRole::FLAGS[:manage_email_subscriptions]) }