feat: reminder email templates, job dedup fix, and reminder visibility (#3)

* feat: add customizable reminder email templates

- Add SubmitterMailer#reminder_email with separate subject/body resolution
- Support per-template and per-account reminder email customization
- Add GUI forms for reminder email templates (account + template level)
- Add i18n keys for reminder emails and reminder visibility UI
- Update SendSubmitterReminderEmailJob to use reminder_email instead of invitation_email
- Add race condition guard (1-minute dedup) in send job

* fix: prevent reminder job duplication on container restart

- Add deduplication in scheduled_jobs.rb initializer (clear existing before scheduling)
- Add reschedule! method in ProcessSubmitterRemindersJob that clears stale copies
- Prevents exponential job accumulation across container restarts

* feat: add reminder visibility and queue management

- Add SubmitterReminders module (lib/) for next-reminder-at calculation
- Show next reminder time per submitter on submission page (with timezone tooltip)
- Add pending reminders queue table on notifications settings page
- Add Skip button to advance past current pending reminder (Turbo Stream)
- Add skip_reminder_email event type to SubmissionEvent
- Update ProcessSubmitterRemindersJob to count skip events in reminder_count
- Add SubmitterRemindersController with destroy action for skip

* docs: document reminder email templates feature in README

---------

Co-authored-by: Sebastian Noe <sebastian.schneider@boxine.de>
pull/681/head
Sebastian Noe 1 month ago committed by GitHub
parent f70c6ebabf
commit 75c2ce6c75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -18,7 +18,7 @@ Support companies that are providing open-source solutions!
| Feature | Status | Description |
|---------|--------|-------------|
| Company logo / white-label | Done | Upload your logo in Settings > Personalization. Displayed in signing forms and emails. |
| Automated reminders | Done | Configure reminder intervals per-account. Pending signers receive scheduled follow-up emails. |
| Automated reminders | Done | Scheduled follow-up emails with customizable templates, reminder queue visibility, and skip controls. |
| Template creation via API | Done | `POST /api/templates/pdf` and `PUT /api/templates/:id/documents` — create and manage templates programmatically with field coordinates or embedded text tags. |
| Professional email design | Done | Table-based responsive email layout with company branding, styled CTA buttons, and proper footer. |
| Teams & user roles | Done | Multi-team support with admin/editor roles. Editors see only their team's documents. Admins can move folders between teams. |
@ -42,6 +42,24 @@ This fork implements team-based access control with two roles:
- API tokens respect the user's role and team membership
- Migrations handle both greenfield installs and existing deployments (auto-creates a "Default" team and backfills)
## Automated Reminders
Reminder emails are sent to pending signers on a configurable schedule.
**Configuration:**
- Set reminder interval (e.g., every 2 days) in Settings > Notifications
- Customize reminder email subject and body at account level (Settings > Personalization) or per-template
- Supports the same template variables as invitation emails (submitter name, template name, link, etc.)
**Visibility & Controls:**
- Submission page shows the next scheduled reminder time per submitter (with timezone tooltip)
- Settings > Notifications includes a pending reminders queue table showing all upcoming reminders
- Skip button lets you advance past a pending reminder without sending it (fires a `skip_reminder_email` event)
**Reliability:**
- Deduplication guard prevents the same reminder from being sent twice within 1 minute
- Job scheduling handles container restarts gracefully (clears stale scheduled jobs before re-registering)
## What's NOT included
These Pro features remain unavailable in this fork (they require significant UI/infrastructure work):

@ -3,6 +3,7 @@
class NotificationsSettingsController < ApplicationController
before_action :load_bcc_config, only: :index
before_action :load_reminder_config, only: :index
before_action :load_pending_reminders, only: :index
authorize_resource :bcc_config, only: :index
authorize_resource :reminder_config, only: :index
@ -38,6 +39,40 @@ class NotificationsSettingsController < ApplicationController
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMINDERS)
end
def load_pending_reminders
@pending_reminders = []
return unless @reminder_config&.value.is_a?(Hash)
submitters = Submitter
.joins(:submission)
.where(account_id: current_account.id)
.where.not(sent_at: nil)
.where(completed_at: nil, declined_at: nil)
.where.not(email: [nil, ''])
.where(submissions: { archived_at: nil })
.includes(:submission, :template, :submission_events)
.limit(50)
submitters.each do |submitter|
next if submitter.template&.archived_at?
next_at = SubmitterReminders.next_reminder_at(submitter, @reminder_config)
next unless next_at
last_reminder = submitter.submission_events
.select { |e| e.event_type.in?(%w[send_reminder_email skip_reminder_email]) }
.max_by(&:created_at)
@pending_reminders << {
submitter: submitter,
next_at: next_at,
last_sent_at: last_reminder&.created_at
}
end
@pending_reminders.sort_by! { |r| r[:next_at] }
end
def email_config_params
params.require(:account_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs|
attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMINDERS])

