Merge pull request #974 from basecamp/bundle-emails

Bundled notification emails
This commit is contained in:
Jorge Manrubia
2025-09-01 11:57:28 +02:00
committed by GitHub
59 changed files with 918 additions and 61 deletions
+1
View File
@@ -60,6 +60,7 @@ group :development, :test do
gem "bundler-audit", require: false
gem "brakeman", require: false
gem "rubocop-rails-omakase", require: false
gem "letter_opener"
end
group :test do
+9
View File
@@ -276,6 +276,8 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
@@ -342,6 +344,12 @@ GEM
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
@@ -611,6 +619,7 @@ DEPENDENCIES
importmap-rails
jbuilder
kamal!
letter_opener
lexxy!
mission_control-jobs
mocha
+14 -1
View File
@@ -63,6 +63,19 @@ A typical scenario is making modifications LLM prompts. You need to:
Notice that if you pass changing data to the prompt, this mechanism won't work. E.g: if you pass data timestamps.
You need to make sure those timestamps are always the same across executions.
### Outbound Emails
#### Development
You can view email previews at http://fizzy.localhost:3006/rails/mailers.
You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to
open sent emails automatically with:
bin/rails dev:email
Under the hood, this will create or remove `tmp/email-dev.txt`.
## Environments
Fizzy is deployed with Kamal. You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment.
@@ -73,7 +86,7 @@ Beta is primarily intended for testing product features.
Beta tenant is:
- https://fizzy.37signals.works/
- https://fizzy-beta.37signals.com
This environment uses local disk for Active Storage.
@@ -1,7 +1,22 @@
class Notifications::SettingsController < ApplicationController
include FilterScoped
before_action :set_settings
def show
@collections = Current.user.collections.alphabetically
end
def update
@settings.update!(settings_params)
redirect_to notifications_settings_path, notice: "Settings updated"
end
private
def set_settings
@settings = Current.user.settings
end
def settings_params
params.expect(user_settings: :bundle_email_frequency)
end
end
@@ -0,0 +1,24 @@
class Notifications::UnsubscribesController < ApplicationController
allow_unauthenticated_access
skip_before_action :verify_authenticity_token
before_action :set_user
def new
end
def create
@user.settings.bundle_email_never!
redirect_to notifications_unsubscribe_path(access_token: params[:access_token])
end
def show
end
private
def set_user
unless @user = User.find_by_token_for(:unsubscribe, params[:access_token])
redirect_to root_path, alert: "Invalid unsubscribe link"
end
end
end
+1 -1
View File
@@ -28,6 +28,6 @@ module AvatarsHelper
end
def avatar_image_tag(user, **options)
image_tag user_avatar_path(user), aria: { hidden: "true" }, size: 48, title: user.name, **options
image_tag user_avatar_url(user), aria: { hidden: "true" }, size: 48, title: user.name, **options
end
end
+9
View File
@@ -65,6 +65,15 @@ module NotificationsHelper
end
end
def bundle_email_frequency_options_for(settings)
options_for_select([
[ "Never", "never" ],
[ "Every few hours", "every_few_hours" ],
[ "Every day", "daily" ],
[ "Every week", "weekly" ]
], settings.bundle_email_frequency)
end
private
def event_notification_action(event)
if event.action.card_published? && event.eventable.assigned_to?(event.creator)
@@ -0,0 +1,9 @@
class Notification::Bundle::DeliverAllJob < ApplicationJob
queue_as :backend
def perform
ApplicationRecord.with_each_tenant do |tenant|
Notification::Bundle.deliver_all
end
end
end
@@ -0,0 +1,7 @@
class Notification::Bundle::DeliverJob < ApplicationJob
queue_as :backend
def perform(bundle)
bundle.deliver
end
end
-2
View File
@@ -1,6 +1,4 @@
class PushNotificationJob < ApplicationJob
queue_as :default
def perform(notification)
NotificationPusher.new(notification).push
end
+9 -1
View File
@@ -1,4 +1,12 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
default from: "The Fizzy team <support@37signals.com>"
layout "mailer"
append_view_path Rails.root.join("app/views/mailers")
helper AvatarsHelper, HtmlHelper
private
def default_url_options
super.merge(script_name: Account.sole.slug)
end
end
@@ -0,0 +1,12 @@
module Mailers::Unsubscribable
extend ActiveSupport::Concern
included do
after_action :set_unsubscribe_headers
end
def set_unsubscribe_headers
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
headers["List-Unsubscribe"] = "<#{notifications_unsubscribe_url(access_token: @unsubscribe_token)}>"
end
end
+16
View File
@@ -0,0 +1,16 @@
class Notification::BundleMailer < ApplicationMailer
include Mailers::Unsubscribable
helper NotificationsHelper
def notification(bundle)
@user = bundle.user
@bundle = bundle
@notifications = bundle.notifications
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
mail \
to: bundle.user.email_address,
subject: "Latest Activity in Fizzy"
end
end
+5
View File
@@ -10,6 +10,7 @@ class Notification < ApplicationRecord
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
after_create_commit :broadcast_unread
after_create :bundle
delegate :notifiable_target, to: :source
delegate :card, to: :source
@@ -40,4 +41,8 @@ class Notification < ApplicationRecord
def broadcast_read
broadcast_remove_to user, :notifications
end
def bundle
user.bundle(self) if user.settings.bundling_emails?
end
end
+69
View File
@@ -0,0 +1,69 @@
class Notification::Bundle < ApplicationRecord
belongs_to :user
enum :status, %i[ pending processing delivered ]
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) }
scope :overlapping_with, ->(other_bundle) do
where(
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
other_bundle.starts_at, other_bundle.starts_at,
other_bundle.ends_at, other_bundle.ends_at,
other_bundle.starts_at, other_bundle.ends_at
)
end
before_create :set_default_window
validate :validate_no_overlapping
class << self
def deliver_all
due.in_batches do |batch|
jobs = batch.collect { DeliverJob.new(it) }
ActiveJob.perform_all_later jobs
end
end
def deliver_all_later
DeliverAllJob.perform_later
end
end
def notifications
user.notifications.where(created_at: window).unread
end
def deliver
processing!
Notification::BundleMailer.notification(self).deliver if notifications.any?
delivered!
end
def deliver_later
DeliverJob.perform_later(self)
end
private
def set_default_window
self.starts_at ||= Time.current
self.ends_at ||= user.settings.bundle_aggregation_period.from_now
end
def window
starts_at..ends_at
end
def validate_no_overlapping
if overlapping_bundles.exists?
errors.add(:base, "Bundle window overlaps with an existing pending bundle")
end
end
def overlapping_bundles
user.notification_bundles.where.not(id: id).overlapping_with(self)
end
end
+2 -6
View File
@@ -1,6 +1,6 @@
class User < ApplicationRecord
include Accessor, AiQuota, Assignee, Attachable, Mentionable, Named, Role,
Searcher, SignalUser, Staff, Transferable, Conversational
include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Mentionable, Named,
Notifiable, Role, Searcher, SignalUser, Staff, Transferable
include Timelined # Depends on Accessor
has_one_attached :avatar
@@ -10,15 +10,11 @@ class User < ApplicationRecord
has_many :comments, inverse_of: :creator, dependent: :destroy
has_many :notifications, dependent: :destroy
has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy
has_many :closures, dependent: :nullify
has_many :pins, dependent: :destroy
has_many :pinned_cards, through: :pins, source: :card
has_many :commands, dependent: :destroy
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
has_many :period_activity_summaries, dependent: :destroy
normalizes :email_address, with: ->(value) { value.strip.downcase }
+10
View File
@@ -0,0 +1,10 @@
module User::Configurable
extend ActiveSupport::Concern
included do
has_one :settings, class_name: "User::Settings", dependent: :destroy
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
after_create :create_settings, unless: :system?
end
end
+29
View File
@@ -0,0 +1,29 @@
module User::Notifiable
extend ActiveSupport::Concern
included do
has_many :notifications, dependent: :destroy
has_many :notification_bundles, class_name: "Notification::Bundle", dependent: :destroy
generates_token_for :unsubscribe, expires_in: 1.month
end
def bundle(notification)
transaction do
find_or_create_bundle_for(notification)
end
end
private
def find_or_create_bundle_for(notification)
find_bundle_for(notification) || create_bundle_for(notification)
end
def find_bundle_for(notification)
notification_bundles.pending.containing(notification).last
end
def create_bundle_for(notification)
notification_bundles.create!(starts_at: notification.created_at)
end
end
+46
View File
@@ -0,0 +1,46 @@
class User::Settings < ApplicationRecord
belongs_to :user
enum :bundle_email_frequency, %i[ never every_few_hours daily weekly ],
default: :every_few_hours, prefix: :bundle_email
after_update :review_pending_bundles, if: :saved_change_to_bundle_email_frequency?
def bundle_aggregation_period
case bundle_email_frequency
when "every_few_hours"
4.hours
when "daily"
1.day
when "weekly"
1.week
else
1.day
end
end
def bundling_emails?
!bundle_email_never?
end
private
def review_pending_bundles
if bundling_emails?
flush_pending_bundles
else
cancel_pending_bundles
end
end
def cancel_pending_bundles
user.notification_bundles.pending.find_each do |bundle|
bundle.destroy
end
end
def flush_pending_bundles
user.notification_bundles.pending.find_each do |bundle|
bundle.deliver_later
end
end
end
+1 -1
View File
@@ -4,7 +4,7 @@
<%= filter_place_menu_item account_settings_path, "Account Settings", "settings" %>
<%= filter_place_menu_item user_path(Current.user), "My Profile", "person" %>
<%= filter_place_menu_item notifications_path, "Notifications", "bell" %>
<%= filter_place_menu_item settings_notifications_path, "Notification Settings", "settings" %>
<%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %>
<%= filter_place_menu_item search_path, "Search", "search" %>
<%= filter_place_menu_item workflows_path, "Workflows", "bolt" %>
</ul>
+118 -2
View File
@@ -3,11 +3,127 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
html {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
font-family: system-ui, sans-serif;
font-size: 1rem;
line-height: 1.3;
margin: 0;
padding: 1rem;
}
img {
border: 0 none;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
a {
color: #2d71e5;
}
a img {
border: 0 none;
}
table, td {
border-collapse: collapse;
}
#body {
height: 100% !important;
margin: 0;
padding: 0;
width: 100% !important;
}
.avatar {
border-radius: 50%;
height: 3em;
margin-right: 0.3em;
max-height: 3em;
min-height: 3em;
overflow: hidden;
width: 3em;
}
.avatar__container {
padding-top: 1em;
vertical-align: top;
width: 3.5em;
}
.btn {
background: #2d71e5;
border-color: #2d71e5;
border-radius: 3rem;
color: #ffffff;
font-weight: 500;
padding: 0.2em 0.4em;
text-decoration: none;
white-space: nowrap;
}
.event__details {
margin-bottom: 0.2em;
margin-top: 0.2em;
}
.event__title {
font-size: 1.2em;
font-weight: 900;
margin-bottom: 0;
margin-top: 0;
}
.footer {
border-top: 1px solid #ccc;
margin-top: 3em;
padding-top: 1em;
}
.notification {
margin-top: 1rem;
margin-bottom: 1rem;
}
.notification__author {
opacity: 0.66;
margin-top: 0;
}
.notification__title {
font-size: 0.9em;
font-weight: normal;
margin-bottom: 0;
margin-top: 0;
opacity: 0.66;
text-transform: uppercase;
}
.subtitle {
font-size: 1.3em;
font-weight: normal;
margin-top: 0;
}
.title {
font-size: 1.5em;
font-weight: 900;
margin-bottom: 0;
}
</style>
</head>
<body>
<%= yield %>
<table id="body">
<%= yield %>
</table>
</body>
</html>
@@ -0,0 +1 @@
<h2 class="notification__title">#<%= notification.card.id %> | <%= notification.card.collection.name %> | <%= notification.creator.name %></h2>
@@ -0,0 +1 @@
#<%= notification.card.id %> | <%= notification.card.collection.name %> | <%= notification.creator.name %>
@@ -0,0 +1,9 @@
<table class="notification">
<tr>
<td class="avatar__container"><%= image_tag user_avatar_url(notification.creator), alt: notification.creator.name, class: "avatar", size: 48 %></td>
<td>
<%= render "notification/bundle_mailer/header", notification: notification %>
<%= render "notification/bundle_mailer/#{notification.source_type.underscore}/body", notification: notification %>
</td>
</tr>
</table>
@@ -0,0 +1,5 @@
================================================================================
<%= render "notification/bundle_mailer/header", notification: notification %>
<%= render "notification/bundle_mailer/#{notification.source_type.underscore}/body", notification: notification %>
@@ -0,0 +1,9 @@
<% event = notification.source %>
<h3 class="event__title">
<%= link_to event_notification_title(event), notification.source %>
</h3>
<p class="event__details">
<%= event_notification_body(event) %>
</p>
@@ -0,0 +1,7 @@
<% event = notification.source %>
<%= event_notification_title(event) %>
<%= event_notification_body(event).squish %>
<%= url_for notification %>
@@ -0,0 +1,9 @@
<% mention = notification.source %>
<h3 class="event__title">
<%= link_to "#{mention.mentioner.first_name} mentioned you", mention %>
</h3>
<p class="event__details">
<%= mention.source.mentionable_content.truncate(250) %>
</p>
@@ -0,0 +1,7 @@
<% mention = notification.source %>
<%= mention.mentioner.first_name %> mentioned you.
<%= mention.source.mentionable_content.truncate(250) %>
<%= url_for mention %>
@@ -0,0 +1,11 @@
<tr>
<td>
<h1 class="title">Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %></h1>
<p class="subtitle">You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %>.</p>
<%= render partial: "notification/bundle_mailer/notification", collection: @notifications, as: :notification %>
<p class="footer">Fizzy emails you about new notifications every few hours. <br>
<%= link_to "Change how often you get these", notifications_settings_url %>
or <%= link_to "unsubscribe from all email notifications", new_notifications_unsubscribe_url(access_token: @unsubscribe_token) %>.
</p>
</td>
</tr>
@@ -0,0 +1,13 @@
Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>
You have <%= pluralize @notifications.count, "new notification" %>.
<%= render partial: "notification/bundle_mailer/notification", collection: @notifications, as: :notification %>
--------------------------------------------------------------------------------
Fizzy emails you about new notifications every few hours.
Change how often you get these:
<%= notifications_settings_url %>
Unsubscribe from all email notifications:
<%= new_notifications_unsubscribe_url(access_token: @unsubscribe_token) %>
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="tray__item tray__item--hat txt-x-small gap-half">
<div data-navigable-list-target="item" class="full-width">
<%= link_to settings_notifications_path,
<%= link_to notifications_settings_path,
class: "btn borderless tray__notification-settings",
title: "Notification settings",
data: { action: "dialog#close" } do %>
+1 -1
View File
@@ -7,7 +7,7 @@
<h1 class="header__title"><%= @page_title %></h1>
<div class="header__actions header__actions--end">
<%= link_to settings_notifications_path, class: "btn" do %>
<%= link_to notifications_settings_path, class: "btn" do %>
<%= icon_tag "settings" %> <span class="for-screen-reader">Notification settings</span>
<% end %>
</div>
@@ -0,0 +1,12 @@
<section class="panel unpad borderless center flex flex-column gap-half margin-block-start">
<h2 class="txt-medium txt-uppercase divider margin-none-block-end">Email Notifications</h2>
<p class="margin-none">Get a single email with all your notifications every few hours, daily, or weekly.</p>
<%= form_with model: settings, url: notifications_settings_path,
class: "flex flex-column gap-half",
method: :patch, local: true, data: { controller: "form" } do |form| %>
<div class="flex flex-column gap-half pad">
<strong><%= form.label :bundle_email_frequency, "Email me about new notifications..." %></strong>
<%= form.select :bundle_email_frequency, bundle_email_frequency_options_for(settings), {}, class: "input input--select txt-align-center", data: { action: "change->form#submit" } %>
</div>
<% end %>
</section>
@@ -0,0 +1,26 @@
<div class="notifications__status panel fill-shade center" data-controller="notifications" data-notifications-subscriptions-url-value="<%= user_push_subscriptions_path(Current.user) %>" data-notifications-enabled-class="notifications--on">
<h2 class="notifications__on-message txt-medium">Push notifications are ON</h2>
<h2 class="notifications__off-message txt-medium">Push notifications are OFF</h2>
<div class="margin-block-start-half" data-notifications-target="subscribeButton" hidden>
<button class="btn txt-small" data-action="notifications#attemptToSubscribe">
<%= icon_tag "bell" %>
<span>Turn ON push notifications on this device</span>
</button>
</div>
<details class="margin-block-start-half" data-notifications-target="explainer">
<summary class="btn txt-x-small">
<%= icon_tag "lifebuoy" %>
<span class="notifications__off-message">Help me fix this</span>
<span class="notifications__on-message">Not receiving notifications?</span>
</summary>
<div class="notifications-help__explainer fill-white margin-block-start border-radius border txt-align-start">
<p class="margin-none-block-start notifications__on-message">When push notifications arent working, this can usually be fixed by checking your notification settings to make sure theyre allowed.</p>
<%= render partial: "notifications/settings/browser" %>
<%= render partial: "notifications/settings/system" %>
<%= render partial: "notifications/settings/install" %>
</div>
</details>
</div>
+10 -28
View File
@@ -5,34 +5,16 @@
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<div class="notifications__status panel fill-shade center margin-block-start margin-block-end-double" data-controller="notifications" data-notifications-subscriptions-url-value="<%= user_push_subscriptions_path(Current.user) %>" data-notifications-enabled-class="notifications--on">
<h2 class="notifications__on-message txt-medium">Push notifications are ON</h2>
<h2 class="notifications__off-message txt-medium">Push notifications are OFF</h2>
<div class="margin-block-start-half" data-notifications-target="subscribeButton" hidden>
<button class="btn txt-small" data-action="notifications#attemptToSubscribe">
<%= icon_tag "bell" %>
<span>Turn ON push notifications on this device</span>
</button>
<section class="settings">
<div class="settings__panel settings__panel--users panel shadow center">
<h2 class="txt-medium txt-uppercase divider">Collections</h2>
<div class="settings__user-list flex flex-column gap-half">
<%= render partial: "notifications/settings/collection", collection: @collections, locals: { user: Current.user } %>
</div>
</div>
<details class="margin-block-start-half" data-notifications-target="explainer">
<summary class="btn txt-x-small">
<%= icon_tag "lifebuoy" %>
<span class="notifications__off-message">Help me fix this</span>
<span class="notifications__on-message">Not receiving notifications?</span>
</summary>
<div class="notifications-help__explainer fill-white margin-block-start border-radius border txt-align-start">
<p class="margin-none-block-start notifications__on-message">When push notifications arent working, this can usually be fixed by checking your notification settings to make sure theyre allowed.</p>
<%= render partial: "notifications/settings/browser" %>
<%= render partial: "notifications/settings/system" %>
<%= render partial: "notifications/settings/install" %>
</div>
</details>
</div>
<section class="panel unpad borderless center flex flex-column gap-half">
<h2 class="txt-medium txt-uppercase divider">Collections</h2>
<%= render partial: "notifications/settings/collection", collection: @collections, locals: { user: Current.user } %>
<div class="settings__panel panel shadow center">
<%= render "notifications/settings/push_notifications" %>
<%= render "notifications/settings/email", settings: @settings %>
</div>
</section>
@@ -0,0 +1,7 @@
<p>Unsubscribing from all email notifications as <%= @user.name %>…</p>
<div class="push_double--top centered delayed-fade-in">
<%= auto_submit_form_with model: @user, url: notifications_unsubscribe_path(access_token: params[:access_token]), method: :post do |form| %>
<%= form.submit "Unsubscribe now", class: "btn" %>
<% end %>
</div>
@@ -0,0 +1,9 @@
<div class="panel margin-block-double center shadow">
<h1 class="font-black txt-x-large margin-none-block-end">Youre unsubscribed</h1>
<p class="margin-block-start-half">Thanks, <%= @user.first_name %>! Fizzy wont send you any more email notifications. If you change your mind you can turn them back on in <%= link_to "Notifications Settings", notifications_settings_path %>.</p>
<%= link_to root_path, class: "btn btn--link margin-block-start" do %>
<%= icon_tag "arrow-left" %>
<span>Back to Fizzy</span>
<% end %>
</div>
+3 -1
View File
@@ -1,7 +1,9 @@
require_relative "production"
Rails.application.configure do
config.action_mailer.default_url_options = { host: "%{tenant}.37signals.works" }
config.action_mailer.smtp_settings[:domain] = "fizzy-beta.37signals.com"
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
config.action_mailer.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" }
# Let's keep beta on local disk. See https://github.com/basecamp/fizzy/pull/557 for context.
config.active_storage.service = :local
+11 -2
View File
@@ -72,8 +72,17 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
# Allow all hosts in development
config.hosts = nil
if Rails.root.join("tmp/email-dev.txt").exist?
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true
else
config.action_mailer.raise_delivery_errors = false
end
config.hosts = %w[ fizzy.localhost localhost 127.0.0.1 ]
# Host to be used in links rendered from ActionMailer
config.action_mailer.default_url_options = { host: config.hosts.first, port: 3006 }
if Rails.root.join("tmp/solid-queue.txt").exist?
config.active_job.queue_adapter = :solid_queue
+3 -6
View File
@@ -74,7 +74,9 @@ Rails.application.configure do
# config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "%{tenant}.fizzy.37signals.com" }
config.action_mailer.default_url_options = { host: "fizzy.37signals.com", protocol: "https" }
config.action_mailer.smtp_settings = { domain: "fizzy.37signals.com", address: "smtp-outbound", port: 25, enable_starttls_auto: false }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
@@ -86,11 +88,6 @@ Rails.application.configure do
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Enable DNS rebinding protection and other `Host` header attacks.
# config.hosts = [
# "example.com", # Allow requests from example.com
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
# ]
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end
+3 -1
View File
@@ -1,5 +1,7 @@
require_relative "production"
Rails.application.configure do
config.action_mailer.default_url_options = { host: "%{tenant}.fizzy.37signals-staging.com" }
config.action_mailer.smtp_settings[:domain] = "fizzy.37signals-staging.com"
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
config.action_mailer.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" }
end
+1 -1
View File
@@ -3,7 +3,7 @@ default: &default
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
- queues: [ "default", "backend" ]
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
+3
View File
@@ -17,6 +17,9 @@ production: &production
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
deliver_bundled_notifications:
command: "Notification::Bundle.deliver_all_later"
schedule: every 30 minutes
beta: *production
staging: *production
+8 -4
View File
@@ -51,10 +51,14 @@ Rails.application.routes.draw do
end
end
namespace :notifications do
resource :settings
resource :unsubscribe
end
resources :notifications do
scope module: :notifications do
get "tray", to: "trays#show", on: :collection
get "settings", to: "settings#show", on: :collection
post "readings", to: "readings#create_all", on: :collection, as: :read_all
post "reading", to: "readings#create", on: :member, as: :read
@@ -162,15 +166,15 @@ Rails.application.routes.draw do
end
resolve "Mention" do |mention, options|
polymorphic_path(mention.source, options)
polymorphic_url(mention.source, options)
end
resolve "Notification" do |notification, options|
polymorphic_path(notification.notifiable_target, options)
polymorphic_url(notification.notifiable_target, options)
end
resolve "Event" do |event, options|
polymorphic_path(event.target, options)
polymorphic_url(event.eventable, options)
end
get "up", to: "rails/health#show", as: :rails_health_check
@@ -0,0 +1,15 @@
class CreateNotificationBundles < ActiveRecord::Migration[8.1]
def change
create_table :notification_bundles do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.datetime :starts_at, null: false
t.datetime :ends_at, null: false
t.integer :status, default: 0, null: false
t.timestamps
end
add_index :notification_bundles, %i[ user_id starts_at ends_at ]
add_index :notification_bundles, %i[ user_id status ]
end
end
@@ -0,0 +1,6 @@
class RemoveUniqueConstraintFromNotificationBundlesUserId < ActiveRecord::Migration[8.1]
def change
remove_index :notification_bundles, :user_id, unique: true
add_index :notification_bundles, %i[ ends_at status ]
end
end
@@ -0,0 +1,12 @@
class CreateNotificationSettings < ActiveRecord::Migration[8.1]
def change
create_table :notification_settings do |t|
t.references :user, null: false, foreign_key: true, index: true
t.integer :bundle_email_frequency, default: 0, null: false
t.timestamps
t.index %i[ user_id bundle_email_frequency ]
end
end
end
@@ -0,0 +1,5 @@
class RenameNotificationSettingsToUserSettings < ActiveRecord::Migration[8.1]
def change
rename_table :notification_settings, :user_settings
end
end
Generated
+11 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_08_27_072601) do
ActiveRecord::Schema[8.1].define(version: 2025_08_28_092106) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -421,6 +421,15 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_27_072601) do
t.datetime "updated_at", null: false
end
create_table "user_settings", force: :cascade do |t|
t.integer "bundle_email_frequency", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency"
t.index ["user_id"], name: "index_user_settings_on_user_id"
end
create_table "users", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
@@ -487,6 +496,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_27_072601) do
add_foreign_key "steps", "cards"
add_foreign_key "taggings", "cards"
add_foreign_key "taggings", "tags"
add_foreign_key "user_settings", "users"
add_foreign_key "watches", "cards"
add_foreign_key "watches", "users"
add_foreign_key "workflow_stages", "workflows"
+52 -1
View File
@@ -1231,6 +1231,21 @@ columns:
- *6
- *35
- *9
user_settings:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: bundle_email_frequency
cast_type: *3
sql_type_metadata: *4
'null': false
default: 0
default_function:
collation:
comment:
- *5
- *6
- *9
- *18
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -1378,6 +1393,7 @@ primary_keys:
steps: id
taggings: id
tags: id
user_settings: id
users: id
watches: id
workflow_stages: id
@@ -1428,6 +1444,7 @@ data_sources:
steps: true
taggings: true
tags: true
user_settings: true
users: true
watches: true
workflow_stages: true
@@ -2841,6 +2858,40 @@ indexes:
comment:
valid: true
tags: []
user_settings:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: user_settings
name: index_user_settings_on_user_id
unique: false
columns:
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: user_settings
name: index_user_settings_on_user_id_and_bundle_email_frequency
unique: false
columns:
- user_id
- bundle_email_frequency
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
@@ -2941,4 +2992,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250827072601
version: 20250828092106
View File
+16
View File
@@ -0,0 +1,16 @@
namespace :dev do
desc "Toggle using Letter Opener to preview emails"
task :email do
file_path = Rails.root.join("tmp", "email-dev.txt")
if File.exist?(file_path)
File.delete(file_path)
puts "Letter Opener turned off"
else
FileUtils.touch(file_path)
puts "Letter Opener turned on"
end
%x(bin/rails restart)
end
end
@@ -0,0 +1,40 @@
require "test_helper"
class Notifications::UnsubscribesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:david)
@access_token = @user.generate_token_for(:unsubscribe)
sign_in_as @user
end
test "new" do
get new_notifications_unsubscribe_path(access_token: @access_token)
assert_response :success
end
test "new with bad token" do
get new_notifications_unsubscribe_path(access_token: "bad")
assert_redirected_to root_path
end
test "create" do
@user.reload.settings.bundle_email_every_few_hours!
assert_changes -> { @user.reload.settings.bundle_email_frequency }, to: "never" do
post notifications_unsubscribe_path(access_token: @access_token)
assert_redirected_to notifications_unsubscribe_path(access_token: @access_token)
end
end
test "create with bad token" do
assert_no_changes -> { @user.reload.settings.bundle_email_frequency } do
post notifications_unsubscribe_path(access_token: "bad")
assert_redirected_to root_path
end
end
test "show" do
get notifications_unsubscribe_path(access_token: @access_token)
assert_response :success
end
end
+18
View File
@@ -0,0 +1,18 @@
_fixture:
model_class: User::Settings
david_settings:
user: david
bundle_email_frequency: never
jz_settings:
user: jz
bundle_email_frequency: never
kevin_settings:
user: kevin
bundle_email_frequency: never
system_settings:
user: system
bundle_email_frequency: never
@@ -0,0 +1,6 @@
class Notification::BundleMailerPreview < ActionMailer::Preview
def notification
ApplicationRecord.current_tenant = "1065895976"
Notification::BundleMailer.notification Notification::Bundle.take!
end
end
+117
View File
@@ -0,0 +1,117 @@
require "test_helper"
class Notification::BundleTest < ActiveSupport::TestCase
setup do
@user = users(:david)
@user.settings.bundle_email_every_few_hours!
end
test "new notifications are bundled" do
notification = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
bundle = @user.notification_bundles.pending.last
assert_includes bundle.notifications, notification
end
test "don't bundle new notifications if bundling is disabled" do
@user.settings.bundle_email_never!
assert_no_difference -> { @user.notification_bundles.count } do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
end
test "notifications are bundled withing the aggregation period" do
notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
travel_to 3.hours.from_now
notification_2 = assert_no_difference -> { @user.notification_bundles.count } do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
travel_to 3.days.from_now
notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@user.notifications.create!(source: events(:logo_published), creator: @user)
end
bundle_1, bundle_2 = @user.notification_bundles.last(2)
assert_includes bundle_1.notifications, notification_1
assert_includes bundle_1.notifications, notification_2
assert_includes bundle_2.notifications, notification_3
end
test "overlapping bundles are invalid" do
bundle_1 = @user.notification_bundles.create!(
starts_at: Time.current,
ends_at: 4.hours.from_now,
status: :pending
)
bundle_2 = @user.notification_bundles.build(
starts_at: 2.hours.from_now,
ends_at: 6.hours.from_now,
status: :pending
)
assert_not bundle_2.valid?
# Bundle with overlapping end time should be invalid
bundle_3 = @user.notification_bundles.build(
starts_at: 2.hours.ago,
ends_at: 2.hours.from_now,
status: :pending
)
assert_not bundle_3.valid?
# Bundle completely within another bundle should be invalid
bundle_4 = @user.notification_bundles.build(
starts_at: 1.hour.from_now,
ends_at: 3.hours.from_now,
status: :pending
)
assert_not bundle_4.valid?
# Non-overlapping bundle should be valid
bundle_5 = @user.notification_bundles.build(
starts_at: 5.hours.from_now,
ends_at: 9.hours.from_now,
status: :pending
)
assert bundle_5.valid?
end
test "deliver_all delivers due bundles" do
notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
bundle = @user.notification_bundles.pending.last
assert bundle.pending?
assert_includes bundle.notifications, notification
bundle.update!(ends_at: 1.minute.ago)
perform_enqueued_jobs only: Notification::Bundle::DeliverJob do
Notification::Bundle.deliver_all
end
bundle.reload
assert bundle.delivered?
end
test "deliver_all don't deliver bundles that are not due" do
@user.notifications.create!(source: events(:logo_published), creator: @user)
bundle = @user.notification_bundles.pending.last
bundle.update!(ends_at: 1.minute.from_now)
perform_enqueued_jobs only: Notification::Bundle::DeliverJob do
Notification::Bundle.deliver_all
end
bundle.reload
assert bundle.pending?
end
end
+8
View File
@@ -0,0 +1,8 @@
require "test_helper"
class User::ConfigurableTest < ActiveSupport::TestCase
test "should create settings for new users" do
user = User.create! name: "Some new user", email_address: "some.new@user.com"
assert user.settings.present?
end
end
+36
View File
@@ -0,0 +1,36 @@
require "test_helper"
class User::SettingsTest < ActiveSupport::TestCase
setup do
@user = users(:david)
@settings = @user.settings
end
test "changing the bundle email frequency to never will cancel pending bundles" do
@settings.update!(bundle_email_frequency: :every_few_hours)
bundle = @user.notification_bundles.create!
@settings.update!(bundle_email_frequency: :never)
assert_nil Notification::Bundle.find_by(id: bundle.id)
end
test "changing the bundle email frequency will deliver pending bundles" do
bundle = @user.notification_bundles.create!
assert bundle.pending?
perform_enqueued_jobs only: Notification::Bundle::DeliverJob do
@settings.update!(bundle_email_frequency: :daily)
end
assert bundle.reload.delivered?
end
test "changing other settings will not affect pending bundles" do
bundle = @user.notification_bundles.create!
perform_enqueued_jobs only: Notification::Bundle::DeliverJob do
@settings.update!(updated_at: 1.hour.from_now)
end
assert bundle.reload.pending?
end
end