From 75c2ce6c7566c8ffbb7b81150d531f01330b5854 Mon Sep 17 00:00:00 2001
From: Sebastian Noe
Date: Mon, 11 May 2026 13:08:57 +0200
Subject: [PATCH] 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
---
README.md | 20 +++++-
.../notifications_settings_controller.rb | 35 ++++++++++
.../submitter_reminders_controller.rb | 29 ++++++++
app/jobs/process_submitter_reminders_job.rb | 16 ++++-
app/jobs/send_submitter_reminder_email_job.rb | 4 +-
app/mailers/submitter_mailer.rb | 34 +++++++++
app/models/account_config.rb | 4 +-
app/models/submission_event.rb | 1 +
.../_reminder_queue.html.erb | 56 +++++++++++++++
.../notifications_settings/index.html.erb | 1 +
.../_reminder_email_form.html.erb | 29 ++++++++
.../personalization_settings/show.html.erb | 1 +
app/views/submissions/show.html.erb | 16 +++++
...nvitation_reminder_email_collapse.html.erb | 9 +++
...er_invitation_reminder_email_form.html.erb | 34 +++++++++
config/initializers/scheduled_jobs.rb | 6 ++
config/locales/i18n.yml | 21 ++++++
config/routes.rb | 1 +
lib/submitter_reminders.rb | 69 +++++++++++++++++++
19 files changed, 379 insertions(+), 7 deletions(-)
create mode 100644 app/controllers/submitter_reminders_controller.rb
create mode 100644 app/views/notifications_settings/_reminder_queue.html.erb
create mode 100644 app/views/personalization_settings/_reminder_email_form.html.erb
create mode 100644 app/views/templates_preferences/_submitter_invitation_reminder_email_form.html.erb
create mode 100644 lib/submitter_reminders.rb
diff --git a/README.md b/README.md
index 27c4f7c7..d20feebb 100644
--- a/README.md
+++ b/README.md
@@ -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):
diff --git a/app/controllers/notifications_settings_controller.rb b/app/controllers/notifications_settings_controller.rb
index f03f09c8..63aa5348 100644
--- a/app/controllers/notifications_settings_controller.rb
+++ b/app/controllers/notifications_settings_controller.rb
@@ -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])
diff --git a/app/controllers/submitter_reminders_controller.rb b/app/controllers/submitter_reminders_controller.rb
new file mode 100644
index 00000000..c4b77c2f
--- /dev/null
+++ b/app/controllers/submitter_reminders_controller.rb
@@ -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
diff --git a/app/jobs/process_submitter_reminders_job.rb b/app/jobs/process_submitter_reminders_job.rb
index 899311b8..16c8b758 100644
--- a/app/jobs/process_submitter_reminders_job.rb
+++ b/app/jobs/process_submitter_reminders_job.rb
@@ -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
diff --git a/app/jobs/send_submitter_reminder_email_job.rb b/app/jobs/send_submitter_reminder_email_job.rb
index d0e8fc01..fd402fe0 100644
--- a/app/jobs/send_submitter_reminder_email_job.rb
+++ b/app/jobs/send_submitter_reminder_email_job.rb
@@ -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!
diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb
index 6736c21d..a7145642 100644
--- a/app/mailers/submitter_mailer.rb
+++ b/app/mailers/submitter_mailer.rb
@@ -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
diff --git a/app/models/account_config.rb b/app/models/account_config.rb
index 5275afb5..bff2c61d 100644
--- a/app/models/account_config.rb
+++ b/app/models/account_config.rb
@@ -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 {
diff --git a/app/models/submission_event.rb b/app/models/submission_event.rb
index e88d192f..0620caad 100644
--- a/app/models/submission_event.rb
+++ b/app/models/submission_event.rb
@@ -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',
diff --git a/app/views/notifications_settings/_reminder_queue.html.erb b/app/views/notifications_settings/_reminder_queue.html.erb
new file mode 100644
index 00000000..66a5bc09
--- /dev/null
+++ b/app/views/notifications_settings/_reminder_queue.html.erb
@@ -0,0 +1,56 @@
+<% if @pending_reminders.present? %>
+
+
+ <%= t('pending_reminders') %>
+
+
+
+
+
+ <%= t('submitter') %>
+ <%= t('template') %>
+ <%= t('last_sent') %>
+ <%= t('next_due') %>
+
+
+
+
+ <% @pending_reminders.each do |entry| %>
+ <%= turbo_frame_tag "reminder_row_#{entry[:submitter].id}" do %>
+
+ <%= entry[:submitter].name.presence || entry[:submitter].email %>
+ <%= entry[:submitter].template&.name %>
+
+ <% if entry[:last_sent_at] %>
+
+ <%= time_ago_in_words(entry[:last_sent_at]) %> <%= t('ago') %>
+
+ <% else %>
+ —
+ <% end %>
+
+
+ <% if entry[:next_at] > Time.current %>
+
+ <%= t('in_time', time: distance_of_time_in_words(Time.current, entry[:next_at])) %>
+
+ <% else %>
+
+ <%= t('overdue') %>
+
+ <% end %>
+
+
+ <%= 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}" } %>
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+<% end %>
diff --git a/app/views/notifications_settings/index.html.erb b/app/views/notifications_settings/index.html.erb
index b163e8e8..db4bec98 100644
--- a/app/views/notifications_settings/index.html.erb
+++ b/app/views/notifications_settings/index.html.erb
@@ -28,6 +28,7 @@
<%= render 'reminder_banner' %>
<%= render 'reminder_form', config: @reminder_config %>
+ <%= render 'reminder_queue' %>
diff --git a/app/views/personalization_settings/_reminder_email_form.html.erb b/app/views/personalization_settings/_reminder_email_form.html.erb
new file mode 100644
index 00000000..56cc9e44
--- /dev/null
+++ b/app/views/personalization_settings/_reminder_email_form.html.erb
@@ -0,0 +1,29 @@
+
+
+
+
+ <%= t('signature_request_reminder_email') %>
+
+
+
+ <%= 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| %>
+
+ <%= ff.label :subject, t('subject'), class: 'label' %>
+ <%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
+
+ <%= render 'personalization_settings/email_body_field', ff:, config: f.object %>
+ <% if can?(:manage, :reply_to) || can?(:manage, :personalization_advanced) %>
+
+ <%= ff.label :reply_to, t('reply_to'), class: 'label' %>
+ <%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
+
+ <% end %>
+ <% end %>
+
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
+
+ <% end %>
+
+
diff --git a/app/views/personalization_settings/show.html.erb b/app/views/personalization_settings/show.html.erb
index 438da311..5e44fcf0 100644
--- a/app/views/personalization_settings/show.html.erb
+++ b/app/views/personalization_settings/show.html.erb
@@ -6,6 +6,7 @@
<%= render 'signature_request_email_form' %>
+ <%= render 'reminder_email_form' %>
<%= render 'documents_copy_email_form' %>
<%= render 'submitter_completed_email_form' %>
diff --git a/app/views/submissions/show.html.erb b/app/views/submissions/show.html.erb
index 83187f47..c0d70ec6 100644
--- a/app/views/submissions/show.html.erb
+++ b/app/views/submissions/show.html.erb
@@ -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'] } %>
@@ -217,6 +218,21 @@
<% end %>
+ <% 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 %>
+
+ <%= svg_icon('clock', class: 'w-5 h-5') %>
+
+ <% 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 %>
+
+
+ <% end %>
+ <% end %>
<% if submitter&.declined_at? %>
diff --git a/app/views/templates_preferences/_submitter_invitation_reminder_email_collapse.html.erb b/app/views/templates_preferences/_submitter_invitation_reminder_email_collapse.html.erb
index e69de29b..739ba184 100644
--- a/app/views/templates_preferences/_submitter_invitation_reminder_email_collapse.html.erb
+++ b/app/views/templates_preferences/_submitter_invitation_reminder_email_collapse.html.erb
@@ -0,0 +1,9 @@
+
+
+
+ <%= t('signature_request_reminder_email') %>
+
+
+ <%= render 'templates_preferences/submitter_invitation_reminder_email_form' %>
+
+
diff --git a/app/views/templates_preferences/_submitter_invitation_reminder_email_form.html.erb b/app/views/templates_preferences/_submitter_invitation_reminder_email_form.html.erb
new file mode 100644
index 00000000..580f1475
--- /dev/null
+++ b/app/views/templates_preferences/_submitter_invitation_reminder_email_form.html.erb
@@ -0,0 +1,34 @@
+
+ <% 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| %>
+
+ <%= 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| %>
+
+
+ <%= 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] %>
+
+ <% end %>
+ <% end %>
+
+
diff --git a/config/initializers/scheduled_jobs.rb b/config/initializers/scheduled_jobs.rb
index b8b4b5a0..e176d0db 100644
--- a/config/initializers/scheduled_jobs.rb
+++ b/config/initializers/scheduled_jobs.rb
@@ -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
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index eacf6304..710bdac6 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index 1556c0fa..96d791c2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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]
diff --git a/lib/submitter_reminders.rb b/lib/submitter_reminders.rb
new file mode 100644
index 00000000..1ee4eff0
--- /dev/null
+++ b/lib/submitter_reminders.rb
@@ -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