@ -0,0 +1,29 @@
# frozen_string_literal: true
class SubmitterRemindersController < ApplicationController
before_action :load_submitter
authorize_resource :submitter
def destroy
SubmissionEvent.create!(
submitter: @submitter,
event_type: 'skip_reminder_email'
)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.remove("reminder_row_#{@submitter.id}")
end
format.html do
redirect_back fallback_location: settings_notifications_path,
notice: I18n.t('reminder_skipped')
end
end
end
private
def load_submitter
@submitter = current_account.submitters.find(params[:id])
end
end

@ -10,11 +10,21 @@ class ProcessSubmitterRemindersJob
process_account_reminders(config)
end
ProcessSubmitterRemindersJob.perform_in(1.hour)
reschedule!
end
private
def reschedule!
require 'sidekiq/api'
Sidekiq::ScheduledSet.new
.select { |j| j.klass == 'ProcessSubmitterRemindersJob' }
.each(&:delete)
ProcessSubmitterRemindersJob.perform_in(1.hour)
end
def process_account_reminders(config)
durations = parse_durations(config.value)
return if durations.empty?
@ -35,7 +45,7 @@ class ProcessSubmitterRemindersJob
end
def send_reminder_if_due(submitter, durations)
reminder_count = submitter.submission_events.where(event_type: 'send_reminder_email').count
reminder_count = submitter.submission_events.where(event_type: %w[send_reminder_email skip_reminder_email]).count
duration = case reminder_count
when 0 then durations[:first]
@ -50,7 +60,7 @@ class ProcessSubmitterRemindersJob
submitter.sent_at
else
submitter.submission_events
.where(event_type: 'send_reminder_email')
.where(event_type: %w[send_reminder_email skip_reminder_email])
.order(:created_at)
.last&.created_at || submitter.sent_at
end

@ -14,8 +14,10 @@ class SendSubmitterReminderEmailJob
return if submitter.template&.archived_at?
return unless submitter.email.to_s.include?('@')
return unless Accounts.can_send_emails?(submitter.account)
return if submitter.submission_events.where(event_type: 'send_reminder_email')
.where('created_at > ?', 1.minute.ago).exists?
mail = SubmitterMailer.invitation_email(submitter)
mail = SubmitterMailer.reminder_email(submitter)
mail.deliver_now!

@ -45,6 +45,40 @@ class SubmitterMailer < ApplicationMailer
end
end
def reminder_email(submitter)
@current_account = submitter.submission.account
@submitter = submitter
@body = @submitter.template&.preferences&.dig('invitation_reminder_email_body').presence
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY)
@body ||= fetch_config_email_body(@email_config, @submitter)
@subject = @submitter.template&.preferences&.dig('invitation_reminder_email_subject').presence
assign_message_metadata('submitter_reminder', @submitter)
reply_to = build_submitter_reply_to(@submitter, email_config: @email_config)
maybe_set_custom_domain(@submitter)
I18n.with_locale(@current_account.locale) do
subject = if @email_config || @subject
ReplaceEmailVariables.call(@subject || @email_config.value['subject'], submitter:)
else
I18n.t(:reminder_you_are_invited_to_sign_a_document)
end
mail(
to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to:,
template_name: 'invitation_email'
)
end
end
def completed_email(submitter, user, to: nil)
@current_account = submitter.submission.account
@submitter = submitter

