From 7438e6fe13e15dd46bcfb8b352eca590bb18e8ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:40:51 +0000 Subject: [PATCH 1/6] Initial plan From deebd2ddb3681c1617651c20a928a4a0ea855ad1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:51:01 +0000 Subject: [PATCH 2/6] Add email verification flow and make email required for email notifications Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/api/model.rs | 5 ++ lnvps_api/src/api/routes.rs | 83 ++++++++++++++++++- lnvps_api/src/worker.rs | 41 +++++++++ lnvps_api_admin/src/admin/model.rs | 3 + lnvps_api_common/src/mock.rs | 20 +++-- lnvps_api_common/src/work/mod.rs | 3 + .../20260219000000_email_verification.sql | 2 + lnvps_db/src/lib.rs | 3 + lnvps_db/src/model.rs | 4 + lnvps_db/src/mysql.rs | 13 ++- 10 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 lnvps_db/migrations/20260219000000_email_verification.sql diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 8394d9d8..7dad1d64 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -71,6 +71,9 @@ pub struct AccountPatchRequest { deserialize_with = "lnvps_api_common::deserialize_nullable_option" )] pub email: Option>, + /// Whether the email address has been verified (read-only, ignored on PATCH) + #[serde(skip_deserializing, skip_serializing_if = "Option::is_none")] + pub email_verified: Option, pub contact_nip17: bool, pub contact_email: bool, #[serde( @@ -132,8 +135,10 @@ pub struct AccountPatchRequest { impl From for AccountPatchRequest { fn from(user: lnvps_db::User) -> Self { + let has_email = user.email.is_some(); AccountPatchRequest { email: Some(user.email.map(|e| e.into())), + email_verified: has_email.then_some(user.email_verified), contact_nip17: user.contact_nip17, contact_email: user.contact_email, country_code: Some(user.country_code), diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index cbeb75a7..5c1cdbc0 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -41,6 +41,10 @@ pub fn routes() -> Router { "/api/v1/account", get(v1_get_account).patch(v1_patch_account), ) + .route( + "/api/v1/account/verify-email", + get(v1_verify_email), + ) .route("/api/v1/vm", get(v1_list_vms)) .route("/api/v1/vm/{id}", get(v1_get_vm).patch(v1_patch_vm)) .route("/api/v1/image", get(v1_list_vm_images)) @@ -131,10 +135,41 @@ async fn v1_patch_account( } } - // Update fields only if they are present in the request - if let Some(email) = &req.email { - user.email = email.clone().map(|s| s.into()); + // validate and handle email change + let mut pending_verification: Option = None; + if let Some(new_email_opt) = &req.email { + if let Some(new_email) = new_email_opt { + // Validate email format + if new_email.trim().is_empty() { + return ApiData::err("Email address cannot be empty"); + } + if !new_email.contains('@') || !new_email.contains('.') { + return ApiData::err("Invalid email address"); + } + // Check if email is changing + let old_email = user.email.as_ref().map(|e| e.as_str().to_string()); + let email_changed = old_email.as_deref() != Some(new_email.as_str()); + user.email = Some(new_email.clone().into()); + if email_changed { + // Mark email as unverified and generate a verification token + let token = hex::encode(rand::random::<[u8; 32]>()); + user.email_verified = false; + user.email_verify_token = Some(token.clone()); + pending_verification = Some(token); + } + } else { + // Email is being cleared + user.email = None; + user.email_verified = false; + user.email_verify_token = None; + } } + + // If contact_email is enabled, email must be set + if req.contact_email && user.email.is_none() { + return ApiData::err("An email address is required to enable email notifications"); + } + user.contact_nip17 = req.contact_nip17; user.contact_email = req.contact_email; if let Some(country_code) = &req.country_code { @@ -168,10 +203,52 @@ async fn v1_patch_account( user.nwc_connection_string = nwc_connection_string.clone().map(|s| s.into()); } + this.db.update_user(&user).await?; + + // Queue verification email after successful save + if let Some(token) = pending_verification { + let verify_url = format!( + "{}/api/v1/account/verify-email?token={}", + this.settings.public_url, token + ); + if let Err(e) = this + .work_sender + .send(WorkJob::SendEmailVerification { + user_id: uid, + verify_url, + }) + .await + { + error!("Failed to queue email verification: {}", e); + } + } + + ApiData::ok(()) +} + +/// Verify email address using the token sent to the user's email +async fn v1_verify_email( + State(this): State, + Query(params): Query, +) -> ApiResult<()> { + if params.token.trim().is_empty() { + return ApiData::err("Verification token is required"); + } + let mut user = match this.db.get_user_by_email_verify_token(¶ms.token).await { + Ok(u) => u, + Err(_) => return ApiData::err("Invalid or expired verification token"), + }; + user.email_verified = true; + user.email_verify_token = None; this.db.update_user(&user).await?; ApiData::ok(()) } +#[derive(serde::Deserialize)] +struct VerifyEmailQuery { + token: String, +} + /// Get user account detail async fn v1_get_account( auth: Nip98Auth, diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index d040266b..e3164c8f 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -575,6 +575,44 @@ impl Worker { Ok(()) } + async fn send_email_verification(&self, user_id: u64, verify_url: &str) -> Result<()> { + let user = self.db.get_user(user_id).await?; + let email = match &user.email { + Some(e) => e.as_str().to_string(), + None => return Ok(()), // No email, nothing to do + }; + let Some(smtp) = self.settings.smtp.as_ref() else { + return Ok(()); + }; + let message = format!( + "Please verify your email address by clicking the link below:\n\n{}", + verify_url + ); + let template = include_str!("../email.html"); + let html = MultiPart::alternative_plain_html( + message.clone(), + template + .replace("%%_MESSAGE_%%", &message) + .replace("%%YEAR%%", Utc::now().year().to_string().as_str()), + ); + let mut b = MessageBuilder::new() + .to(email.parse()?) + .subject("Verify your email address"); + if let Some(f) = &smtp.from { + b = b.from(f.parse()?); + } + let msg = b.multipart(html)?; + let sender = AsyncSmtpTransport::::relay(&smtp.server)? + .credentials(Credentials::new( + smtp.username.to_string(), + smtp.password.to_string(), + )) + .timeout(Some(Duration::from_secs(10))) + .build(); + sender.send(msg).await?; + Ok(()) + } + async fn queue_notification(&self, user_id: u64, message: String, title: Option) { if let Err(e) = self .work_commander @@ -1670,6 +1708,9 @@ impl Worker { vm.id, user_id ))); } + WorkJob::SendEmailVerification { user_id, verify_url } => { + self.send_email_verification(*user_id, verify_url).await?; + } } Ok(None) } diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 593ff9d7..858c4895 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -169,6 +169,7 @@ pub struct AdminUserInfo { pub pubkey: String, // hex encoded pub created: DateTime, pub email: Option, + pub email_verified: bool, pub contact_nip17: bool, pub contact_email: bool, pub country_code: Option, @@ -262,6 +263,7 @@ impl From for AdminUserInfo { pubkey: hex::encode(&user.pubkey), created: user.created, email: user.email.map(|e| e.into()), + email_verified: user.email_verified, contact_nip17: user.contact_nip17, contact_email: user.contact_email, country_code: user.country_code, @@ -288,6 +290,7 @@ impl From for AdminUserInfo { pubkey: hex::encode(&user.user_info.pubkey), created: user.user_info.created, email: user.user_info.email.map(|e| e.into()), + email_verified: user.user_info.email_verified, contact_nip17: user.user_info.contact_nip17, contact_email: user.user_info.contact_email, country_code: user.user_info.country_code, diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 4431aee1..16b55a7d 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -3,14 +3,13 @@ use anyhow::{Context, anyhow}; use chrono::{TimeDelta, Utc}; use lnvps_db::nostr::LNVPSNostrDb; use lnvps_db::{ - AccessPolicy, AvailableIpSpace, Company, CpuArch, CpuMfg, DbResult, DiskInterface, DiskType, - IpRange, IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, NostrDomain, - NostrDomainHandle, OsDistribution, PaymentMethod, PaymentMethodConfig, Referral, + AccessPolicy, AvailableIpSpace, Company, CpuArch, CpuMfg, DbError, DbResult, DiskInterface, + DiskType, IpRange, IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, + NostrDomain, NostrDomainHandle, OsDistribution, PaymentMethod, PaymentMethodConfig, Referral, ReferralCostUsage, ReferralPayout, Router, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, - VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, - VmTemplate, + VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use async_trait::async_trait; @@ -290,6 +289,8 @@ impl LNVpsDbBase for MockDb { let mut users = self.users.lock().await; if let Some(u) = users.get_mut(&user.id) { u.email = user.email.clone(); + u.email_verified = user.email_verified; + u.email_verify_token = user.email_verify_token.clone(); u.contact_email = user.contact_email; u.contact_nip17 = user.contact_nip17; } @@ -302,6 +303,15 @@ impl LNVpsDbBase for MockDb { Ok(()) } + async fn get_user_by_email_verify_token(&self, token: &str) -> DbResult { + let users = self.users.lock().await; + users + .values() + .find(|u| u.email_verify_token.as_deref() == Some(token)) + .cloned() + .ok_or_else(|| DbError::Other(anyhow!("no user with that token"))) + } + async fn list_users(&self) -> DbResult> { let users = self.users.lock().await; Ok(users.values().cloned().collect()) diff --git a/lnvps_api_common/src/work/mod.rs b/lnvps_api_common/src/work/mod.rs index dbf4d692..ede676a2 100644 --- a/lnvps_api_common/src/work/mod.rs +++ b/lnvps_api_common/src/work/mod.rs @@ -114,6 +114,8 @@ pub enum WorkJob { admin_user_id: u64, reason: Option, }, + /// Send an email verification link to the user + SendEmailVerification { user_id: u64, verify_url: String }, } impl WorkJob { @@ -150,6 +152,7 @@ impl fmt::Display for WorkJob { WorkJob::UpdateVmIp { .. } => write!(f, "UpdateVmIp"), WorkJob::ProcessVmRefund { .. } => write!(f, "ProcessVmRefund"), WorkJob::CreateVm { .. } => write!(f, "CreateVm"), + WorkJob::SendEmailVerification { .. } => write!(f, "SendEmailVerification"), } } } diff --git a/lnvps_db/migrations/20260219000000_email_verification.sql b/lnvps_db/migrations/20260219000000_email_verification.sql new file mode 100644 index 00000000..03ccfdc9 --- /dev/null +++ b/lnvps_db/migrations/20260219000000_email_verification.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN email_verified TINYINT NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN email_verify_token VARCHAR(64) NULL; diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 8d2a0d44..9c0cde4e 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -91,6 +91,9 @@ pub trait LNVpsDbBase: Send + Sync { /// Delete user record async fn delete_user(&self, id: u64) -> DbResult<()>; + /// Get user by email verification token + async fn get_user_by_email_verify_token(&self, token: &str) -> DbResult; + /// List all users async fn list_users(&self) -> DbResult>; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index fbebcddf..a3cd4632 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -20,6 +20,10 @@ pub struct User { pub created: DateTime, /// Users email address for notifications (encrypted) pub email: Option, + /// Whether the email address has been verified + pub email_verified: bool, + /// Token used for email address verification (temporary) + pub email_verify_token: Option, /// If user should be contacted via NIP-17 for notifications pub contact_nip17: bool, /// If user should be contacted via email for notifications diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index c7bf6f9a..fdaf4b34 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -64,9 +64,11 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn update_user(&self, user: &User) -> DbResult<()> { sqlx::query( - "update users set email=?, contact_nip17=?, contact_email=?, country_code=?, billing_name=?, billing_address_1=?, billing_address_2=?, billing_city=?, billing_state=?, billing_postcode=?, billing_tax_id=?, nwc_connection_string=? where id = ?", + "update users set email=?, email_verified=?, email_verify_token=?, contact_nip17=?, contact_email=?, country_code=?, billing_name=?, billing_address_1=?, billing_address_2=?, billing_city=?, billing_state=?, billing_postcode=?, billing_tax_id=?, nwc_connection_string=? where id = ?", ) .bind(&user.email) + .bind(user.email_verified) + .bind(&user.email_verify_token) .bind(user.contact_nip17) .bind(user.contact_email) .bind(&user.country_code) @@ -90,6 +92,15 @@ impl LNVpsDbBase for LNVpsDbMysql { )) } + async fn get_user_by_email_verify_token(&self, token: &str) -> DbResult { + Ok( + sqlx::query_as("select * from users where email_verify_token = ?") + .bind(token) + .fetch_one(&self.db) + .await?, + ) + } + async fn list_users(&self) -> DbResult> { Ok(sqlx::query_as("select * from users") .fetch_all(&self.db) From 32ac543fa52ea4e5ad1ce51eea22b14fce5cba58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:55:19 +0000 Subject: [PATCH 3/6] Update API documentation for email verification flow Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- API_DOCUMENTATION.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 9ff9286b..876ad6e7 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -44,6 +44,7 @@ interface AuthHeaders { ```typescript interface AccountInfo { email?: string; + email_verified?: boolean; // Present when email is set; true if email has been verified contact_nip17: boolean; contact_email: boolean; country_code?: string; // ISO 3166-1 alpha-3 country code @@ -328,6 +329,15 @@ interface VmUpgradeQuote { - **PATCH** `/api/v1/account` - **Auth**: Required - **Body**: `AccountInfo` +- **Notes**: + - Setting `contact_email: true` requires an email address to be present + - When email is changed, a verification email is sent and `email_verified` is reset to `false` +- **Response**: `null` + +#### Verify Email Address +- **GET** `/api/v1/account/verify-email?token=` +- **Auth**: Not required +- **Query**: `token` — the verification token from the verification email - **Response**: `null` ### Automatic Renewal with Nostr Wallet Connect From 4136ea20653e74b3ba5dabd217ddaaafb60fe7bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:36:52 +0000 Subject: [PATCH 4/6] Address review feedback: email required, HTML verify page, shared send_email, BIT(1) migration Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/api/routes.rs | 53 +++++++++--- lnvps_api/src/worker.rs | 84 ++++++++----------- lnvps_api_common/src/mock.rs | 2 +- .../20260219000000_email_verification.sql | 4 +- lnvps_db/src/model.rs | 4 +- lnvps_db/src/mysql.rs | 10 ++- 6 files changed, 86 insertions(+), 71 deletions(-) diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 5c1cdbc0..f8975975 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -1,7 +1,7 @@ use anyhow::Result; use axum::extract::ws::{Message, WebSocket}; use axum::extract::{Path, Query, State, WebSocketUpgrade}; -use axum::response::Html; +use axum::response::{Html, IntoResponse}; use axum::routing::{any, get, patch, post}; use axum::{Json, Router}; use chrono::{DateTime, Datelike, Utc}; @@ -154,14 +154,11 @@ async fn v1_patch_account( // Mark email as unverified and generate a verification token let token = hex::encode(rand::random::<[u8; 32]>()); user.email_verified = false; - user.email_verify_token = Some(token.clone()); + user.email_verify_token = token.clone(); pending_verification = Some(token); } } else { - // Email is being cleared - user.email = None; - user.email_verified = false; - user.email_verify_token = None; + return ApiData::err("Email address is required and cannot be removed"); } } @@ -230,18 +227,50 @@ async fn v1_patch_account( async fn v1_verify_email( State(this): State, Query(params): Query, -) -> ApiResult<()> { +) -> impl IntoResponse { + let make_page = |title: &str, message: &str, success: bool| { + let color = if success { "#2ecc71" } else { "#e74c3c" }; + Html(format!( + r#"{title} + +

