diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl
index b0058b6..9f92713 100644
--- a/assets/i18n/en/main.ftl
+++ b/assets/i18n/en/main.ftl
@@ -286,6 +286,11 @@ set-password-submit = Set password
set-password-invalid = This link is invalid or has expired.
set-password-weak = Password must be at least 8 characters.
set-password-mismatch = Passwords don't match.
+resend-verification-title = Resend verification email
+resend-verification-intro = Enter your email and we'll send a fresh verification link.
+resend-verification-submit = Resend
+resend-verification-done = If that email belongs to an unverified account, we've sent a new verification link. Check your inbox (and spam). You can request another in a minute.
+login-resend = Didn't get the verification email? Resend it
order-confirmed-title = Thank you for your order!
order-confirmed-sub = We have received your order.
order-number = Order number
diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl
index e15843f..6453d0c 100644
--- a/assets/i18n/sk/main.ftl
+++ b/assets/i18n/sk/main.ftl
@@ -286,6 +286,11 @@ set-password-submit = Nastaviť heslo
set-password-invalid = Odkaz je neplatný alebo vypršal.
set-password-weak = Heslo musí mať aspoň 8 znakov.
set-password-mismatch = Heslá sa nezhodujú.
+resend-verification-title = Znova odoslať overovací e-mail
+resend-verification-intro = Zadajte svoj e-mail a pošleme vám nový overovací odkaz.
+resend-verification-submit = Odoslať znova
+resend-verification-done = Ak k tomuto e-mailu patrí neoverený účet, poslali sme naň nový overovací odkaz. Skontrolujte si schránku aj priečinok so spamom. Ďalšiu žiadosť môžete odoslať o minútu.
+login-resend = Nedostali ste overovací e-mail? Poslať znova
order-confirmed-title = Ďakujeme za objednávku!
order-confirmed-sub = Vašu objednávku sme prijali.
order-number = Číslo objednávky
diff --git a/assets/views/auth/login.html b/assets/views/auth/login.html
index bb053ea..be04a7f 100644
--- a/assets/views/auth/login.html
+++ b/assets/views/auth/login.html
@@ -22,6 +22,9 @@
{% if error == "unverified" %}
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
+
+ {{ t(key="login-resend", lang=lang | default(value='sk')) }}
+
{% elif error %}
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
{% endif %}
diff --git a/assets/views/auth/resend_verification.html b/assets/views/auth/resend_verification.html
new file mode 100644
index 0000000..98cddde
--- /dev/null
+++ b/assets/views/auth/resend_verification.html
@@ -0,0 +1,37 @@
+{% extends "base.html" %}
+{% import "macros/ui.html" as ui %}
+
+{% block title %}{{ t(key="resend-verification-title", lang=lang | default(value='sk')) }}{% endblock title %}
+
+{% block content %}
+
+
+
+ {{ t(key="brand", lang=lang | default(value='sk')) }}
+ {{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
+
+
+
+
{{ t(key="resend-verification-title", lang=lang | default(value='sk')) }}
+
+ {% if done %}
+
+ {{ t(key="resend-verification-done", lang=lang | default(value='sk')) }}
+
+
+ {{ t(key="nav-login", lang=lang | default(value='sk')) }}
+
+ {% else %}
+
{{ t(key="resend-verification-intro", lang=lang | default(value='sk')) }}
+
+ {% endif %}
+
+
+
+{% endblock content %}
diff --git a/src/controllers/auth_pages.rs b/src/controllers/auth_pages.rs
index 618f7e9..9b0df32 100644
--- a/src/controllers/auth_pages.rs
+++ b/src/controllers/auth_pages.rs
@@ -185,6 +185,62 @@ fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result {
)
}
+/// Resend the email-verification link. Throttled per account (see
+/// [`users::Model::verification_resend_wait_secs`]) so it can't be used to spam
+/// an inbox, and always returns the same neutral message so it can't be used to
+/// probe which addresses are registered.
+#[derive(Debug, serde::Deserialize)]
+struct ResendVerificationForm {
+ email: String,
+}
+
+fn resend_verification_view(v: &TeraView, jar: &CookieJar, done: bool) -> Result {
+ format::view(
+ v,
+ "auth/resend_verification.html",
+ json!({
+ "done": done,
+ "logged_in_admin": false,
+ "lang": current_lang(jar),
+ }),
+ )
+}
+
+#[debug_handler]
+async fn resend_verification_page(
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+) -> Result {
+ resend_verification_view(&v, &jar, false)
+}
+
+#[debug_handler]
+async fn resend_verification(
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ // Resend only for a real, still-unverified account that is past its cooldown.
+ // Anything else (unknown email, already verified, too soon) silently does
+ // nothing — the response is identical either way.
+ if let Ok(user) = users::Model::find_by_email(&ctx.db, form.email.trim()).await {
+ if user.email_verified_at.is_none() && user.verification_resend_wait_secs() == 0 {
+ match user.into_active_model().set_email_verification_sent(&ctx.db).await {
+ Ok(user) => {
+ if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await {
+ tracing::error!(error = %err, "failed to resend verification email");
+ }
+ }
+ Err(err) => tracing::error!(error = %err, "failed to refresh verification token"),
+ }
+ } else {
+ tracing::info!("verification resend skipped (already verified or within cooldown)");
+ }
+ }
+ resend_verification_view(&v, &jar, true)
+}
+
/// Set-password form for accounts created during checkout (and any account that
/// has a valid reset token). Reuses the password-reset token machinery.
#[derive(Debug, serde::Deserialize)]
@@ -276,6 +332,8 @@ pub fn routes() -> Routes {
.add("/register", get(register_page))
.add("/register", post(register))
.add("/verify/{token}", get(verify))
+ .add("/resend-verification", get(resend_verification_page))
+ .add("/resend-verification", post(resend_verification))
.add("/set-password/{token}", get(set_password_page))
.add("/set-password", post(set_password))
.add("/logout", post(logout))
diff --git a/src/models/users.rs b/src/models/users.rs
index a06b823..6db5cbc 100644
--- a/src/models/users.rs
+++ b/src/models/users.rs
@@ -13,6 +13,9 @@ pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model};
pub const MAGIC_LINK_LENGTH: i8 = 32;
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
+/// Minimum gap between verification-email resends for one account, in seconds.
+pub const VERIFICATION_RESEND_COOLDOWN_SECS: i64 = 60;
+
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginParams {
pub email: String,
@@ -238,6 +241,22 @@ impl Model {
self.account_type == "company"
}
+ /// Seconds the user must still wait before another verification email may be
+ /// sent — 0 means a resend is allowed now. Throttling resends off the last
+ /// `email_verification_sent_at` keeps the endpoint from being an easy way to
+ /// spam someone's inbox.
+ #[must_use]
+ pub fn verification_resend_wait_secs(&self) -> i64 {
+ match self.email_verification_sent_at {
+ Some(sent) => {
+ let elapsed =
+ (chrono::Utc::now() - sent.with_timezone(&chrono::Utc)).num_seconds();
+ (VERIFICATION_RESEND_COOLDOWN_SECS - elapsed).max(0)
+ }
+ None => 0,
+ }
+ }
+
/// Asynchronously creates a user with a password and saves it to the
/// database.
///