3 Commits

Author SHA1 Message Date
Priec
11762728c9 mail sent over test working fully now
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-19 10:54:30 +02:00
Priec
ebb208baba not working smtp test 2026-06-19 10:40:43 +02:00
Priec
7cba3d9eba resend verification mail 2026-06-19 01:05:18 +02:00
12 changed files with 286 additions and 16 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -22,6 +22,9 @@
{% if error == "unverified" %}
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">
<a href="/resend-verification" class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="login-resend", lang=lang | default(value='sk')) }}</a>
</p>
{% elif error %}
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
{% endif %}

View File

@@ -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 %}
<div class="mx-auto mt-8 max-w-sm">
<div class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="brand", lang=lang | default(value='sk')) }}</span>
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
</div>
<div class="p-5">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="resend-verification-title", lang=lang | default(value='sk')) }}</h1>
{% if done %}
<div class="mt-3 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
{{ t(key="resend-verification-done", lang=lang | default(value='sk')) }}
</div>
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
<a href="/login" class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
</p>
{% else %}
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="resend-verification-intro", lang=lang | default(value='sk')) }}</p>
<form method="post" action="/resend-verification" hx-boost="false" class="mt-4 flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="login-email", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
</div>
{{ ui::button(label=t(key="resend-verification-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
</form>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

@@ -42,33 +42,58 @@ workers:
# Mailer Configuration.
# Defaults keep the whole suite on the in-memory stub mailer. The real-SMTP
# smoke test (tests/mailer/smtp_send.rs) opts in by setting these env vars
# before boot; nothing else in the suite sends real mail.
mailer:
stub: true
stub: {{ get_env(name="MAILER_STUB", default="true") }}
# SMTP mailer configuration.
smtp:
# Enable/Disable smtp mailer.
enable: true
enable: {{ get_env(name="SMTP_ENABLE", default="true") }}
# SMTP server host. e.x localhost, smtp.gmail.com
host: localhost
host: "{{ get_env(name="SMTP_HOST", default="localhost") }}"
# SMTP server port
port: 1025
port: {{ get_env(name="SMTP_PORT", default="1025") }}
# Use secure connection (SSL/TLS).
secure: false
# auth:
# user:
# password:
secure: {{ get_env(name="SMTP_SECURE", default="false") }}
auth:
user: "{{ get_env(name="SMTP_USER", default="") }}"
password: "{{ get_env(name="SMTP_PASSWORD", default="") }}"
# Initializers Configuration
# initializers:
# oauth2:
# authorization_code: # Authorization code grant type
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
# ... other fields
# OAuth2StoreInitializer requires this block to boot (it builds the client store
# in after_routes). Static, non-secret placeholders: tests never perform a real
# OAuth2 handshake, they just need the store to construct successfully.
initializers:
oauth2:
# Private-cookie key: a ", "-separated list of >=64 byte values (not a
# plain string). This is loco-oauth2's documented sample key; fine for tests.
secret_key: "144, 76, 183, 1, 15, 184, 233, 174, 214, 251, 190, 186, 122, 61, 74, 84, 225, 110, 189, 115, 10, 251, 133, 128, 52, 46, 15, 66, 85, 1, 245, 73, 27, 113, 189, 15, 209, 205, 61, 100, 73, 31, 18, 58, 235, 105, 141, 36, 70, 92, 231, 151, 27, 32, 243, 117, 30, 244, 110, 89, 233, 196, 137, 130"
authorization_code:
- client_identifier: google
client_credentials:
client_id: test-client-id
client_secret: test-client-secret
url_config:
auth_url: https://accounts.google.com/o/oauth2/auth
token_url: https://www.googleapis.com/oauth2/v3/token
redirect_url: http://localhost:5150/api/oauth2/google/callback
profile_url: https://openidconnect.googleapis.com/v1/userinfo
scopes:
- https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile
cookie_config:
protected_url: http://localhost:5150/
timeout_seconds: 600
# Database Configuration
database:
# Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test") }}
# Database connection URI. Pinned to the throwaway test DB and intentionally
# NOT read from `DATABASE_URL`: the app loads `.env` on boot (app.rs
# `load_config`), and this config has `dangerously_recreate: true`, so honoring
# an env override here would let `cargo test` recreate the dev/prod database.
uri: "postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test"
# When enabled, the sql query will be logged.
enable_logging: false
# Set the timeout duration when acquiring a connection.

View File

@@ -185,6 +185,62 @@ fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result<Response> {
)
}
/// 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<Response> {
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<TeraView>,
) -> Result<Response> {
resend_verification_view(&v, &jar, false)
}
#[debug_handler]
async fn resend_verification(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(form): Form<ResendVerificationForm>,
) -> Result<Response> {
// 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))

View File

@@ -13,7 +13,17 @@ static set_password: Dir<'_> = include_dir!("src/mailers/auth/set_password");
#[allow(clippy::module_name_repetitions)]
pub struct AuthMailer {}
impl Mailer for AuthMailer {}
impl Mailer for AuthMailer {
/// Override the framework default (`System <system@example.com>`), which any
/// real MX rejects (`example.com` is nullMX). Must be a sender the SMTP
/// account is allowed to send as.
fn opts() -> mailer::MailerOpts {
mailer::MailerOpts {
from: "Kompress <info@kompress.sk>".to_string(),
..Default::default()
}
}
}
impl AuthMailer {
/// Sending welcome email the the given user
///

View File

@@ -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.
///

1
tests/mailer/mod.rs Normal file
View File

@@ -0,0 +1 @@
mod smtp_send;

104
tests/mailer/smtp_send.rs Normal file
View File

@@ -0,0 +1,104 @@
//! Real-SMTP smoke test.
//!
//! Sends an actual email through the live SMTP server using the real
//! `AuthMailer` pipeline (config -> Loco mailer -> templates -> SMTP). The test
//! PASSES when the SMTP server accepts the message (the send returns `Ok`);
//! confirm real delivery by checking the recipient's inbox.
//!
//! It is `#[ignore]`d so it never runs in CI or a normal `cargo test` (it opens
//! a real network connection, uses real credentials, and sends a real email).
//! Run it explicitly, inside `nix develop` so `SMTP_PASSWORD` is present:
//!
//! ```sh
//! nix develop -c cargo test --test mod -- --ignored mailer::smtp_send
//! # optional: choose the recipient (defaults to the address below)
//! MAILER_TEST_TO=you@example.com \
//! nix develop -c cargo test --test mod -- --ignored mailer::smtp_send
//! ```
use kompress_eshop::{
app::App,
mailers::auth::AuthMailer,
models::users::{Model, RegisterParams},
};
use loco_rs::testing::prelude::*;
use sea_orm::IntoActiveModel;
use serial_test::serial;
// Non-secret production SMTP settings (mirror `.env`). The password is
// intentionally NOT here: it is supplied at runtime via `SMTP_PASSWORD`
// (direnv -> `pass`), and never committed.
// Dial the name the TLS cert is actually valid for. `smtp.kompress.sk` is a
// DNS alias for the same server (213.215.124.101) but the cert only lists
// smtp.euronet.sk, so connecting via the alias fails certificate validation.
const SMTP_HOST: &str = "smtp.euronet.sk";
const SMTP_PORT: &str = "587";
const SMTP_USER: &str = "kompres";
const SMTP_SECURE: &str = "true";
// Where the test email is sent. Override with `MAILER_TEST_TO`.
const DEFAULT_RECIPIENT: &str = "filippriec@tutanota.com";
#[tokio::test]
#[serial]
#[ignore = "sends a real email via live SMTP; run explicitly with --ignored"]
async fn sends_real_email() {
// The actual secret must come from the environment (direnv -> `pass`).
// Fail loudly with guidance rather than silently sending nothing.
let password = std::env::var("SMTP_PASSWORD").unwrap_or_default();
assert!(
!password.is_empty(),
"SMTP_PASSWORD is not set. Run inside `nix develop` so direnv loads it from `pass`."
);
let recipient =
std::env::var("MAILER_TEST_TO").unwrap_or_else(|_| DEFAULT_RECIPIENT.to_string());
// Flip the booted context onto the real SMTP transport. `config/test.yaml`
// reads these via `get_env` at boot. We deliberately do NOT load `.env`
// here: it carries `DATABASE_URL`, and `test.yaml` has
// `dangerously_recreate: true`, so loading it would recreate the real DB.
// Leaving `DATABASE_URL` untouched keeps boot on the throwaway test DB.
//
// SAFETY: edition 2024 marks `set_var` as unsafe. This test is `#[serial]`,
// so no other test mutates the process environment concurrently.
unsafe {
std::env::set_var("MAILER_STUB", "false");
std::env::set_var("SMTP_ENABLE", "true");
std::env::set_var("SMTP_HOST", SMTP_HOST);
std::env::set_var("SMTP_PORT", SMTP_PORT);
std::env::set_var("SMTP_USER", SMTP_USER);
std::env::set_var("SMTP_SECURE", SMTP_SECURE);
}
let boot = boot_test::<App>()
.await
.expect("Failed to boot test application");
// A real user whose address is the recipient, so `send_welcome` targets it.
let user = Model::create_with_password(
&boot.app_context.db,
&RegisterParams {
email: recipient.clone(),
password: "smtp-smoke-test".to_string(),
name: "SMTP smoke test".to_string(),
account_type: Some("personal".to_string()),
},
)
.await
.expect("failed to create test user");
// Give the welcome email a realistic verification token/link.
user.into_active_model()
.set_email_verification_sent(&boot.app_context.db)
.await
.expect("failed to set email verification token");
let user = Model::find_by_email(&boot.app_context.db, &recipient)
.await
.expect("failed to reload test user");
// The assertion: the live SMTP server must accept the message.
AuthMailer::send_welcome(&boot.app_context, &user)
.await
.unwrap_or_else(|e| panic!("real SMTP send to {recipient} failed: {e:?}"));
}

View File

@@ -1,3 +1,4 @@
mod mailer;
mod models;
mod requests;
mod tasks;

View File

@@ -50,6 +50,7 @@ async fn can_create_with_password() {
email: "test@framework.com".to_string(),
password: "1234".to_string(),
name: "framework".to_string(),
account_type: Some("personal".to_string()),
};
let res = Model::create_with_password(&boot.app_context.db, &params).await;
@@ -78,6 +79,7 @@ async fn handle_create_with_password_with_duplicate() {
email: "user1@example.com".to_string(),
password: "1234".to_string(),
name: "framework".to_string(),
account_type: Some("personal".to_string()),
},
)
.await;