@ -77,8 +77,8 @@ class AccountConfig < ApplicationRecord
},
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda {
{
'subject' => I18n.t(:you_are_invited_to_sign_a_document),
'body' => I18n.t(:submitter_invitation_email_sign_body)
'subject' => I18n.t(:reminder_you_are_invited_to_sign_a_document),
'body' => I18n.t(:submitter_reminder_email_sign_body)
}
},
SUBMITTER_COMPLETED_EMAIL_KEY => lambda {

@ -46,6 +46,7 @@ class SubmissionEvent < ApplicationRecord
bounce_email: 'bounce_email',
complaint_email: 'complaint_email',
send_reminder_email: 'send_reminder_email',
skip_reminder_email: 'skip_reminder_email',
send_sms: 'send_sms',
send_2fa_sms: 'send_2fa_sms',
send_2fa_email: 'send_2fa_email',

@ -0,0 +1,56 @@
<% if @pending_reminders.present? %>
<div class="mt-8">
<h3 class="text-2xl font-bold mb-4">
<%= t('pending_reminders') %>
</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th><%= t('submitter') %></th>
<th><%= t('template') %></th>
<th><%= t('last_sent') %></th>
<th><%= t('next_due') %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @pending_reminders.each do |entry| %>
<%= turbo_frame_tag "reminder_row_#{entry[:submitter].id}" do %>
<tr>
<td><%= entry[:submitter].name.presence || entry[:submitter].email %></td>
<td><%= entry[:submitter].template&.name %></td>
<td>
<% if entry[:last_sent_at] %>
<span class="tooltip tooltip-bottom" data-tip="<%= l(entry[:last_sent_at].in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %> <%= current_account.timezone %>">
<%= time_ago_in_words(entry[:last_sent_at]) %> <%= t('ago') %>
</span>
<% else %>
<% end %>
</td>
<td>
<% if entry[:next_at] > Time.current %>
<span class="tooltip tooltip-bottom" data-tip="<%= l(entry[:next_at].in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %> <%= current_account.timezone %>">
<%= t('in_time', time: distance_of_time_in_words(Time.current, entry[:next_at])) %>
</span>
<% else %>
<span class="tooltip tooltip-bottom" data-tip="<%= l(entry[:next_at].in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %> <%= current_account.timezone %>">
<%= t('overdue') %>
</span>
<% end %>
</td>
<td>
<%= button_to t('skip'), settings_submitter_reminder_path(entry[:submitter]),
method: :delete,
class: 'btn btn-xs btn-outline',
data: { turbo_frame: "reminder_row_#{entry[:submitter].id}" } %>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>

@ -28,6 +28,7 @@
</div>
<%= render 'reminder_banner' %>
<%= render 'reminder_form', config: @reminder_config %>
<%= render 'reminder_queue' %>
</div>
<div class="w-0 md:w-52"></div>
</div>

@ -0,0 +1,29 @@
<div class="collapse collapse-plus bg-base-200 overflow-visible">
<input type="checkbox">
<div class="collapse-title text-xl font-medium capitalize">
<div>
<%= t('signature_request_reminder_email') %>
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body, :reply_to).new(*f.object.value.values_at('subject', 'body', 'reply_to')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, t('subject'), class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
</div>
<%= render 'personalization_settings/email_body_field', ff:, config: f.object %>
<% if can?(:manage, :reply_to) || can?(:manage, :personalization_advanced) %>
<div class="form-control">
<%= ff.label :reply_to, t('reply_to'), class: 'label' %>
<%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
</div>
<% end %>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
</div>
<% end %>
</div>
</div>

@ -6,6 +6,7 @@
</p>
<div class="space-y-4">
<%= render 'signature_request_email_form' %>
<%= render 'reminder_email_form' %>
<%= render 'documents_copy_email_form' %>
<%= render 'submitter_completed_email_form' %>
</div>

@ -148,6 +148,7 @@
<% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600 bg-pink-500 bg-cyan-500 bg-orange-500 bg-lime-500 bg-indigo-500] %>
<% submitter_fields_index = (@submission.template_fields || @submission.template.fields).group_by { |f| f['submitter_uuid'] } %>
<% submitter_field_counters = Hash.new { 0 } %>
<% reminder_config_for_page = signed_in? ? AccountConfig.find_by(account_id: @submission.account_id, key: AccountConfig::SUBMITTER_REMINDERS) : nil %>
<% (@submission.template_submitters || @submission.template.submitters).each_with_index do |item, index| %>
<% submitter = @submission.submitters.find { |e| e.uuid == item['uuid'] } %>
<div class="sticky -top-1 bg-base-100 pt-1 -mt-1">
@ -217,6 +218,21 @@
<% end %>
</span>
</div>
<% if signed_in? && submitter && !submitter.completed_at? && !submitter.declined_at? && submitter.sent_at? %>
<% next_at = SubmitterReminders.next_reminder_at(submitter, reminder_config_for_page) if reminder_config_for_page %>
<% if next_at %>
<div class="flex items-center space-x-1 mt-1">
<%= svg_icon('clock', class: 'w-5 h-5') %>
<span class="text-sm opacity-70 tooltip tooltip-bottom" data-tip="<%= l(next_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale) %> <%= @submission.account.timezone %>">
<% if next_at > Time.current %>
<%= t('next_reminder_in_time', time: distance_of_time_in_words(Time.current, next_at)) %>
<% else %>
<%= t('reminder_overdue') %>
<% end %>
</span>
</div>
<% end %>
<% end %>
<% if submitter&.declined_at? %>
<div class="flex items-center space-x-1 mt-1">
<span>