{title}

{message}

"# + )) + }; + if params.token.trim().is_empty() { - return ApiData::err("Verification token is required"); + return make_page( + "Invalid Link", + "The verification link is missing a token.", + false, + ); } let mut user = match this.db.get_user_by_email_verify_token(¶ms.token).await { Ok(u) => u, - Err(_) => return ApiData::err("Invalid or expired verification token"), + Err(_) => { + return make_page( + "Invalid or Expired Link", + "This verification link is invalid or has already been used.", + false, + ); + } }; user.email_verified = true; - user.email_verify_token = None; - this.db.update_user(&user).await?; - ApiData::ok(()) + user.email_verify_token = String::new(); + if let Err(e) = this.db.update_user(&user).await { + error!("Failed to mark email verified: {}", e); + return make_page( + "Error", + "An error occurred. Please try again later.", + false, + ); + } + make_page( + "Email Verified", + "Your email address has been successfully verified.", + true, + ) } #[derive(serde::Deserialize)] diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index e3164c8f..28ab8851 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -520,6 +520,36 @@ impl Worker { Ok(()) } + /// Shared function for sending an email via SMTP using the HTML email template. + async fn send_email( + smtp: &SmtpConfig, + to: &str, + subject: &str, + message: &str, + ) -> Result<()> { + let template = include_str!("../email.html"); + let html = MultiPart::alternative_plain_html( + message.to_string(), + template + .replace("%%_MESSAGE_%%", message) + .replace("%%YEAR%%", Utc::now().year().to_string().as_str()), + ); + let mut b = MessageBuilder::new().to(to.parse()?).subject(subject); + if let Some(f) = &smtp.from { + b = b.from(f.parse()?); + } + let msg = b.multipart(html)?; + let sender = AsyncSmtpTransport::::relay(&smtp.server)? + .credentials(Credentials::new( + smtp.username.to_string(), + smtp.password.to_string(), + )) + .timeout(Some(Duration::from_secs(10))) + .build(); + sender.send(msg).await?; + Ok(()) + } + async fn send_notification( &self, user_id: u64, @@ -531,33 +561,9 @@ impl Worker { && user.contact_email && user.email.is_some() { - // send email - let mut b = MessageBuilder::new().to(user.email.unwrap().as_str().parse()?); - if let Some(t) = title { - b = b.subject(t); - } - if let Some(f) = &smtp.from { - b = b.from(f.parse()?); - } - let template = include_str!("../email.html"); - let html = MultiPart::alternative_plain_html( - message.clone(), - template - .replace("%%_MESSAGE_%%", &message) - .replace("%%YEAR%%", Utc::now().year().to_string().as_str()), - ); - - let msg = b.multipart(html)?; - - let sender = AsyncSmtpTransport::::relay(&smtp.server)? - .credentials(Credentials::new( - smtp.username.to_string(), - smtp.password.to_string(), - )) - .timeout(Some(Duration::from_secs(10))) - .build(); - - sender.send(msg).await?; + let to = user.email.as_ref().unwrap().as_str().to_string(); + let subject = title.as_deref().unwrap_or("Notification"); + Self::send_email(smtp, &to, subject, &message).await?; } if user.contact_nip17 && let Some(c) = self.nostr.as_ref() @@ -588,29 +594,7 @@ impl Worker { "Please verify your email address by clicking the link below:\n\n{}", verify_url ); - let template = include_str!("../email.html"); - let html = MultiPart::alternative_plain_html( - message.clone(), - template - .replace("%%_MESSAGE_%%", &message) - .replace("%%YEAR%%", Utc::now().year().to_string().as_str()), - ); - let mut b = MessageBuilder::new() - .to(email.parse()?) - .subject("Verify your email address"); - if let Some(f) = &smtp.from { - b = b.from(f.parse()?); - } - let msg = b.multipart(html)?; - let sender = AsyncSmtpTransport::::relay(&smtp.server)? - .credentials(Credentials::new( - smtp.username.to_string(), - smtp.password.to_string(), - )) - .timeout(Some(Duration::from_secs(10))) - .build(); - sender.send(msg).await?; - Ok(()) + Self::send_email(smtp, &email, "Verify your email address", &message).await } async fn queue_notification(&self, user_id: u64, message: String, title: Option) { diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 16b55a7d..e348fd63 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -307,7 +307,7 @@ impl LNVpsDbBase for MockDb { let users = self.users.lock().await; users .values() - .find(|u| u.email_verify_token.as_deref() == Some(token)) + .find(|u| !u.email_verify_token.is_empty() && u.email_verify_token == token) .cloned() .ok_or_else(|| DbError::Other(anyhow!("no user with that token"))) } diff --git a/lnvps_db/migrations/20260219000000_email_verification.sql b/lnvps_db/migrations/20260219000000_email_verification.sql index 03ccfdc9..82f24f93 100644 --- a/lnvps_db/migrations/20260219000000_email_verification.sql +++ b/lnvps_db/migrations/20260219000000_email_verification.sql @@ -1,2 +1,2 @@ -ALTER TABLE users ADD COLUMN email_verified TINYINT NOT NULL DEFAULT 0; -ALTER TABLE users ADD COLUMN email_verify_token VARCHAR(64) NULL; +ALTER TABLE users ADD COLUMN email_verified BIT(1) NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN email_verify_token VARCHAR(64) NOT NULL DEFAULT ''; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index a3cd4632..bb351250 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -22,8 +22,8 @@ pub struct User { pub email: Option, /// Whether the email address has been verified pub email_verified: bool, - /// Token used for email address verification (temporary) - pub email_verify_token: Option, + /// Token used for email address verification (empty string means no pending verification) + pub email_verify_token: String, /// If user should be contacted via NIP-17 for notifications pub contact_nip17: bool, /// If user should be contacted via email for notifications diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index fdaf4b34..1b82d97a 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -94,10 +94,12 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn get_user_by_email_verify_token(&self, token: &str) -> DbResult { Ok( - sqlx::query_as("select * from users where email_verify_token = ?") - .bind(token) - .fetch_one(&self.db) - .await?, + sqlx::query_as( + "select * from users where email_verify_token = ? and email_verify_token != ''", + ) + .bind(token) + .fetch_one(&self.db) + .await?, ) } From 952923f9c3976d327a325dabcbbdc7b93ca3f88b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:01:03 +0000 Subject: [PATCH 5/6] Use mustache for email.html, add verify-email.html template, make email required in User model Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/email.html | 4 +- lnvps_api/src/api/model.rs | 5 +- lnvps_api/src/api/routes.rs | 38 ++++++++------- lnvps_api/src/worker.rs | 26 +++++------ lnvps_api/verify-email.html | 46 +++++++++++++++++++ lnvps_api_admin/src/admin/model.rs | 6 +-- lnvps_api_admin/src/admin/users.rs | 2 +- lnvps_api_admin/src/admin/vms.rs | 4 +- lnvps_api_common/src/mock.rs | 2 +- .../20260219000000_email_verification.sql | 1 + lnvps_db/src/model.rs | 2 +- lnvps_db/src/mysql.rs | 6 ++- 12 files changed, 98 insertions(+), 44 deletions(-) create mode 100644 lnvps_api/verify-email.html diff --git a/lnvps_api/email.html b/lnvps_api/email.html index 3294f8be..f3197b6a 100644 --- a/lnvps_api/email.html +++ b/lnvps_api/email.html @@ -46,10 +46,10 @@ logo
-

