diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 109828c7b..449fbe0bd 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -21,6 +21,17 @@ def user_acceptance(user)
mail(to: user.email, subject: I18n.t("user_mailer.user_acceptance.subject"))
end
+ def inactive_account_warning(user, disable_date)
+ @user = user
+ @disable_date = disable_date
+ mail(to: user.email, subject: I18n.t("user_mailer.inactive_account_warning.subject", disable_date: I18n.l(disable_date)))
+ end
+
+ def account_deactivated_for_inactivity(user)
+ @user = user
+ mail(to: user.email, subject: I18n.t("user_mailer.account_deactivated_for_inactivity.subject"))
+ end
+
private
def generate_reset_url(user)
diff --git a/app/views/mailers/user_mailer/account_deactivated_for_inactivity.html.mjml b/app/views/mailers/user_mailer/account_deactivated_for_inactivity.html.mjml
new file mode 100644
index 000000000..dd465ed76
--- /dev/null
+++ b/app/views/mailers/user_mailer/account_deactivated_for_inactivity.html.mjml
@@ -0,0 +1,16 @@
+
+ <%= t("mailers.greeting", name: @user.display_name, fallback: true) %>
+
+
+
+ <%= t(".message") %>
+
+
+
+ <%= t(".contact") %>
+
+
+
+ <%= t("mailers.salutation") %>
+ <%= t("mailers.signature") %>
+
diff --git a/app/views/mailers/user_mailer/account_deactivated_for_inactivity.text.erb b/app/views/mailers/user_mailer/account_deactivated_for_inactivity.text.erb
new file mode 100644
index 000000000..79de44ffd
--- /dev/null
+++ b/app/views/mailers/user_mailer/account_deactivated_for_inactivity.text.erb
@@ -0,0 +1,8 @@
+<%= t("mailers.greeting", name: @user.display_name, fallback: true) %>
+
+<%= t(".message") %>
+
+<%= t(".contact") %>
+
+<%= t("mailers.salutation") %>
+<%= t("mailers.signature") %>
diff --git a/app/views/mailers/user_mailer/inactive_account_warning.html.mjml b/app/views/mailers/user_mailer/inactive_account_warning.html.mjml
new file mode 100644
index 000000000..702874d8f
--- /dev/null
+++ b/app/views/mailers/user_mailer/inactive_account_warning.html.mjml
@@ -0,0 +1,20 @@
+
+ <%= t("mailers.greeting", name: @user.display_name, fallback: true) %>
+
+
+
+ <%= t(".message", disable_date: l(@disable_date)) %>
+
+
+
+ <%= t(".cta", link: link_to(ENV["FRONTEND_URL"], ENV["FRONTEND_URL"], target: "_blank")).html_safe %>
+
+
+
+ <%= t("mailers.contact_us") %>
+
+
+
+ <%= t("mailers.salutation") %>
+ <%= t("mailers.signature") %>
+
diff --git a/app/views/mailers/user_mailer/inactive_account_warning.text.erb b/app/views/mailers/user_mailer/inactive_account_warning.text.erb
new file mode 100644
index 000000000..95058db85
--- /dev/null
+++ b/app/views/mailers/user_mailer/inactive_account_warning.text.erb
@@ -0,0 +1,10 @@
+<%= t("mailers.greeting", name: @user.display_name, fallback: true) %>
+
+<%= t(".message", disable_date: l(@disable_date)) %>
+
+<%= t(".cta", link: ENV["FRONTEND_URL"]) %>
+
+<%= t("mailers.contact_us") %>
+
+<%= t("mailers.salutation") %>
+<%= t("mailers.signature") %>
diff --git a/config/locales/mailers.en.yml b/config/locales/mailers.en.yml
index 814352bfe..59fa04cff 100644
--- a/config/locales/mailers.en.yml
+++ b/config/locales/mailers.en.yml
@@ -22,6 +22,14 @@ en:
message: Please click on the link to reset your password to connect to the Open Timber Portal
link_expiration: The above link will expire in 6 hours. If you made multiple requests to reset your password, please make sure to click on the link in the most recent e-mail.
outro: If you didn't request this password reset, please ignore this email. Your password will not change until you access the link above and create a new password.
+ inactive_account_warning:
+ subject: "Open Timber Portal account inactivity notice - disable date %{disable_date}"
+ message: "Your account has not been used for 18 months. Please log in soon to keep it active. If there is no login activity, your account will be disabled on %{disable_date}."
+ cta: "Log in here: %{link}"
+ account_deactivated_for_inactivity:
+ subject: "Your Open Timber Portal account has been deactivated"
+ message: "Because your account has not been used for 2 years, it is now deactivated."
+ contact: "If you need help or think this is an error, please contact the OTP team."
observation_mailer:
observation_created:
subject: Observation %{id} was successfully created
diff --git a/config/locales/mailers.es.yml b/config/locales/mailers.es.yml
index 7cd1a25e5..5af1fec6d 100644
--- a/config/locales/mailers.es.yml
+++ b/config/locales/mailers.es.yml
@@ -22,6 +22,14 @@ es:
message: "Haga clic en el enlace para restablecer su contraseña y conectarse al Open Timber Portal"
link_expiration: "El enlace anterior expirará en 6 horas. Si realizó múltiples solicitudes para restablecer su contraseña, asegúrese de hacer clic en el enlace del correo electrónico más reciente."
outro: "Si no solicitó este restablecimiento de contraseña, ignore este correo electrónico. Su contraseña no cambiará hasta que acceda al enlace anterior y cree una nueva contraseña."
+ inactive_account_warning:
+ subject: "Aviso de inactividad de su cuenta de Open Timber Portal - fecha de desactivacion %{disable_date}"
+ message: "Su cuenta no se ha utilizado durante 18 meses. Inicie sesion pronto para mantenerla activa. Si no hay actividad de inicio de sesion, su cuenta se desactivara el %{disable_date}."
+ cta: "Inicie sesion aqui: %{link}"
+ account_deactivated_for_inactivity:
+ subject: "Su cuenta de Open Timber Portal ha sido desactivada"
+ message: "Debido a que su cuenta no se ha utilizado en 2 anos, ahora esta desactivada."
+ contact: "Si necesita ayuda o cree que esto es un error, contacte al equipo de OTP."
observation_mailer:
observation_created:
subject: "La observación %{id} se creó exitosamente"
diff --git a/config/locales/mailers.fr.yml b/config/locales/mailers.fr.yml
index e7ada8003..70fc9c8c8 100644
--- a/config/locales/mailers.fr.yml
+++ b/config/locales/mailers.fr.yml
@@ -22,6 +22,14 @@ fr:
message: Veuillez cliquer sur le lien afin de réinitialiser votre mot de passe pour vous connecter à l’Open Timber Portal
link_expiration: Le lien ci-dessus expire après 6 heures. Si vous avez fait plusieurs demandes de réinitialisation, veuillez vous assurer de cliquer sur le lien qui figure dans le mail le plus récent.
outro: Si vous n’avez pas fait cette demande de réinitialisation, veuillez ignorer cet e-mail. Votre mot de passe ne changera pas tant que vous n'aurez pas cliqué sur le lien ci-dessus et créé un nouveau mot de passe.
+ inactive_account_warning:
+ subject: "Avis d'inactivite du compte Open Timber Portal - date de desactivation %{disable_date}"
+ message: "Votre compte n'a pas ete utilise depuis 18 mois. Connectez-vous bientot pour le garder actif. Sans nouvelle connexion, votre compte sera desactive le %{disable_date}."
+ cta: "Connectez-vous ici: %{link}"
+ account_deactivated_for_inactivity:
+ subject: "Votre compte Open Timber Portal a ete desactive"
+ message: "Parce que votre compte n'a pas ete utilise depuis 2 ans, il est maintenant desactive."
+ contact: "Si vous avez besoin d'aide ou pensez qu'il s'agit d'une erreur, contactez l'equipe OTP."
observation_mailer:
observation_created:
subject: L’observation %{id} a été créée
diff --git a/config/schedule.rb b/config/schedule.rb
index a48883924..6ec8624d5 100644
--- a/config/schedule.rb
+++ b/config/schedule.rb
@@ -16,6 +16,7 @@
rake "scheduler:set_active_fmu_operator", check_in: "update-fmus"
rake "scheduler:generate_documents_stats", check_in: "generate-documents-stats"
rake "scheduler:generate_observation_reports_stats", check_in: "generate-observation-reports-stats"
+ rake "scheduler:deactivate_inactive_users", check_in: "deactivate-inactive-users"
end
every 1.hour do
diff --git a/db/migrate/20260430153500_add_last_inactivity_warning_sent_at_to_users.rb b/db/migrate/20260430153500_add_last_inactivity_warning_sent_at_to_users.rb
new file mode 100644
index 000000000..d3e94fefe
--- /dev/null
+++ b/db/migrate/20260430153500_add_last_inactivity_warning_sent_at_to_users.rb
@@ -0,0 +1,5 @@
+class AddLastInactivityWarningSentAtToUsers < ActiveRecord::Migration[7.2]
+ def change
+ add_column :users, :last_inactivity_warning_sent_at, :datetime
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7d6974c30..d714b857e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_02_23_113655) do
+ActiveRecord::Schema[7.2].define(version: 2026_04_30_153500) do
create_schema "tiger"
create_schema "tiger_data"
create_schema "topology"
@@ -1122,6 +1122,7 @@
t.string "last_name"
t.boolean "organization_account", default: false, null: false
t.boolean "should_change_password", default: false, null: false
+ t.datetime "last_inactivity_warning_sent_at"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["name"], name: "index_users_on_name"
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
diff --git a/lib/tasks/scheduler.rake b/lib/tasks/scheduler.rake
index e7dd5e5c0..e16683370 100644
--- a/lib/tasks/scheduler.rake
+++ b/lib/tasks/scheduler.rake
@@ -90,4 +90,47 @@ namespace :scheduler do
Rails.logger.info "Sent quarterly newsletters to operators. It took #{time} ms."
Rails.logger.info "::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::"
end
+
+ desc "Warn and deactivate inactive users"
+ task deactivate_inactive_users: :environment do
+ Rails.logger.info "::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::"
+ Rails.logger.info "Going to process inactive users at: #{Time.zone.now.strftime("%d/%m/%Y %H:%M")}"
+
+ warning_threshold = 18.months.ago.end_of_day
+ warning_cooldown = 1.month.ago.end_of_day
+ deactivation_threshold = 2.years.ago.end_of_day
+
+ warned_users = 0
+ deactivated_users = 0
+
+ time = Benchmark.ms do
+ warning_users = User.where(is_active: true)
+ .where("COALESCE(last_sign_in_at, created_at) <= ?", warning_threshold)
+ .where("COALESCE(last_sign_in_at, created_at) > ?", deactivation_threshold)
+ .where("last_inactivity_warning_sent_at IS NULL OR last_inactivity_warning_sent_at <= ?", warning_cooldown)
+
+ warning_users.find_each do |user|
+ disable_date = (user.last_sign_in_at || user.created_at).to_date + 2.years
+ I18n.with_locale(user.locale.presence || I18n.default_locale) do
+ UserMailer.inactive_account_warning(user, disable_date).deliver_now
+ end
+ user.update!(last_inactivity_warning_sent_at: Time.zone.now)
+ warned_users += 1
+ end
+
+ users_to_deactivate = User.where(is_active: true)
+ .where("COALESCE(last_sign_in_at, created_at) <= ?", deactivation_threshold)
+
+ users_to_deactivate.find_each do |user|
+ user.update!(is_active: false, deactivated_at: Time.zone.now)
+ I18n.with_locale(user.locale.presence || I18n.default_locale) do
+ UserMailer.account_deactivated_for_inactivity(user).deliver_now
+ end
+ deactivated_users += 1
+ end
+ end
+
+ Rails.logger.info "Processed inactive users. Warned=#{warned_users}, Deactivated=#{deactivated_users}. It took #{time} ms."
+ Rails.logger.info "::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::"
+ end
end
diff --git a/spec/lib/tasks/deactivate_inactive_users_spec.rb b/spec/lib/tasks/deactivate_inactive_users_spec.rb
new file mode 100644
index 000000000..30f25a7a8
--- /dev/null
+++ b/spec/lib/tasks/deactivate_inactive_users_spec.rb
@@ -0,0 +1,70 @@
+require "rails_helper"
+
+Rails.application.load_tasks if Rake::Task.tasks.empty?
+
+describe "deactivate_inactive_users" do
+ include ActiveSupport::Testing::TimeHelpers
+
+ after(:each) do
+ Rake::Task["scheduler:deactivate_inactive_users"].reenable
+ ActionMailer::Base.deliveries.clear
+ end
+
+ subject(:run_task) { Rake::Task["scheduler:deactivate_inactive_users"].invoke }
+
+ around do |example|
+ travel_to(Time.zone.parse("2026-04-30 10:00:00")) { example.run }
+ end
+
+ it "sends warning emails to users inactive for at least 18 months" do
+ warned_user = create(:operator_user, last_sign_in_at: 18.months.ago - 1.day)
+ create(:operator_user, last_sign_in_at: 17.months.ago)
+
+ expect { run_task }.to change { ActionMailer::Base.deliveries.count }.by(1)
+
+ mail = ActionMailer::Base.deliveries.first
+ expect(mail.to).to eq([warned_user.email])
+ expect(mail.body.encoded).to include(I18n.l(warned_user.last_sign_in_at.to_date + 2.years))
+ end
+
+ it "does not send warning email again within one month" do
+ create(
+ :operator_user,
+ last_sign_in_at: 19.months.ago,
+ last_inactivity_warning_sent_at: 20.days.ago
+ )
+
+ expect { run_task }.not_to change { ActionMailer::Base.deliveries.count }
+ end
+
+ it "sends warning email again when one month has passed since last warning" do
+ warned_user = create(
+ :operator_user,
+ last_sign_in_at: 19.months.ago,
+ last_inactivity_warning_sent_at: 1.month.ago - 1.day
+ )
+
+ expect { run_task }.to change { ActionMailer::Base.deliveries.count }.by(1)
+ expect(warned_user.reload.last_inactivity_warning_sent_at).to be_present
+ end
+
+ it "deactivates users inactive for 2 years or more" do
+ old_user = create(:operator_user, is_active: true, last_sign_in_at: 2.years.ago - 1.day, deactivated_at: nil)
+ active_user = create(:operator_user, is_active: true, last_sign_in_at: 1.year.ago)
+
+ expect { run_task }.to change { old_user.reload.is_active }.from(true).to(false)
+ expect(active_user.reload.is_active).to eq(true)
+
+ expect(old_user.reload.deactivated_at).to be_present
+ expect(ActionMailer::Base.deliveries.last.to).to eq([old_user.email])
+ expect(ActionMailer::Base.deliveries.last.subject).to eq(I18n.t("user_mailer.account_deactivated_for_inactivity.subject"))
+ end
+
+ it "uses created_at when user has never logged in" do
+ never_logged_user = create(:operator_user, last_sign_in_at: nil, created_at: 18.months.ago)
+
+ expect { run_task }.to change { ActionMailer::Base.deliveries.count }.by(1)
+
+ expect(ActionMailer::Base.deliveries.first.to).to eq([never_logged_user.email])
+ end
+end
diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb
index fc61fc48c..1d4fadd14 100644
--- a/spec/mailers/user_mailer_spec.rb
+++ b/spec/mailers/user_mailer_spec.rb
@@ -30,4 +30,31 @@
expect(mail.body.encoded).to match(I18n.t("user_mailer.forgotten_password.message"))
end
end
+
+ describe "inactive_account_warning" do
+ let(:disable_date) { Date.new(2028, 1, 1) }
+ let(:mail) { UserMailer.inactive_account_warning(user, disable_date) }
+
+ it "renders the headers" do
+ expect(mail.subject).to eq(I18n.t("user_mailer.inactive_account_warning.subject", disable_date: I18n.l(disable_date)))
+ expect(mail.to).to eq([user.email])
+ end
+
+ it "renders the body" do
+ expect(mail.body.encoded).to match(I18n.t("user_mailer.inactive_account_warning.message", disable_date: I18n.l(disable_date)))
+ end
+ end
+
+ describe "account_deactivated_for_inactivity" do
+ let(:mail) { UserMailer.account_deactivated_for_inactivity(user) }
+
+ it "renders the headers" do
+ expect(mail.subject).to eq(I18n.t("user_mailer.account_deactivated_for_inactivity.subject"))
+ expect(mail.to).to eq([user.email])
+ end
+
+ it "renders the body" do
+ expect(mail.body.encoded).to match(I18n.t("user_mailer.account_deactivated_for_inactivity.message"))
+ end
+ end
end