@ -0,0 +1,9 @@
<div class="collapse collapse-arrow join-item border border-base-300">
<input type="checkbox" name="accordion">
<div class="collapse-title text-xl font-medium">
<%= t('signature_request_reminder_email') %>
</div>
<div class="collapse-content">
<%= render 'templates_preferences/submitter_invitation_reminder_email_form' %>
</div>
</div>

@ -0,0 +1,34 @@
<div id="<%= AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY %>_form">
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY).value %>
<% template_email_preferences_values = @template.preferences.values_at('invitation_reminder_email_subject', 'invitation_reminder_email_body').compact_blank.presence %>
<% is_custom_template_email = template_email_preferences_values.present? %>
<% if is_custom_template_email %>
<%= button_to nil, template_preferences_path(@template), id: 'submitter_invitation_reminder_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
<% end %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_invitation_reminder_email_template_form' }, data: { close_on_submit: false } do |f| %>
<toggle-on-submit data-element-id="email_saved_alert_reminder"></toggle-on-submit>
<%= f.fields_for :preferences, Struct.new(:invitation_reminder_email_subject, :invitation_reminder_email_body).new(@template.preferences['invitation_reminder_email_subject'].presence || configs['subject'], @template.preferences['invitation_reminder_email_body'].presence || configs['body']) do |ff| %>
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :invitation_reminder_email_subject, t('email_subject'), class: 'label' %>
<% if is_custom_template_email %>
<label for="submitter_invitation_reminder_email_reset_link" class="label underline">
<%= t('reset_default') %>
</label>
<% end %>
</div>
<%= ff.text_field :invitation_reminder_email_subject, required: true, class: 'base-input', dir: 'auto' %>
</div>
<div class="form-control">
<%= ff.label :invitation_reminder_email_body, t('email_body'), class: 'label' %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:invitation_reminder_email_body), value: ff.object.invitation_reminder_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY] %>
</div>
<% end %>
<% end %>
<div class="form-control pt-2">
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_invitation_reminder_email_template_form', class: 'base-button' %>
<div class="flex justify-center">
<span id="email_saved_alert_reminder" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
</div>
</div>
</div>