%%_MESSAGE_%%

+

{{message}}


- (c) %%YEAR%% LNVPS.net + (c) {{year}} LNVPS.net diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 7dad1d64..63027a11 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -135,9 +135,10 @@ pub struct AccountPatchRequest { impl From for AccountPatchRequest { fn from(user: lnvps_db::User) -> Self { - let has_email = user.email.is_some(); + let has_email = !user.email.is_empty(); + let email_str: String = user.email.into(); AccountPatchRequest { - email: Some(user.email.map(|e| e.into())), + email: if has_email { Some(Some(email_str)) } else { None }, email_verified: has_email.then_some(user.email_verified), contact_nip17: user.contact_nip17, contact_email: user.contact_email, diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index f8975975..bfeff0e0 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -147,9 +147,9 @@ async fn v1_patch_account( return ApiData::err("Invalid email address"); } // Check if email is changing - let old_email = user.email.as_ref().map(|e| e.as_str().to_string()); - let email_changed = old_email.as_deref() != Some(new_email.as_str()); - user.email = Some(new_email.clone().into()); + let old_email = user.email.as_str().to_string(); + let email_changed = old_email != new_email.as_str(); + user.email = new_email.clone().into(); if email_changed { // Mark email as unverified and generate a verification token let token = hex::encode(rand::random::<[u8; 32]>()); @@ -163,7 +163,7 @@ async fn v1_patch_account( } // If contact_email is enabled, email must be set - if req.contact_email && user.email.is_none() { + if req.contact_email && user.email.is_empty() { return ApiData::err("An email address is required to enable email notifications"); } @@ -228,22 +228,26 @@ async fn v1_verify_email( State(this): State, Query(params): Query, ) -> impl IntoResponse { - let make_page = |title: &str, message: &str, success: bool| { - let color = if success { "#2ecc71" } else { "#e74c3c" }; - Html(format!( - r#"{title} - -

{title}

{message}

"# - )) + let make_page = |title: &str, message: &str, color: &str| { + let template = mustache::compile_str(include_str!("../../verify-email.html")) + .expect("valid verify-email template"); + let rendered = template + .render_to_string( + &mustache::MapBuilder::new() + .insert_str("title", title) + .insert_str("message", message) + .insert_str("color", color) + .build(), + ) + .unwrap_or_else(|_| format!("

{title}

{message}

")); + Html(rendered) }; if params.token.trim().is_empty() { return make_page( "Invalid Link", "The verification link is missing a token.", - false, + "#e74c3c", ); } let mut user = match this.db.get_user_by_email_verify_token(¶ms.token).await { @@ -252,7 +256,7 @@ h1{{color:{color}}}p{{color:#555}} return make_page( "Invalid or Expired Link", "This verification link is invalid or has already been used.", - false, + "#e74c3c", ); } }; @@ -263,13 +267,13 @@ h1{{color:{color}}}p{{color:#555}} return make_page( "Error", "An error occurred. Please try again later.", - false, + "#e74c3c", ); } make_page( "Email Verified", "Your email address has been successfully verified.", - true, + "#2ecc71", ) } diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 28ab8851..4fd37631 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -527,13 +527,12 @@ impl Worker { subject: &str, message: &str, ) -> Result<()> { - let template = include_str!("../email.html"); - let html = MultiPart::alternative_plain_html( - message.to_string(), - template - .replace("%%_MESSAGE_%%", message) - .replace("%%YEAR%%", Utc::now().year().to_string().as_str()), - ); + let template = mustache::compile_str(include_str!("../email.html"))?; + let rendered = template.render_to_string(&mustache::MapBuilder::new() + .insert_str("message", message) + .insert_str("year", Utc::now().year().to_string()) + .build())?; + let html = MultiPart::alternative_plain_html(message.to_string(), rendered); let mut b = MessageBuilder::new().to(to.parse()?).subject(subject); if let Some(f) = &smtp.from { b = b.from(f.parse()?); @@ -559,9 +558,9 @@ impl Worker { let user = self.db.get_user(user_id).await?; if let Some(smtp) = self.settings.smtp.as_ref() && user.contact_email - && user.email.is_some() + && !user.email.is_empty() { - let to = user.email.as_ref().unwrap().as_str().to_string(); + let to = user.email.as_str().to_string(); let subject = title.as_deref().unwrap_or("Notification"); Self::send_email(smtp, &to, subject, &message).await?; } @@ -583,10 +582,9 @@ impl Worker { async fn send_email_verification(&self, user_id: u64, verify_url: &str) -> Result<()> { let user = self.db.get_user(user_id).await?; - let email = match &user.email { - Some(e) => e.as_str().to_string(), - None => return Ok(()), // No email, nothing to do - }; + if user.email.is_empty() { + return Ok(()); // No email, nothing to do + } let Some(smtp) = self.settings.smtp.as_ref() else { return Ok(()); }; @@ -594,7 +592,7 @@ impl Worker { "Please verify your email address by clicking the link below:\n\n{}", verify_url ); - Self::send_email(smtp, &email, "Verify your email address", &message).await + Self::send_email(smtp, user.email.as_str(), "Verify your email address", &message).await } async fn queue_notification(&self, user_id: u64, message: String, title: Option) { diff --git a/lnvps_api/verify-email.html b/lnvps_api/verify-email.html new file mode 100644 index 00000000..37c63e61 --- /dev/null +++ b/lnvps_api/verify-email.html @@ -0,0 +1,46 @@ + + + + + {{title}} + + + + + + +
+

{{title}}

+

{{message}}

+
+ + diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 858c4895..0ecf0eb8 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -262,7 +262,7 @@ impl From for AdminUserInfo { id: user.id, pubkey: hex::encode(&user.pubkey), created: user.created, - email: user.email.map(|e| e.into()), + email: if user.email.is_empty() { None } else { Some(user.email.into()) }, email_verified: user.email_verified, contact_nip17: user.contact_nip17, contact_email: user.contact_email, @@ -289,7 +289,7 @@ impl From for AdminUserInfo { id: user.user_info.id, pubkey: hex::encode(&user.user_info.pubkey), created: user.user_info.created, - email: user.user_info.email.map(|e| e.into()), + email: if user.user_info.email.is_empty() { None } else { Some(user.user_info.email.into()) }, email_verified: user.user_info.email_verified, contact_nip17: user.user_info.contact_nip17, contact_email: user.user_info.contact_email, @@ -1610,7 +1610,7 @@ impl AdminVmHistoryInfo { && let Ok(user) = db.get_user(user_id).await { initiated_by_user_pubkey = Some(hex::encode(&user.pubkey)); - initiated_by_user_email = user.email.map(|e| e.into()); + initiated_by_user_email = if user.email.is_empty() { None } else { Some(user.email.into()) }; } Ok(Self { diff --git a/lnvps_api_admin/src/admin/users.rs b/lnvps_api_admin/src/admin/users.rs index 95807c3a..1f5f2733 100644 --- a/lnvps_api_admin/src/admin/users.rs +++ b/lnvps_api_admin/src/admin/users.rs @@ -91,7 +91,7 @@ async fn admin_update_user( // Update user fields if provided if let Some(email) = &req.email { - user.email = Some(email.into()); + user.email = email.into(); } if let Some(contact_nip17) = req.contact_nip17 { user.contact_nip17 = contact_nip17; diff --git a/lnvps_api_admin/src/admin/vms.rs b/lnvps_api_admin/src/admin/vms.rs index 74a47563..46b3d010 100644 --- a/lnvps_api_admin/src/admin/vms.rs +++ b/lnvps_api_admin/src/admin/vms.rs @@ -155,7 +155,7 @@ async fn admin_list_vms( vm.host_id, vm.user_id, hex::encode(&user.pubkey), - user.email.map(|e| e.into()), + if user.email.is_empty() { None } else { Some(user.email.into()) }, host.name.clone(), region.id, region.name.clone(), @@ -226,7 +226,7 @@ async fn admin_get_vm( vm.host_id, vm.user_id, hex::encode(&user.pubkey), - user.email.map(|e| e.into()), + if user.email.is_empty() { None } else { Some(user.email.into()) }, host_name, region_id, region_name, diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index e348fd63..5b06d320 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -1038,7 +1038,7 @@ impl LNVpsDbBase for MockDb { if has_active_vm && (user.contact_email || user.contact_nip17) { // For email: check if they have an email address // For nip17: they should have a pubkey (which all users do) - if (user.contact_email && user.email.is_some()) || user.contact_nip17 { + if (user.contact_email && !user.email.is_empty()) || user.contact_nip17 { active_customers.push(user.clone()); } } diff --git a/lnvps_db/migrations/20260219000000_email_verification.sql b/lnvps_db/migrations/20260219000000_email_verification.sql index 82f24f93..57e188a5 100644 --- a/lnvps_db/migrations/20260219000000_email_verification.sql +++ b/lnvps_db/migrations/20260219000000_email_verification.sql @@ -1,2 +1,3 @@ ALTER TABLE users ADD COLUMN email_verified BIT(1) NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN email_verify_token VARCHAR(64) NOT NULL DEFAULT ''; +ALTER TABLE users MODIFY COLUMN email VARCHAR(255) NOT NULL DEFAULT ''; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index bb351250..381a7f48 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -19,7 +19,7 @@ pub struct User { /// When this user first started using the service (first login) pub created: DateTime, /// Users email address for notifications (encrypted) - pub email: Option, + pub email: EncryptedString, /// Whether the email address has been verified pub email_verified: bool, /// Token used for email address verification (empty string means no pending verification) diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 1b82d97a..df288471 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1018,6 +1018,8 @@ impl LNVpsDbBase for LNVpsDbMysql { u.pubkey, u.created, u.email, + u.email_verified, + u.email_verify_token, u.contact_nip17, u.contact_email, u.country_code, @@ -1033,7 +1035,7 @@ impl LNVpsDbBase for LNVpsDbMysql { INNER JOIN vm ON u.id = vm.user_id WHERE vm.deleted = 0 AND ( - (u.contact_email = 1 AND u.email IS NOT NULL) + (u.contact_email = 1 AND u.email != '') OR u.contact_nip17 = 1 ) @@ -2410,6 +2412,8 @@ impl AdminDb for LNVpsDbMysql { u.pubkey, u.created, u.email, + u.email_verified, + u.email_verify_token, u.contact_nip17, u.contact_email, u.country_code, From 2505af8e952bd7f64bf869e96f8ec860060d9dbf Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 20 Feb 2026 17:14:44 +0000 Subject: [PATCH 6/6] Fix email verification: migration timestamp, clickable links, SMTP error handling - Rename migration to unique timestamp (20260220165223) - Fix migration to handle NULL emails and drop unique index - Use triple braces in mustache for unescaped HTML links - Add link styling for dark background (#3498db) - Use OpError for SMTP errors: Fatal (5xx) skipped, Transient retried - Add docs/agents/migrations.md with timestamp rules --- AGENTS.md | 1 + docs/agents/migrations.md | 29 ++++++++ lnvps_api/email.html | 10 ++- lnvps_api/src/api/routes.rs | 20 +++-- lnvps_api/src/worker.rs | 73 ++++++++++++++----- lnvps_api_admin/src/bin/generate_demo_data.rs | 4 +- ... => 20260220165223_email_verification.sql} | 7 +- 7 files changed, 114 insertions(+), 30 deletions(-) create mode 100644 docs/agents/migrations.md rename lnvps_db/migrations/{20260219000000_email_verification.sql => 20260220165223_email_verification.sql} (51%) diff --git a/AGENTS.md b/AGENTS.md index c8abef95..78632826 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ These docs apply to all projects using this agent structure: | [docs/agents/build-and-test.md](docs/agents/build-and-test.md) | Running builds, tests, clippy, or formatting | | [docs/agents/code-style.md](docs/agents/code-style.md) | Writing or reviewing Rust code (imports, errors, naming, async, derives, serde, tests) | | [docs/agents/api-guidelines.md](docs/agents/api-guidelines.md) | Modifying any user-facing or admin API endpoint | +| [docs/agents/migrations.md](docs/agents/migrations.md) | Adding or modifying database migrations | | [docs/agents/currency.md](docs/agents/currency.md) | Working with money amounts, pricing, or payments | | [docs/agents/bug-fixes.md](docs/agents/bug-fixes.md) | Resolving bugs — LNVPS-specific additions | | [docs/agents/coverage.md](docs/agents/coverage.md) | Function coverage — LNVPS-specific additions | diff --git a/docs/agents/migrations.md b/docs/agents/migrations.md new file mode 100644 index 00000000..b3b40268 --- /dev/null +++ b/docs/agents/migrations.md @@ -0,0 +1,29 @@ +# Database Migrations + +## Timestamp Rules + +**CRITICAL:** Migration filenames must have unique timestamps (full 14-digit `YYYYMMDDHHMMSS`). Before creating or modifying a migration: + +1. **Check existing timestamps** — Run `ls lnvps_db/migrations/` and verify your full timestamp doesn't conflict +2. **After rebasing** — If your branch adds migrations, check that the timestamps don't collide with migrations added to master +3. **Use the current timestamp** — Generate with `date +%Y%m%d%H%M%S` + +Migration format: `YYYYMMDDHHMMSS_description.sql` + +Example conflict to avoid: +``` +20260219000000_cpu_type.sql # from master +20260219000000_email_verification.sql # CONFLICT — same full timestamp! +``` + +Fix by using a completely unique timestamp: +``` +20260219000000_cpu_type.sql +20260221120000_email_verification.sql # different date AND time +``` + +## Migration Best Practices + +- Use `NOT NULL DEFAULT ` for new columns to avoid breaking existing rows +- Test migrations against a database with production-like data +- Never modify a migration that has already been applied to any environment diff --git a/lnvps_api/email.html b/lnvps_api/email.html index f3197b6a..a546c74c 100644 --- a/lnvps_api/email.html +++ b/lnvps_api/email.html @@ -37,6 +37,14 @@ white-space: pre-wrap; word-wrap: break-word; } + + a { + color: #3498db; + } + + a:hover { + color: #5dade2; + } @@ -46,7 +54,7 @@ logo
-

{{message}}

+

{{{message}}}


(c) {{year}} LNVPS.net diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index bfeff0e0..7c52ccb3 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -223,6 +223,13 @@ async fn v1_patch_account( ApiData::ok(()) } +#[derive(serde::Serialize)] +struct VerifyEmailPage { + title: String, + message: String, + color: String, +} + /// Verify email address using the token sent to the user's email async fn v1_verify_email( State(this): State, @@ -231,14 +238,13 @@ async fn v1_verify_email( let make_page = |title: &str, message: &str, color: &str| { let template = mustache::compile_str(include_str!("../../verify-email.html")) .expect("valid verify-email template"); + let data = VerifyEmailPage { + title: title.to_string(), + message: message.to_string(), + color: color.to_string(), + }; let rendered = template - .render_to_string( - &mustache::MapBuilder::new() - .insert_str("title", title) - .insert_str("message", message) - .insert_str("color", color) - .build(), - ) + .render_to_string(&data) .unwrap_or_else(|_| format!("

{title}

{message}

")); Html(rendered) }; diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 4fd37631..bdc19bc3 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -521,31 +521,52 @@ impl Worker { } /// Shared function for sending an email via SMTP using the HTML email template. + /// If `html_message` is provided, it will be used in the HTML template instead of `plain_message`. + /// Returns `OpError::Fatal` for permanent SMTP errors (5xx) and `OpError::Transient` for temporary failures. async fn send_email( smtp: &SmtpConfig, to: &str, subject: &str, - message: &str, - ) -> Result<()> { - let template = mustache::compile_str(include_str!("../email.html"))?; - let rendered = template.render_to_string(&mustache::MapBuilder::new() - .insert_str("message", message) - .insert_str("year", Utc::now().year().to_string()) - .build())?; - let html = MultiPart::alternative_plain_html(message.to_string(), rendered); - let mut b = MessageBuilder::new().to(to.parse()?).subject(subject); + plain_message: &str, + html_message: Option<&str>, + ) -> Result<(), OpError> { + #[derive(serde::Serialize)] + struct EmailData { + message: String, + year: String, + } + let template = + mustache::compile_str(include_str!("../email.html")).map_err(|e| OpError::Fatal(e.into()))?; + let data = EmailData { + message: html_message.unwrap_or(plain_message).to_string(), + year: Utc::now().year().to_string(), + }; + let rendered = template + .render_to_string(&data) + .map_err(|e| OpError::Fatal(e.into()))?; + let html = MultiPart::alternative_plain_html(plain_message.to_string(), rendered); + let mut b = MessageBuilder::new() + .to(to.parse().map_err(|e: lettre::address::AddressError| OpError::Fatal(e.into()))?) + .subject(subject); if let Some(f) = &smtp.from { - b = b.from(f.parse()?); + b = b.from(f.parse().map_err(|e: lettre::address::AddressError| OpError::Fatal(e.into()))?); } - let msg = b.multipart(html)?; - let sender = AsyncSmtpTransport::::relay(&smtp.server)? + let msg = b.multipart(html).map_err(|e| OpError::Fatal(e.into()))?; + let sender = AsyncSmtpTransport::::relay(&smtp.server) + .map_err(|e| OpError::Transient(e.into()))? .credentials(Credentials::new( smtp.username.to_string(), smtp.password.to_string(), )) .timeout(Some(Duration::from_secs(10))) .build(); - sender.send(msg).await?; + sender.send(msg).await.map_err(|e| { + if e.is_permanent() { + OpError::Fatal(e.into()) + } else { + OpError::Transient(e.into()) + } + })?; Ok(()) } @@ -562,7 +583,12 @@ impl Worker { { let to = user.email.as_str().to_string(); let subject = title.as_deref().unwrap_or("Notification"); - Self::send_email(smtp, &to, subject, &message).await?; + if let Err(e) = Self::send_email(smtp, &to, subject, &message, None).await { + match e { + OpError::Fatal(e) => warn!("Permanent email error to {}, skipping: {}", to, e), + OpError::Transient(e) => return Err(e), + } + } } if user.contact_nip17 && let Some(c) = self.nostr.as_ref() @@ -580,19 +606,23 @@ impl Worker { Ok(()) } - async fn send_email_verification(&self, user_id: u64, verify_url: &str) -> Result<()> { - let user = self.db.get_user(user_id).await?; + async fn send_email_verification(&self, user_id: u64, verify_url: &str) -> Result<(), OpError> { + let user = self.db.get_user(user_id).await.map_err(|e| OpError::Transient(anyhow::Error::from(e)))?; if user.email.is_empty() { return Ok(()); // No email, nothing to do } let Some(smtp) = self.settings.smtp.as_ref() else { return Ok(()); }; - let message = format!( + let plain_text = format!( "Please verify your email address by clicking the link below:\n\n{}", verify_url ); - Self::send_email(smtp, user.email.as_str(), "Verify your email address", &message).await + let html_message = format!( + r#"Please verify your email address by clicking the link below:

Verify Email Address"#, + verify_url + ); + Self::send_email(smtp, user.email.as_str(), "Verify your email address", &plain_text, Some(&html_message)).await } async fn queue_notification(&self, user_id: u64, message: String, title: Option) { @@ -1691,7 +1721,12 @@ impl Worker { ))); } WorkJob::SendEmailVerification { user_id, verify_url } => { - self.send_email_verification(*user_id, verify_url).await?; + if let Err(e) = self.send_email_verification(*user_id, verify_url).await { + match e { + OpError::Fatal(e) => warn!("Permanent email error for user {}, skipping: {}", user_id, e), + OpError::Transient(e) => return Err(e), + } + } } } Ok(None) diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index 0ef4ee39..20cdc52a 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -1061,7 +1061,9 @@ async fn create_users(db: &LNVpsDbMysql) -> Result> { id: 0, // Will be auto-generated pubkey: pubkey_bytes.clone(), created, - email: Some(EncryptedString::new(email.to_string())), + email: EncryptedString::new(email.to_string()), + email_verified: true, + email_verify_token: String::new(), contact_nip17: true, contact_email: true, country_code: None, diff --git a/lnvps_db/migrations/20260219000000_email_verification.sql b/lnvps_db/migrations/20260220165223_email_verification.sql similarity index 51% rename from lnvps_db/migrations/20260219000000_email_verification.sql rename to lnvps_db/migrations/20260220165223_email_verification.sql index 57e188a5..0dab65e1 100644 --- a/lnvps_db/migrations/20260219000000_email_verification.sql +++ b/lnvps_db/migrations/20260220165223_email_verification.sql @@ -1,3 +1,6 @@ -ALTER TABLE users ADD COLUMN email_verified BIT(1) NOT NULL DEFAULT 0; -ALTER TABLE users ADD COLUMN email_verify_token VARCHAR(64) NOT NULL DEFAULT ''; +DROP INDEX ix_user_email ON users; +UPDATE users SET email = '' WHERE email IS NULL; ALTER TABLE users MODIFY COLUMN email VARCHAR(255) NOT NULL DEFAULT ''; +CREATE INDEX ix_user_email ON users (email); +ALTER TABLE users ADD COLUMN email_verified BIT(1) NOT NULL DEFAULT 0 AFTER email; +ALTER TABLE users ADD COLUMN email_verify_token VARCHAR(64) NOT NULL DEFAULT '' AFTER email_verified;