From 1991f307d83fe288cae45542d8d3a64b3920c730 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 18 Jun 2026 19:42:00 +0200 Subject: [PATCH 01/12] Sends email verification --- .env.template | 4 + .../down.sql | 2 + .../2026-06-18-120000_add_sends_emails/up.sql | 13 ++ .../down.sql | 2 + .../2026-06-18-120000_add_sends_emails/up.sql | 13 ++ .../down.sql | 2 + .../2026-06-18-120000_add_sends_emails/up.sql | 13 ++ playwright/tests/send.spec.ts | 89 +++++++++++- playwright/tests/setups/2fa.ts | 2 +- src/api/core/sends.rs | 37 +++-- src/api/identity.rs | 4 + src/auth/send.rs | 44 +++++- src/config.rs | 4 + src/db/models/mod.rs | 2 +- src/db/models/send.rs | 137 ++++++++++++++++-- src/db/schema.rs | 14 ++ src/mail.rs | 13 ++ src/main.rs | 7 + src/static/templates/email/sends_otp.hbs | 6 + src/static/templates/email/sends_otp.html.hbs | 16 ++ 20 files changed, 386 insertions(+), 38 deletions(-) create mode 100644 migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql create mode 100644 migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql create mode 100644 migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql create mode 100644 migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql create mode 100644 migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql create mode 100644 migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql create mode 100644 src/static/templates/email/sends_otp.hbs create mode 100644 src/static/templates/email/sends_otp.html.hbs diff --git a/.env.template b/.env.template index 272c7738..545bb0d6 100644 --- a/.env.template +++ b/.env.template @@ -187,6 +187,10 @@ ## Cron schedule of the job that cleans sso auth from incomplete flow ## Defaults to daily (20 minutes after midnight). Set blank to disable this job. # PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *" +# +## Cron schedule of the job that cleans expired sends email OTP +## Defaults to daily (22 minutes after midnight). Set blank to disable this job. +# PURGE_SENDS_OTP="0 22 0 * * *" ######################## ### General settings ### diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql new file mode 100644 index 00000000..2cfff832 --- /dev/null +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sends DROP COLUMN emails; +DROP TABLE sends_otp; diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql new file mode 100644 index 00000000..ae69e9d2 --- /dev/null +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE sends ADD COLUMN emails TEXT; + +CREATE TABLE sends_otp ( + send_uuid CHAR(36) NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, + email VARCHAR(255) NOT NULL, + code TEXT NOT NULL, + + creation_date DATETIME NOT NULL, + revision_date DATETIME NOT NULL, + expiration_date DATETIME NOT NULL, + + PRIMARY KEY(send_uuid, email) +); diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql new file mode 100644 index 00000000..2cfff832 --- /dev/null +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sends DROP COLUMN emails; +DROP TABLE sends_otp; diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql new file mode 100644 index 00000000..bdbc4b95 --- /dev/null +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE sends ADD COLUMN emails TEXT; + +CREATE TABLE sends_otp ( + send_uuid TEXT NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, + email TEXT NOT NULL, + code TEXT NOT NULL, + + creation_date TIMESTAMP NOT NULL, + revision_date TIMESTAMP NOT NULL, + expiration_date TIMESTAMP NOT NULL, + + PRIMARY KEY(send_uuid, email) +); diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql new file mode 100644 index 00000000..2cfff832 --- /dev/null +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sends DROP COLUMN emails; +DROP TABLE sends_otp; diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql new file mode 100644 index 00000000..86ab7a45 --- /dev/null +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE sends ADD COLUMN emails TEXT; + +CREATE TABLE sends_otp ( + send_uuid TEXT NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, + email TEXT NOT NULL, + code TEXT NOT NULL, + + creation_date DATETIME NOT NULL, + revision_date DATETIME NOT NULL, + expiration_date DATETIME NOT NULL, + + PRIMARY KEY(send_uuid, email) +); diff --git a/playwright/tests/send.spec.ts b/playwright/tests/send.spec.ts index ddb8009d..12a159ed 100644 --- a/playwright/tests/send.spec.ts +++ b/playwright/tests/send.spec.ts @@ -1,21 +1,46 @@ import { test, expect, type Page, type TestInfo } from '@playwright/test'; import * as OTPAuth from "otpauth"; +import { MailDev } from 'maildev'; import * as utils from "../global-utils"; -import { createAccount } from './setups/user'; +import { createAccount, logUser } from './setups/user'; +import { retrieveEmailCode } from './setups/2fa'; let users = utils.loadEnv(); +let mailserver; + test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, {}); + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVault(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + }); + + const context = await browser.newContext(); + const page = await context.newPage(); + await createAccount(test, page, users.user1); + await context.close(); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); + if( mailserver ){ + await mailserver.close(); + } }); test('Send', async ({ browser, page }) => { - await createAccount(test, page, users.user1); + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await logUser(test, page, users.user1); const send_url = await test.step('Create', async () => { await page.getByRole('link', { name: 'Send' }).click(); @@ -33,15 +58,21 @@ test('Send', async ({ browser, page }) => { return await page.evaluate(() => navigator.clipboard.readText()); }); - const context2 = await browser.newContext(); - const page2 = await context2.newPage(); - await test.step('View', async () => { await page2.goto(send_url, { waitUntil: 'domcontentloaded' }); await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible(); }); + await context2.close(); +}); + +test('Password', async ({ browser, page }) => { + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await logUser(test, page, users.user1); + const pwd_url = await test.step('Create with password', async () => { await page.getByRole('link', { name: 'Send' }).click(); await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); @@ -69,4 +100,50 @@ test('Send', async ({ browser, page }) => { await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible(); }); + + await context2.close(); +}); + +test('Validation', async ({ browser, page }) => { + const mailBuffer = mailserver.buffer(users.user2.email); + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + const pageMail = await context2.newPage(); + + await logUser(test, page, users.user1); + + const mail_url = await test.step('Create with email validation', async () => { + await page.getByRole('link', { name: 'Send' }).click(); + await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Text' }).click(); + + await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Email'); + await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('Email'); + await page.getByRole('combobox', { name: 'Who can view' }).click(); + await page.getByText('Specific people').click(); + await page.getByRole('textbox', { name: 'Emails (required)' }).fill(users.user2.email); + + await page.getByRole('button', { name: 'Save' }).click(); + await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); + + return await page.evaluate(() => navigator.clipboard.readText()); + }); + + await test.step('View with email OTP', async () => { + await page2.goto(mail_url, { waitUntil: 'domcontentloaded' }); + await expect(page2.getByRole('heading', { name: 'Verify your email to view' })).toBeVisible(); + await page2.getByRole('textbox', { name: 'Email (required)' }).fill(users.user2.email); + await page2.getByRole('button', { name: 'Send code' }).click(); + + let code = await retrieveEmailCode(test, pageMail, mailBuffer); + await page2.getByRole('textbox', { name: 'Verification code' }).fill(code); + await page2.getByRole('button', { name: 'View Send' }).click(); + await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); + await expect(await page2.getByRole('paragraph').filter({ hasText: 'Email' })).toBeVisible(); + }); + + mailBuffer.close(); + await context2.close(); }); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index 0b1711a1..94a4ad4f 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -83,7 +83,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string { return await test.step('retrieve code', async () => { - const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code")); + const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Verification Code")); const page2 = await page.context().newPage(); await page2.setContent(codeMail.html); const code = await page2.getByTestId("2fa").innerText(); diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 2a604b74..b8fdbe04 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -148,21 +148,29 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { ); } - if data.emails.is_some() { - err!("Sends with email verification is not supported"); + if !CONFIG.mail_enabled() && data.emails.is_some() { + err!("Email are disabled, cannot send verification code"); } - let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); - send.user_uuid = Some(user_id); - send.notes = data.notes; - send.max_access_count = match data.max_access_count { + let max_access_count = match data.max_access_count { Some(m) => Some(m.into_i32()?), _ => None, }; - send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); - send.disabled = data.disabled; - send.hide_email = data.hide_email; - send.atype = data.r#type; + + let mut send = Send::new( + data.r#type, + user_id, + data.name, + data.notes, + data_str, + data.key, + max_access_count, + data.emails, + data.expiration_date.map(|d| d.naive_utc()), + data.deletion_date.naive_utc(), + data.disabled, + data.hide_email, + ); send.set_password(data.password.as_deref()); @@ -469,14 +477,14 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: let data: SendData = data.into_inner(); enforce_disable_hide_email_policy(&data, &headers, &conn).await?; + if !CONFIG.mail_enabled() && data.emails.is_some() { + err!("Email are disabled, cannot send verification code"); + } + let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { err!("Send not found", "Send send_id is invalid or does not belong to user") }; - if data.emails.is_some() { - err!("Sends with email verification is not supported"); - } - update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) @@ -527,6 +535,7 @@ async fn update_send_from_data( send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); send.hide_email = data.hide_email; send.disabled = data.disabled; + send.emails = data.emails.map(|e| e.to_lowercase()); // Only change the value if it's present if let Some(password) = data.password { diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..bdcda769 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -105,6 +105,8 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo let tokens = auth::SendTokens::generate_tokens( data.send_id.as_ref().unwrap(), data.password_hash_b64, + data.email.map(|e| e.to_lowercase()), + data.otp, &client_header.ip, &conn, ) @@ -1165,6 +1167,8 @@ struct ConnectData { // Needed for send access send_id: Option, password_hash_b64: Option, + email: Option, + otp: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth/send.rs b/src/auth/send.rs index 518b7098..f42cb7d0 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -3,14 +3,17 @@ use chrono::{TimeDelta, Utc}; use rocket::request::{FromRequest, Outcome, Request}; use crate::{ + CONFIG, api::ApiResult, auth, auth::{BasicJwtClaims, ClientIp}, + crypto, db::{ DbConn, - models::{Send, SendId}, + models::{SendId, SendOTP}, }, error::{Error, ErrorKind}, + mail, }; fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { @@ -68,6 +71,8 @@ impl SendTokens { pub async fn generate_tokens( access_id: &str, password: Option, + email: Option, + otp: Option, ip: &ClientIp, conn: &DbConn, ) -> ApiResult { @@ -75,7 +80,7 @@ impl SendTokens { return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); }; - let Some(send) = Send::find_by_uuid(&send_id, conn).await else { + let Some((mut send, o_otp)) = SendOTP::find_with_send(&send_id, email.as_ref(), conn).await else { return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); }; @@ -89,6 +94,41 @@ impl SendTokens { return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true); } + if !CONFIG.mail_enabled() && send.emails.is_some() { + return Self::invalid_error( + &format!("Send {send_id}, email disabled but require verification"), + "send_id_invalid", + false, + ); + } + + if let Some(mut split) = send.emails.as_ref().map(|s| s.split(',')) { + let now = Utc::now().naive_utc(); + match email.as_ref() { + Some(e) if split.any(|s| s == e) => { + match (otp, o_otp) { + (Some(code), Some(db_otp)) if crypto::ct_eq(&code, &db_otp.code) && now < db_otp.expiration_date => (), + (None, _) => { + // SEND OTP CODE + let code = crypto::generate_email_token(CONFIG.email_token_size()); + SendOTP::new(send_id, e, code.clone()).save(conn).await?; + mail::send_sends_otp(e, &code).await?; + return Self::expected_error("Email OTP required", "email_and_otp_required"); + } + _ => return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true), + } + } + Some(e) => { + return Self::invalid_error( + &format!("Send {send_id}, invalid access from {e}"), + "send_id_invalid", + false, + ); + } + None => return Self::expected_error("Email validation required", "email_required"), + } + } + if send.password_hash.is_some() { match password { Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } diff --git a/src/config.rs b/src/config.rs index a56748f4..fa6a1051 100644 --- a/src/config.rs +++ b/src/config.rs @@ -567,6 +567,9 @@ make_config! { /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login. /// Defaults to daily. Set blank to disable this job. purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_owned(); + /// Purge expired sends OTP |> Cron schedule of the job that cleans the sends email verification OTP. + /// Defaults to daily. Set blank to disable this job. + purge_sends_otp: String, false, def, "0 22 0 * * *".to_owned(); }, /// General settings @@ -1744,6 +1747,7 @@ where reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); + reg!("email/sends_otp", ".html"); reg!("email/smtp_test", ".html"); reg!("email/sso_change_email", ".html"); reg!("email/twofactor_email", ".html"); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..a7a99c28 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -34,7 +34,7 @@ pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, }; -pub use self::send::{Send, SendFileId, SendId, SendType}; +pub use self::send::{Send, SendFileId, SendId, SendOTP, SendType}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 721378f3..f9d0b8e5 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -1,6 +1,6 @@ use std::path::Path; -use chrono::{NaiveDateTime, Utc}; +use chrono::{NaiveDateTime, TimeDelta, Utc}; use data_encoding::BASE64URL_NOPAD; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; @@ -12,14 +12,17 @@ use crate::{ CONFIG, api::EmptyResult, config::PathType, - db::{DbConn, schema::sends}, + db::{ + DbConn, DbPool, + schema::{sends, sends_otp}, + }, error::MapResult, util::{LowerCase, NumberOrString, format_date}, }; use super::{OrganizationId, User, UserId}; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] +#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = sends)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] @@ -41,6 +44,7 @@ pub struct Send { pub max_access_count: Option, pub access_count: i32, + pub emails: Option, pub creation_date: NaiveDateTime, pub revision_date: NaiveDateTime, @@ -60,7 +64,7 @@ pub enum SendType { enum SendAuthType { #[allow(dead_code)] // Send requires email OTP verification - Email = 0, // Not yet supported by Vaultwarden + Email = 0, // Send requires a password Password = 1, // Send requires no auth @@ -68,17 +72,29 @@ enum SendAuthType { } impl Send { - pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self { + #[allow(clippy::too_many_arguments)] + pub fn new( + atype: i32, + user_uuid: UserId, + name: String, + notes: Option, + data: String, + akey: String, + max_access_count: Option, + emails: Option, + expiration_date: Option, + deletion_date: NaiveDateTime, + disabled: bool, + hide_email: Option, + ) -> Self { let now = Utc::now().naive_utc(); Self { uuid: SendId::from(crate::util::get_uuid()), - user_uuid: None, + user_uuid: Some(user_uuid), organization_uuid: None, - name, - notes: None, - + notes, atype, data, akey, @@ -86,16 +102,17 @@ impl Send { password_salt: None, password_iter: None, - max_access_count: None, + max_access_count, access_count: 0, + emails: emails.map(|e| e.to_lowercase()), creation_date: now, revision_date: now, - expiration_date: None, + expiration_date, deletion_date, - disabled: false, - hide_email: None, + disabled, + hide_email, } } @@ -166,9 +183,10 @@ impl Send { "maxAccessCount": self.max_access_count, "accessCount": self.access_count, "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), - "authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 }, + "authType": if self.password_hash.is_some() { SendAuthType::Password } else if self.emails.is_some() { SendAuthType::Email } else { SendAuthType::None } as i32, "disabled": self.disabled, "hideEmail": self.hide_email.unwrap_or(false), + "emails": self.emails, "revisionDate": format_date(&self.revision_date), "expirationDate": self.expiration_date.as_ref().map(format_date), @@ -397,3 +415,94 @@ impl AsRef for SendFileId { Path::new(&self.0) } } + +#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] +#[diesel(table_name = sends_otp)] +#[diesel(treat_none_as_null = true)] +#[diesel(primary_key(send_uuid, email))] +pub struct SendOTP { + pub send_uuid: SendId, + pub email: String, + + pub code: String, + + pub creation_date: NaiveDateTime, + pub revision_date: NaiveDateTime, + pub expiration_date: NaiveDateTime, +} + +impl SendOTP { + pub fn new(send_id: SendId, email: &str, code: String) -> Self { + let now = Utc::now().naive_utc(); + + Self { + send_uuid: send_id, + email: email.to_lowercase(), + code, + creation_date: now, + revision_date: now, + expiration_date: now + TimeDelta::try_minutes(5).unwrap(), + } + } + + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: + mysql { + diesel::insert_into(sends_otp::table) + .values(&*self) + .on_conflict(diesel::dsl::DuplicatedKeys) + .do_update() + .set(( + sends_otp::code.eq(&self.code), + sends_otp::expiration_date.eq(self.expiration_date), + sends_otp::revision_date.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .map_res("Error saving send_otp") + } + postgresql, sqlite { + diesel::insert_into(sends_otp::table) + .values(&*self) + .on_conflict((sends_otp::send_uuid, sends_otp::email)) + .do_update() + .set(( + sends_otp::code.eq(&self.code), + sends_otp::expiration_date.eq(self.expiration_date), + sends_otp::revision_date.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .map_res("Error saving send_otp") + } + } + } + + pub async fn find_with_send(uuid: &SendId, email: Option<&String>, conn: &DbConn) -> Option<(Send, Option)> { + if let Some(mail) = email.map(|e| e.to_lowercase()) { + conn.run(move |conn| { + sends::table + .left_join(sends_otp::table.on(sends::uuid.eq(sends_otp::send_uuid).and(sends_otp::email.eq(mail)))) + .select(<(Send, Option)>::as_select()) + .filter(sends::uuid.eq(uuid)) + .first::<(Send, Option)>(conn) + .ok() + }) + .await + } else { + Send::find_by_uuid(uuid, conn).await.map(|s| (s, None)) + } + } + + pub async fn delete_expired(pool: DbPool) -> EmptyResult { + debug!("Purging expired sends_otp"); + if let Ok(conn) = pool.get().await { + conn.run(move |conn| { + diesel::delete(sends_otp::table.filter(sends_otp::expiration_date.lt(Utc::now().naive_utc()))) + .execute(conn) + .map_res("Error deleting expired Sends OTP") + }) + .await + } else { + err!("Failed to get DB connection while purging expired sends_otp") + } + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs index ae1e3216..dde675b7 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -145,6 +145,7 @@ table! { password_iter -> Nullable, max_access_count -> Nullable, access_count -> Integer, + emails -> Nullable, creation_date -> Timestamp, revision_date -> Timestamp, expiration_date -> Nullable, @@ -154,6 +155,17 @@ table! { } } +table! { + sends_otp (send_uuid, email) { + send_uuid -> Text, + email -> Text, + code -> Text, + creation_date -> Timestamp, + revision_date -> Timestamp, + expiration_date -> Timestamp, + } +} + table! { twofactor (uuid) { uuid -> Text, @@ -368,6 +380,7 @@ joinable!(folders_ciphers -> folders (folder_uuid)); joinable!(org_policies -> organizations (org_uuid)); joinable!(sends -> organizations (organization_uuid)); joinable!(sends -> users (user_uuid)); +joinable!(sends_otp -> sends (send_uuid)); joinable!(twofactor -> users (user_uuid)); joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); @@ -398,6 +411,7 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, + sends_otp, sso_users, twofactor, users, diff --git a/src/mail.rs b/src/mail.rs index 2efb8d70..93e4e27d 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -684,6 +684,19 @@ pub async fn send_protected_action_token(address: &str, token: &str) -> EmptyRes send_email(address, &subject, body_html, body_text).await } +pub async fn send_sends_otp(address: &str, code: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/sends_otp", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "token": code, + }), + )?; + + send_email(address, &subject, body_html, body_text).await +} + async fn send_with_selected_transport(email: Message) -> EmptyResult { if CONFIG.use_sendmail() { match sendmail_transport().send(email).await { diff --git a/src/main.rs b/src/main.rs index f27729d6..68910779 100644 --- a/src/main.rs +++ b/src/main.rs @@ -743,6 +743,13 @@ fn schedule_jobs(pool: db::DbPool) { })); } + // Purge expired sends OTP (default to daily at 00h22). + if !CONFIG.purge_sends_otp().is_empty() { + sched.add(Job::new(CONFIG.purge_sends_otp().parse().unwrap(), || { + runtime.spawn(db::models::SendOTP::delete_expired(pool.clone())); + })); + } + // Periodically check for jobs to run. We probably won't need any // jobs that run more often than once a minute, so a default poll // interval of 30 seconds should be sufficient. Users who want to diff --git a/src/static/templates/email/sends_otp.hbs b/src/static/templates/email/sends_otp.hbs new file mode 100644 index 00000000..8aae1a5d --- /dev/null +++ b/src/static/templates/email/sends_otp.hbs @@ -0,0 +1,6 @@ +Your Vaultwarden Sends Verification Code + +Your email verification code is: {{token}} + +Use this code to access the shared secret in Vaultwarden. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/sends_otp.html.hbs b/src/static/templates/email/sends_otp.html.hbs new file mode 100644 index 00000000..b94e0dd9 --- /dev/null +++ b/src/static/templates/email/sends_otp.html.hbs @@ -0,0 +1,16 @@ +Your Vaultwarden Sends Verification Code + +{{> email/email_header }} + + + + + + + +
+ Your email verification code is: {{token}} +
+ Use this code to access the shared secret in Vaultwarden. +
+{{> email/email_footer }} From 71ffbe6dca10008620baacb7c50daca21804e4ff Mon Sep 17 00:00:00 2001 From: Timshel Date: Mon, 22 Jun 2026 16:23:42 +0200 Subject: [PATCH 02/12] Refactor Sends table --- .../2026-06-18-120000_add_sends_emails/up.sql | 38 +++++++++++++++++ .../2026-06-18-120000_add_sends_emails/up.sql | 7 ++++ .../down.sql | 2 - .../2026-06-18-120000_add_sends_emails/up.sql | 41 ++++++++++++++++++- src/api/core/sends.rs | 6 +-- src/api/notifications.rs | 3 +- src/api/push.rs | 4 +- src/db/models/send.rs | 40 ++++++------------ src/db/schema.rs | 6 +-- 9 files changed, 105 insertions(+), 42 deletions(-) diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql index ae69e9d2..299c9093 100644 --- a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql @@ -11,3 +11,41 @@ CREATE TABLE sends_otp ( PRIMARY KEY(send_uuid, email) ); + + +DELETE FROM sends where user_uuid IS NULL; +UPDATE sends SET hide_email = false WHERE hide_email IS NULL; + +SELECT if ( + EXISTS( + SELECT CONSTRAINT_NAME FROM information_schema.table_constraints + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sends' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + AND CONSTRAINT_NAME = 'sends_ibfk_2' + ) + ,'ALTER TABLE sends DROP FOREIGN KEY `sends_ibfk_2`' + ,'SELECT "info: FK sends_ibfk_2 does not exist."' +) INTO @drop_stmt; +PREPARE drop_stmt FROM @drop_stmt; +EXECUTE drop_stmt; + +SELECT if ( + EXISTS( + SELECT CONSTRAINT_NAME FROM information_schema.table_constraints + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sends' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + AND CONSTRAINT_NAME = '2' + ) + ,'ALTER TABLE sends DROP FOREIGN KEY `2`' + ,'SELECT "info: FK sends 2 does not exist."' +) INTO @drop_stmt; +PREPARE drop_stmt FROM @drop_stmt; +EXECUTE drop_stmt; + +DEALLOCATE PREPARE drop_stmt; + +ALTER TABLE sends DROP COLUMN organization_uuid; +ALTER TABLE sends MODIFY user_uuid CHAR(36) NOT NULL; +ALTER TABLE sends MODIFY hide_email BOOLEAN NOT NULL; diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql index bdbc4b95..9f7a0921 100644 --- a/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql @@ -11,3 +11,10 @@ CREATE TABLE sends_otp ( PRIMARY KEY(send_uuid, email) ); + + +DELETE FROM sends where user_uuid IS NULL; +UPDATE sends SET hide_email = false WHERE hide_email IS NULL; +ALTER TABLE sends DROP COLUMN organization_uuid; +ALTER TABLE sends ALTER COLUMN user_uuid SET NOT NULL; +ALTER TABLE sends ALTER COLUMN hide_email SET NOT NULL; diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql index 2cfff832..e69de29b 100644 --- a/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql @@ -1,2 +0,0 @@ -ALTER TABLE sends DROP COLUMN emails; -DROP TABLE sends_otp; diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql index 86ab7a45..28b6daab 100644 --- a/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql @@ -1,4 +1,43 @@ -ALTER TABLE sends ADD COLUMN emails TEXT; +ALTER TABLE sends RENAME TO sends_old; + +CREATE TABLE sends ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users (uuid), + + name TEXT NOT NULL, + notes TEXT, + + atype INTEGER NOT NULL, + data TEXT NOT NULL, + akey TEXT NOT NULL, + password_hash BLOB, + password_salt BLOB, + password_iter INTEGER, + emails TEXT, + + max_access_count INTEGER, + access_count INTEGER NOT NULL, + + creation_date DATETIME NOT NULL, + revision_date DATETIME NOT NULL, + expiration_date DATETIME, + deletion_date DATETIME NOT NULL, + + disabled BOOLEAN NOT NULL, + hide_email BOOLEAN NOT NULL +); + +INSERT INTO sends( + uuid, user_uuid, name, notes, atype, data, akey, password_hash, password_salt, password_iter, + max_access_count, access_count, creation_date, revision_date, expiration_date, deletion_date, + disabled, hide_email +) SELECT uuid, user_uuid, name, notes, atype, data, akey, password_hash, password_salt, password_iter, + max_access_count, access_count, creation_date, revision_date, expiration_date, deletion_date, + disabled, + CASE WHEN hide_email IS NOT NULL THEN hide_email ELSE false END + FROM sends_old WHERE user_uuid IS NOT NULL; + +DROP TABLE sends_old; CREATE TABLE sends_otp ( send_uuid TEXT NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index b8fdbe04..6b11dbd5 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -169,7 +169,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { data.expiration_date.map(|d| d.naive_utc()), data.deletion_date.naive_utc(), data.disabled, - data.hide_email, + data.hide_email.unwrap_or(false), ); send.set_password(data.password.as_deref()); @@ -498,7 +498,7 @@ async fn update_send_from_data( nt: &Notify<'_>, ut: UpdateType, ) -> EmptyResult { - if send.user_uuid.as_ref() != Some(&headers.user.uuid) { + if send.user_uuid != headers.user.uuid { err!("Send is not owned by user") } @@ -533,7 +533,7 @@ async fn update_send_from_data( _ => None, }; send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); - send.hide_email = data.hide_email; + send.hide_email = data.hide_email.unwrap_or(false); send.disabled = data.disabled; send.emails = data.emails.map(|e| e.to_lowercase()); diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 32204916..16429b44 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -516,12 +516,11 @@ impl WebSocketUsers { if *NOTIFICATIONS_DISABLED { return; } - let user_id = convert_option(send.user_uuid.as_deref()); let data = create_update( vec![ ("Id".into(), send.uuid.to_string().into()), - ("UserId".into(), user_id), + ("UserId".into(), send.user_uuid.to_string().into()), ("RevisionDate".into(), serialize_date(send.revision_date)), ], ut, diff --git a/src/api/push.rs b/src/api/push.rs index e87a0985..ac27d85e 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -244,9 +244,7 @@ pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device } pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) { - if let Some(s) = &send.user_uuid - && Device::check_user_has_push_device(s, conn).await - { + if Device::check_user_has_push_device(&send.user_uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": send.user_uuid, "organizationId": null, diff --git a/src/db/models/send.rs b/src/db/models/send.rs index f9d0b8e5..7b29a9c3 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -20,7 +20,7 @@ use crate::{ util::{LowerCase, NumberOrString, format_date}, }; -use super::{OrganizationId, User, UserId}; +use super::{User, UserId}; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = sends)] @@ -29,8 +29,7 @@ use super::{OrganizationId, User, UserId}; pub struct Send { pub uuid: SendId, - pub user_uuid: Option, - pub organization_uuid: Option, + pub user_uuid: UserId, pub name: String, pub notes: Option, @@ -52,7 +51,7 @@ pub struct Send { pub deletion_date: NaiveDateTime, pub disabled: bool, - pub hide_email: Option, + pub hide_email: bool, } #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] @@ -85,14 +84,13 @@ impl Send { expiration_date: Option, deletion_date: NaiveDateTime, disabled: bool, - hide_email: Option, + hide_email: bool, ) -> Self { let now = Utc::now().naive_utc(); Self { uuid: SendId::from(crate::util::get_uuid()), - user_uuid: Some(user_uuid), - organization_uuid: None, + user_uuid, name, notes, atype, @@ -142,19 +140,13 @@ impl Send { } pub async fn creator_identifier(&self, conn: &DbConn) -> Option { - if let Some(hide_email) = self.hide_email - && hide_email + if !self.hide_email + && let Some(user) = User::find_by_uuid(&self.user_uuid, conn).await { - return None; - } - - if let Some(user_uuid) = &self.user_uuid - && let Some(user) = User::find_by_uuid(user_uuid, conn).await - { - return Some(user.email); + Some(user.email) + } else { + None } - - None } fn access_id(&self) -> String { @@ -185,7 +177,7 @@ impl Send { "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "authType": if self.password_hash.is_some() { SendAuthType::Password } else if self.emails.is_some() { SendAuthType::Email } else { SendAuthType::None } as i32, "disabled": self.disabled, - "hideEmail": self.hide_email.unwrap_or(false), + "hideEmail": self.hide_email, "emails": self.emails, "revisionDate": format_date(&self.revision_date), @@ -314,14 +306,8 @@ impl Send { } pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { - let mut user_uuids = Vec::new(); - if let Some(user_uuid) = &self.user_uuid { - User::update_uuid_revision(user_uuid, conn).await; - user_uuids.push(user_uuid.clone()); - } else { - // Belongs to Organization, not implemented - } - user_uuids + User::update_uuid_revision(&self.user_uuid, conn).await; + vec![self.user_uuid.clone()] } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { diff --git a/src/db/schema.rs b/src/db/schema.rs index dde675b7..d45e96ff 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -133,8 +133,7 @@ table! { table! { sends (uuid) { uuid -> Text, - user_uuid -> Nullable, - organization_uuid -> Nullable, + user_uuid -> Text, name -> Text, notes -> Nullable, atype -> Integer, @@ -151,7 +150,7 @@ table! { expiration_date -> Nullable, deletion_date -> Timestamp, disabled -> Bool, - hide_email -> Nullable, + hide_email -> Bool, } } @@ -378,7 +377,6 @@ joinable!(folders -> users (user_uuid)); joinable!(folders_ciphers -> ciphers (cipher_uuid)); joinable!(folders_ciphers -> folders (folder_uuid)); joinable!(org_policies -> organizations (org_uuid)); -joinable!(sends -> organizations (organization_uuid)); joinable!(sends -> users (user_uuid)); joinable!(sends_otp -> sends (send_uuid)); joinable!(twofactor -> users (user_uuid)); From 53eefc1d7a4446caa636460eb2aa9ebbddbb2153 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 15 Oct 2026 10:03:32 +0200 Subject: [PATCH 03/12] Delete SendOTP on use --- src/auth/send.rs | 24 ++++++++++++++++++------ src/db/models/send.rs | 9 +++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/auth/send.rs b/src/auth/send.rs index f42cb7d0..c293e01e 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -80,7 +80,7 @@ impl SendTokens { return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); }; - let Some((mut send, o_otp)) = SendOTP::find_with_send(&send_id, email.as_ref(), conn).await else { + let Some((send, o_otp)) = SendOTP::find_with_send(&send_id, email.as_ref(), conn).await else { return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); }; @@ -106,16 +106,28 @@ impl SendTokens { let now = Utc::now().naive_utc(); match email.as_ref() { Some(e) if split.any(|s| s == e) => { - match (otp, o_otp) { - (Some(code), Some(db_otp)) if crypto::ct_eq(&code, &db_otp.code) && now < db_otp.expiration_date => (), - (None, _) => { + if let Some(code) = otp { + if let Some(db_otp) = o_otp { + if crypto::ct_eq(&code, &db_otp.code) && now < db_otp.expiration_date { + db_otp.delete(conn).await?; + } else { + return Self::expected_error("Email OTP required", "email_and_otp_required"); + } + } else { + return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true); + } + } else { + if let Some(db_otp) = o_otp + && now + TimeDelta::try_minutes(1).unwrap() < db_otp.expiration_date + { + // Code is still valid for a minute do nothing + } else { // SEND OTP CODE let code = crypto::generate_email_token(CONFIG.email_token_size()); SendOTP::new(send_id, e, code.clone()).save(conn).await?; mail::send_sends_otp(e, &code).await?; - return Self::expected_error("Email OTP required", "email_and_otp_required"); } - _ => return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true), + return Self::expected_error("Email OTP required", "email_and_otp_required"); } } Some(e) => { diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 7b29a9c3..b27a7650 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -462,6 +462,15 @@ impl SendOTP { } } + pub async fn delete(&self, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::delete(sends_otp::table.filter(sends_otp::send_uuid.eq(&self.send_uuid))) + .execute(conn) + .map_res("Error deleting send_otp") + }) + .await + } + pub async fn find_with_send(uuid: &SendId, email: Option<&String>, conn: &DbConn) -> Option<(Send, Option)> { if let Some(mail) = email.map(|e| e.to_lowercase()) { conn.run(move |conn| { From daf6f7780aa31601333205be78a67125153c201e Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 15 Oct 2026 11:26:12 +0200 Subject: [PATCH 04/12] Sends emails trim and lower case --- src/api/core/sends.rs | 2 +- src/auth/send.rs | 8 ++++---- src/db/models/send.rs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 6b11dbd5..0b45f319 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -535,7 +535,7 @@ async fn update_send_from_data( send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); send.hide_email = data.hide_email.unwrap_or(false); send.disabled = data.disabled; - send.emails = data.emails.map(|e| e.to_lowercase()); + send.emails = data.emails.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()); // Only change the value if it's present if let Some(password) = data.password { diff --git a/src/auth/send.rs b/src/auth/send.rs index c293e01e..c53f577e 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -104,8 +104,8 @@ impl SendTokens { if let Some(mut split) = send.emails.as_ref().map(|s| s.split(',')) { let now = Utc::now().naive_utc(); - match email.as_ref() { - Some(e) if split.any(|s| s == e) => { + match email.map(|e| e.trim().to_lowercase()) { + Some(e) if split.any(|s| s.trim() == e) => { if let Some(code) = otp { if let Some(db_otp) = o_otp { if crypto::ct_eq(&code, &db_otp.code) && now < db_otp.expiration_date { @@ -124,8 +124,8 @@ impl SendTokens { } else { // SEND OTP CODE let code = crypto::generate_email_token(CONFIG.email_token_size()); - SendOTP::new(send_id, e, code.clone()).save(conn).await?; - mail::send_sends_otp(e, &code).await?; + SendOTP::new(send_id, &e, code.clone()).save(conn).await?; + mail::send_sends_otp(&e, &code).await?; } return Self::expected_error("Email OTP required", "email_and_otp_required"); } diff --git a/src/db/models/send.rs b/src/db/models/send.rs index b27a7650..5c1677d0 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -102,7 +102,7 @@ impl Send { max_access_count, access_count: 0, - emails: emails.map(|e| e.to_lowercase()), + emails: emails.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()), creation_date: now, revision_date: now, From 49d3af40742fde5579bf3385299d3d9c18ffab49 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 15 Oct 2026 11:26:39 +0200 Subject: [PATCH 05/12] Sends handle invalid email as valid --- src/auth/send.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/auth/send.rs b/src/auth/send.rs index c53f577e..30e1f9df 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -130,13 +130,7 @@ impl SendTokens { return Self::expected_error("Email OTP required", "email_and_otp_required"); } } - Some(e) => { - return Self::invalid_error( - &format!("Send {send_id}, invalid access from {e}"), - "send_id_invalid", - false, - ); - } + Some(_) => return Self::expected_error("Email OTP required", "email_and_otp_required"), None => return Self::expected_error("Email validation required", "email_required"), } } From 8a4c6d416a3999ea4abee60803ef433056d099a8 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 15 Oct 2026 11:38:51 +0200 Subject: [PATCH 06/12] Remove cockroachdb, delete partial down --- migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql | 0 migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql | 1 - migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql | 2 -- .../postgresql/2026-06-18-120000_add_sends_emails/down.sql | 2 -- 4 files changed, 5 deletions(-) delete mode 100644 migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql delete mode 100644 migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql deleted file mode 100644 index d25396cf..00000000 --- a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql index 2cfff832..e69de29b 100644 --- a/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql @@ -1,2 +0,0 @@ -ALTER TABLE sends DROP COLUMN emails; -DROP TABLE sends_otp; diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql index 2cfff832..e69de29b 100644 --- a/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql @@ -1,2 +0,0 @@ -ALTER TABLE sends DROP COLUMN emails; -DROP TABLE sends_otp; From 5253c3ea041ca17830445d64794306598a508e3a Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 15 Oct 2026 12:07:23 +0200 Subject: [PATCH 07/12] Sends dynamic drop of FK constraint --- .../2026-06-18-120000_add_sends_emails/up.sql | 31 +++---------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql index 299c9093..3de9af59 100644 --- a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql @@ -16,34 +16,13 @@ CREATE TABLE sends_otp ( DELETE FROM sends where user_uuid IS NULL; UPDATE sends SET hide_email = false WHERE hide_email IS NULL; -SELECT if ( - EXISTS( - SELECT CONSTRAINT_NAME FROM information_schema.table_constraints - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'sends' - AND CONSTRAINT_TYPE = 'FOREIGN KEY' - AND CONSTRAINT_NAME = 'sends_ibfk_2' - ) - ,'ALTER TABLE sends DROP FOREIGN KEY `sends_ibfk_2`' - ,'SELECT "info: FK sends_ibfk_2 does not exist."' -) INTO @drop_stmt; +SELECT CONCAT('ALTER TABLE sends DROP FOREIGN KEY `', constraint_name, '`') INTO @drop_stmt + FROM information_schema.key_column_usage + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sends' + AND COLUMN_NAME = 'organization_uuid'; PREPARE drop_stmt FROM @drop_stmt; EXECUTE drop_stmt; - -SELECT if ( - EXISTS( - SELECT CONSTRAINT_NAME FROM information_schema.table_constraints - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'sends' - AND CONSTRAINT_TYPE = 'FOREIGN KEY' - AND CONSTRAINT_NAME = '2' - ) - ,'ALTER TABLE sends DROP FOREIGN KEY `2`' - ,'SELECT "info: FK sends 2 does not exist."' -) INTO @drop_stmt; -PREPARE drop_stmt FROM @drop_stmt; -EXECUTE drop_stmt; - DEALLOCATE PREPARE drop_stmt; ALTER TABLE sends DROP COLUMN organization_uuid; From 5db5a0b2fd616ee465e77fcca158545b3b77c452 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 15 Oct 2026 14:38:14 +0200 Subject: [PATCH 08/12] Sends: delete the otp if the mail was not sent --- src/auth/send.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/auth/send.rs b/src/auth/send.rs index 30e1f9df..18e2e850 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -124,8 +124,12 @@ impl SendTokens { } else { // SEND OTP CODE let code = crypto::generate_email_token(CONFIG.email_token_size()); - SendOTP::new(send_id, &e, code.clone()).save(conn).await?; - mail::send_sends_otp(&e, &code).await?; + let send_otp = SendOTP::new(send_id, &e, code.clone()); + send_otp.save(conn).await?; + if let Err(e) = mail::send_sends_otp(&e, &code).await { + drop(send_otp.delete(conn).await); + Err(e)?; + } } return Self::expected_error("Email OTP required", "email_and_otp_required"); } From 379737a401ceb0fcc03283633d2bbe98faa624ff Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 15 Oct 2026 15:09:22 +0200 Subject: [PATCH 09/12] Sends: fix tests --- playwright/tests/send.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/playwright/tests/send.spec.ts b/playwright/tests/send.spec.ts index 12a159ed..1a09ec9d 100644 --- a/playwright/tests/send.spec.ts +++ b/playwright/tests/send.spec.ts @@ -77,7 +77,7 @@ test('Password', async ({ browser, page }) => { await page.getByRole('link', { name: 'Send' }).click(); await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); - await page.getByRole('button', { name: 'New' }).click(); + await page.getByRole('button', { name: ' New', exact: true }).click(); await page.getByRole('menuitem', { name: 'Text' }).click(); await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password'); @@ -116,14 +116,14 @@ test('Validation', async ({ browser, page }) => { await page.getByRole('link', { name: 'Send' }).click(); await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); - await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('button', { name: ' New', exact: true }).click(); await page.getByRole('menuitem', { name: 'Text' }).click(); - await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Email'); - await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('Email'); + await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Email'); + await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('Email'); await page.getByRole('combobox', { name: 'Who can view' }).click(); await page.getByText('Specific people').click(); - await page.getByRole('textbox', { name: 'Emails (required)' }).fill(users.user2.email); + await page.getByRole('textbox', { name: 'Emails * (required)' }).fill(users.user2.email); await page.getByRole('button', { name: 'Save' }).click(); await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); @@ -134,7 +134,7 @@ test('Validation', async ({ browser, page }) => { await test.step('View with email OTP', async () => { await page2.goto(mail_url, { waitUntil: 'domcontentloaded' }); await expect(page2.getByRole('heading', { name: 'Verify your email to view' })).toBeVisible(); - await page2.getByRole('textbox', { name: 'Email (required)' }).fill(users.user2.email); + await page2.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email); await page2.getByRole('button', { name: 'Send code' }).click(); let code = await retrieveEmailCode(test, pageMail, mailBuffer); From 3948edaa7f8171a0f9af3aeac36f3e9cece3a7b0 Mon Sep 17 00:00:00 2001 From: Timshel Date: Fri, 16 Oct 2026 10:28:22 +0200 Subject: [PATCH 10/12] Sends: explicit FK creation --- migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql index 3de9af59..5b988ffd 100644 --- a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql @@ -1,7 +1,7 @@ ALTER TABLE sends ADD COLUMN emails TEXT; CREATE TABLE sends_otp ( - send_uuid CHAR(36) NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, + send_uuid CHAR(36) NOT NULL, email VARCHAR(255) NOT NULL, code TEXT NOT NULL, @@ -12,6 +12,7 @@ CREATE TABLE sends_otp ( PRIMARY KEY(send_uuid, email) ); +ALTER TABLE sends_otp ADD CONSTRAINT fk_send_uuid FOREIGN KEY (send_uuid) REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE; DELETE FROM sends where user_uuid IS NULL; UPDATE sends SET hide_email = false WHERE hide_email IS NULL; From d6e0a74f57c9831931c476b738228278ed64a737 Mon Sep 17 00:00:00 2001 From: Timshel Date: Fri, 16 Oct 2026 13:29:10 +0200 Subject: [PATCH 11/12] Sends: store auth_type --- Cargo.lock | 12 +++++ Cargo.toml | 1 + .../2026-06-18-120000_add_sends_emails/up.sql | 3 ++ .../2026-06-18-120000_add_sends_emails/up.sql | 3 ++ .../2026-06-18-120000_add_sends_emails/up.sql | 6 ++- src/api/core/sends.rs | 44 ++++++++++++------- src/db/models/mod.rs | 2 +- src/db/models/send.rs | 18 +++++--- src/db/schema.rs | 1 + 9 files changed, 65 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c9d9237..3f9b8ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4961,6 +4961,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -5991,6 +6002,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_repr", "subtle", "svg-hush", "syslog", diff --git a/Cargo.toml b/Cargo.toml index 764fbd53..963e0117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,7 @@ tokio-util = { version = "0.7.19", features = ["compat"] } # A generic serialization/deserialization framework serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +serde_repr = "0.1.21" # A safe, extensible ORM and Query builder diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] } diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql index 5b988ffd..324a7b4f 100644 --- a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql @@ -1,3 +1,6 @@ +ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2; +UPDATE sends SET auth_type = 1 WHERE password_hash is NOT NULL; + ALTER TABLE sends ADD COLUMN emails TEXT; CREATE TABLE sends_otp ( diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql index 9f7a0921..94022002 100644 --- a/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql @@ -1,3 +1,6 @@ +ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2; +UPDATE sends SET auth_type = 1 WHERE password_hash is NOT NULL; + ALTER TABLE sends ADD COLUMN emails TEXT; CREATE TABLE sends_otp ( diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql index 28b6daab..582c8378 100644 --- a/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql @@ -8,6 +8,7 @@ CREATE TABLE sends ( notes TEXT, atype INTEGER NOT NULL, + auth_type INTEGER NOT NULL, data TEXT NOT NULL, akey TEXT NOT NULL, password_hash BLOB, @@ -30,11 +31,12 @@ CREATE TABLE sends ( INSERT INTO sends( uuid, user_uuid, name, notes, atype, data, akey, password_hash, password_salt, password_iter, max_access_count, access_count, creation_date, revision_date, expiration_date, deletion_date, - disabled, hide_email + disabled, hide_email, auth_type ) SELECT uuid, user_uuid, name, notes, atype, data, akey, password_hash, password_salt, password_iter, max_access_count, access_count, creation_date, revision_date, expiration_date, deletion_date, disabled, - CASE WHEN hide_email IS NOT NULL THEN hide_email ELSE false END + CASE WHEN hide_email IS NOT NULL THEN hide_email ELSE false END, + CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 2 END FROM sends_old WHERE user_uuid IS NOT NULL; DROP TABLE sends_old; diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 0b45f319..b5f9047b 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -16,7 +16,7 @@ use crate::{ config::PathType, db::{ DbConn, DbPool, - models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, + models::{Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, SendType, UserId}, }, util::{NumberOrString, save_temp_file}, }; @@ -69,7 +69,8 @@ pub async fn purge_sends(pool: DbPool) { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendData { - r#type: i32, + r#type: SendType, + auth_type: SendAuthType, pub key: String, password: Option, max_access_count: Option, @@ -127,12 +128,9 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c } fn create_send(data: SendData, user_id: UserId) -> ApiResult { - let data_val = if data.r#type == SendType::Text as i32 { - data.text - } else if data.r#type == SendType::File as i32 { - data.file - } else { - err!("Invalid Send type") + let data_val = match data.r#type { + SendType::Text => data.text, + SendType::File => data.file, }; let data_str = if let Some(mut d) = data_val { @@ -159,6 +157,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { let mut send = Send::new( data.r#type, + data.auth_type, user_id, data.name, data.notes, @@ -205,7 +204,7 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not let data: SendData = data.into_inner(); enforce_disable_hide_email_policy(&data, &headers, &conn).await?; - if data.r#type == SendType::File as i32 { + if data.r#type == SendType::File { err!("File sends should use /api/sends/file/v2") } @@ -235,7 +234,7 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) let data = data.into_inner(); - if data.r#type != SendType::File as i32 { + if data.r#type != SendType::File { err!("Send content is not a file"); } @@ -502,7 +501,7 @@ async fn update_send_from_data( err!("Send is not owned by user") } - if send.atype != data.r#type { + if send.atype != data.r#type as i32 { err!("Sends can't change type") } @@ -514,7 +513,7 @@ async fn update_send_from_data( // When updating a file Send, we receive nulls in the File field, as it's immutable, // so we only need to update the data field in the Text case - if data.r#type == SendType::Text as i32 { + if data.r#type == SendType::Text { let data_str = if let Some(mut d) = data.text { d.as_object_mut().and_then(|d| d.remove("response")); serde_json::to_string(&d)? @@ -535,11 +534,24 @@ async fn update_send_from_data( send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); send.hide_email = data.hide_email.unwrap_or(false); send.disabled = data.disabled; - send.emails = data.emails.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()); - // Only change the value if it's present - if let Some(password) = data.password { - send.set_password(Some(&password)); + send.auth_type = data.auth_type as i32; + match data.auth_type { + SendAuthType::Email => { + send.emails = data.emails.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()); + send.set_password(None); + } + SendAuthType::Password => { + send.emails = None; + // Only change the value if it's present + if let Some(password) = data.password { + send.set_password(Some(&password)); + } + } + SendAuthType::None => { + send.emails = None; + send.set_password(None); + } } send.save(conn).await?; diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index a7a99c28..b726a326 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -34,7 +34,7 @@ pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, }; -pub use self::send::{Send, SendFileId, SendId, SendOTP, SendType}; +pub use self::send::{Send, SendAuthType, SendFileId, SendId, SendOTP, SendType}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 5c1677d0..d055bf12 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -6,6 +6,7 @@ use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use macros::{IdFromParam, UuidFromParam}; use serde_json::Value; +use serde_repr::Deserialize_repr; use uuid::Uuid; use crate::{ @@ -35,6 +36,7 @@ pub struct Send { pub notes: Option, pub atype: i32, + pub auth_type: i32, pub data: String, pub akey: String, pub password_hash: Option>, @@ -54,14 +56,16 @@ pub struct Send { pub hide_email: bool, } -#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] +#[derive(Copy, Clone, Deserialize_repr, PartialEq, Eq, num_derive::FromPrimitive)] +#[repr(u8)] pub enum SendType { Text = 0, File = 1, } -enum SendAuthType { - #[allow(dead_code)] +#[derive(Copy, Clone, Deserialize_repr, Eq, PartialEq, num_derive::FromPrimitive)] +#[repr(u8)] +pub enum SendAuthType { // Send requires email OTP verification Email = 0, // Send requires a password @@ -73,7 +77,8 @@ enum SendAuthType { impl Send { #[allow(clippy::too_many_arguments)] pub fn new( - atype: i32, + atype: SendType, + auth_type: SendAuthType, user_uuid: UserId, name: String, notes: Option, @@ -93,7 +98,8 @@ impl Send { user_uuid, name, notes, - atype, + atype: atype as i32, + auth_type: auth_type as i32, data, akey, password_hash: None, @@ -175,7 +181,7 @@ impl Send { "maxAccessCount": self.max_access_count, "accessCount": self.access_count, "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), - "authType": if self.password_hash.is_some() { SendAuthType::Password } else if self.emails.is_some() { SendAuthType::Email } else { SendAuthType::None } as i32, + "authType": self.auth_type, "disabled": self.disabled, "hideEmail": self.hide_email, "emails": self.emails, diff --git a/src/db/schema.rs b/src/db/schema.rs index d45e96ff..3bdc9d83 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -137,6 +137,7 @@ table! { name -> Text, notes -> Nullable, atype -> Integer, + auth_type -> Integer, data -> Text, akey -> Text, password_hash -> Nullable, From 29302350dfd6c824d00b4e7061c1d8c685002e43 Mon Sep 17 00:00:00 2001 From: Timshel Date: Sun, 18 Oct 2026 22:00:44 +0200 Subject: [PATCH 12/12] Down migrations --- .../down.sql | 10 +++++ .../down.sql | 9 ++++ .../down.sql | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql index e69de29b..940f3d3e 100644 --- a/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,10 @@ +DROP TABLE sends_otp; + +ALTER TABLE sends DROP COLUMN auth_type; +ALTER TABLE sends DROP COLUMN emails; + +ALTER TABLE sends MODIFY user_uuid CHAR(36) NULL; +ALTER TABLE sends MODIFY hide_email BOOLEAN NULL; + +ALTER TABLE sends ADD COLUMN organization_uuid CHAR(36); +ALTER TABLE sends ADD FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid); diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql index e69de29b..f6229112 100644 --- a/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,9 @@ +DROP TABLE sends_otp; + +ALTER TABLE sends DROP COLUMN auth_type; +ALTER TABLE sends DROP COLUMN emails; + +ALTER TABLE sends ALTER COLUMN user_uuid DROP NOT NULL; +ALTER TABLE sends ALTER COLUMN hide_email DROP NOT NULL; + +ALTER TABLE sends ADD COLUMN organization_uuid TEXT REFERENCES organizations(uuid); diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql index e69de29b..eaba43d7 100644 --- a/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,41 @@ +DROP TABLE sends_otp; + +ALTER TABLE sends RENAME TO sends_old; + +CREATE TABLE sends ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT REFERENCES users (uuid), + organization_uuid TEXT REFERENCES organizations (uuid), + + name TEXT NOT NULL, + notes TEXT, + + atype INTEGER NOT NULL, + data TEXT NOT NULL, + akey TEXT NOT NULL, + password_hash BLOB, + password_salt BLOB, + password_iter INTEGER, + + max_access_count INTEGER, + access_count INTEGER NOT NULL, + + creation_date DATETIME NOT NULL, + revision_date DATETIME NOT NULL, + expiration_date DATETIME, + deletion_date DATETIME NOT NULL, + + disabled BOOLEAN NOT NULL, + hide_email BOOLEAN +); + +INSERT INTO sends( + uuid, user_uuid, name, notes, atype, data, akey, password_hash, password_salt, password_iter, + max_access_count, access_count, creation_date, revision_date, expiration_date, deletion_date, + disabled, hide_email +) SELECT uuid, user_uuid, name, notes, atype, data, akey, password_hash, password_salt, password_iter, + max_access_count, access_count, creation_date, revision_date, expiration_date, deletion_date, + disabled, hide_email + FROM sends_old WHERE user_uuid IS NOT NULL; + +DROP TABLE sends_old;