@ -1,5 +1,11 @@
# frozen_string_literal: true
ActiveSupport.on_load(:sidekiq_config) do
require 'sidekiq/api'
Sidekiq::ScheduledSet.new
.select { |j| j.klass == 'ProcessSubmitterRemindersJob' }
.each(&:delete)
ProcessSubmitterRemindersJob.perform_in(1.minute)
end

@ -114,6 +114,18 @@ en: &en
Please contact us by replying to this email if you have any questions.
Thanks,
{account.name}
reminder_you_are_invited_to_sign_a_document: 'Reminder: You have a document awaiting your signature'
submitter_reminder_email_sign_body: |
Hi there,
This is a friendly reminder that you have been invited to sign the "{template.name}".
[Review and Sign]({submitter.link})
Please contact us by replying to this email if you have any questions.
Thanks,
{account.name}
submitter_completed_email_body: |
@ -301,6 +313,15 @@ en: &en
first_reminder_in: First reminder in
second_reminder_in: Second reminder in
third_reminder_in: Third reminder in
next_reminder_in_time: "Next reminder: in %{time}"
reminder_overdue: Reminder overdue
pending_reminders: Pending Reminders
last_sent: Last Sent
next_due: Next Due
in_time: "in %{time}"
overdue: Overdue
ago: ago
reminder_skipped: Reminder skipped
learn_more: Learn More
unable_to_save: Unable to save.
invalid_timeserver: Invalid Timeserver

@ -189,6 +189,7 @@ Rails.application.routes.draw do
resources :email, only: %i[index create], controller: 'email_smtp_settings'
resources :sso, only: %i[index], controller: 'sso_settings'
resources :notifications, only: %i[index create], controller: 'notifications_settings'
resources :submitter_reminders, only: [:destroy], controller: 'submitter_reminders'
resource :esign, only: %i[show create new update destroy], controller: 'esign_settings'
resources :teams, only: %i[index new create edit update destroy]
resources :users, only: %i[index]

@ -0,0 +1,69 @@
# frozen_string_literal: true
module SubmitterReminders
module_function
def next_reminder_at(submitter, reminder_config)
return nil unless reminder_config&.value.is_a?(Hash)
return nil if submitter.completed_at? || submitter.declined_at?
return nil if submitter.submission.archived_at?
return nil if submitter.template&.archived_at?
return nil unless submitter.sent_at
durations = parse_durations(reminder_config.value)
return nil if durations.empty?
reminder_events = submitter.submission_events
.select { |e| e.event_type.in?(%w[send_reminder_email skip_reminder_email]) }
reminder_count = reminder_events.size
duration = case reminder_count
when 0 then durations[:first]
when 1 then durations[:second]
when 2 then durations[:third]
end
return nil unless duration
base_time = if reminder_count == 0
submitter.sent_at
else
reminder_events.max_by(&:created_at)&.created_at || submitter.sent_at
end
return nil if base_time.nil?
base_time + duration
end
def parse_durations(value)
return {} unless value.is_a?(Hash)
result = {}
result[:first] = duration_to_seconds(value['first_duration']) if value['first_duration'].present?
result[:second] = duration_to_seconds(value['second_duration']) if value['second_duration'].present?
result[:third] = duration_to_seconds(value['third_duration']) if value['third_duration'].present?
result
end
def duration_to_seconds(key)
case key
when 'one_hour' then 1.hour
when 'two_hours' then 2.hours
when 'four_hours' then 4.hours
when 'eight_hours' then 8.hours
when 'twelve_hours' then 12.hours
when 'twenty_four_hours' then 24.hours
when 'two_days' then 2.days
when 'three_days' then 3.days
when 'four_days' then 4.days
when 'five_days' then 5.days
when 'six_days' then 6.days
when 'seven_days' then 7.days
when 'eight_days' then 8.days
when 'fifteen_days' then 15.days
when 'twenty_one_days' then 21.days
when 'thirty_days' then 30.days
end
end
end
Loading…
Cancel
Save