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/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 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 3294f8be..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,10 +54,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 8394d9d8..63027a11 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,11 @@ pub struct AccountPatchRequest { impl From for AccountPatchRequest { fn from(user: lnvps_db::User) -> Self { + 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, country_code: Some(user.country_code), diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index cbeb75a7..7c52ccb3 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}; @@ -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,38 @@ 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_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]>()); + user.email_verified = false; + user.email_verify_token = token.clone(); + pending_verification = Some(token); + } + } else { + return ApiData::err("Email address is required and cannot be removed"); + } } + + // If contact_email is enabled, email must be set + if req.contact_email && user.email.is_empty() { + 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 { @@ -169,9 +201,93 @@ async fn v1_patch_account( } 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(()) } +#[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, + Query(params): Query, +) -> impl IntoResponse { + 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(&data) + .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.", + "#e74c3c", + ); + } + let mut user = match this.db.get_user_by_email_verify_token(¶ms.token).await { + Ok(u) => u, + Err(_) => { + return make_page( + "Invalid or Expired Link", + "This verification link is invalid or has already been used.", + "#e74c3c", + ); + } + }; + user.email_verified = true; + 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.", + "#e74c3c", + ); + } + make_page( + "Email Verified", + "Your email address has been successfully verified.", + "#2ecc71", + ) +} + +#[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..bdc19bc3 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -520,6 +520,56 @@ impl Worker { Ok(()) } + /// 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, + 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().map_err(|e: lettre::address::AddressError| OpError::Fatal(e.into()))?); + } + 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.map_err(|e| { + if e.is_permanent() { + OpError::Fatal(e.into()) + } else { + OpError::Transient(e.into()) + } + })?; + Ok(()) + } + async fn send_notification( &self, user_id: u64, @@ -529,35 +579,16 @@ 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() { - // 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 to = user.email.as_str().to_string(); + let subject = title.as_deref().unwrap_or("Notification"); + 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), + } } - 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?; } if user.contact_nip17 && let Some(c) = self.nostr.as_ref() @@ -575,6 +606,25 @@ impl Worker { Ok(()) } + 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 plain_text = format!( + "Please verify your email address by clicking the link below:\n\n{}", + verify_url + ); + 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) { if let Err(e) = self .work_commander @@ -1670,6 +1720,14 @@ impl Worker { vm.id, user_id ))); } + WorkJob::SendEmailVerification { user_id, verify_url } => { + 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/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 593ff9d7..0ecf0eb8 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, @@ -261,7 +262,8 @@ 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, country_code: user.country_code, @@ -287,7 +289,8 @@ 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, country_code: user.user_info.country_code, @@ -1607,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_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_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 4431aee1..5b06d320 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.is_empty() && u.email_verify_token == 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()) @@ -1028,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_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/20260220165223_email_verification.sql b/lnvps_db/migrations/20260220165223_email_verification.sql new file mode 100644 index 00000000..0dab65e1 --- /dev/null +++ b/lnvps_db/migrations/20260220165223_email_verification.sql @@ -0,0 +1,6 @@ +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; 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..381a7f48 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -19,7 +19,11 @@ 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) + 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 c7bf6f9a..df288471 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,17 @@ 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 = ? and 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) @@ -1005,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, @@ -1020,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 ) @@ -2397,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,