Browse Source

Sends email verification

pull/7363/head
Timshel 1 month ago
parent
commit
9f7fcc0e69
  1. 4
      .env.template
  2. 2
      migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql
  3. 13
      migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql
  4. 2
      migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql
  5. 13
      migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql
  6. 2
      migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql
  7. 13
      migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql
  8. 89
      playwright/tests/send.spec.ts
  9. 2
      playwright/tests/setups/2fa.ts
  10. 37
      src/api/core/sends.rs
  11. 4
      src/api/identity.rs
  12. 47
      src/auth/send.rs
  13. 4
      src/config.rs
  14. 2
      src/db/models/mod.rs
  15. 159
      src/db/models/send.rs
  16. 14
      src/db/schema.rs
  17. 13
      src/mail.rs
  18. 7
      src/main.rs
  19. 6
      src/static/templates/email/sends_otp.hbs
  20. 16
      src/static/templates/email/sends_otp.html.hbs

4
.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 ###

2
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;

13
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)
);

2
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;

13
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)
);

2
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;

13
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)
);

89
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();
});

2
playwright/tests/setups/2fa.ts

@ -65,7 +65,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();

37
src/api/core/sends.rs

@ -151,21 +151,29 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
);
}
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());
@ -636,14 +644,14 @@ async fn put_send(send_id: SendId, data: Json<SendData>, 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()))
@ -694,6 +702,7 @@ pub 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 {

4
src/api/identity.rs

@ -115,6 +115,8 @@ async fn login(
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,
)
@ -1161,6 +1163,8 @@ struct ConnectData {
// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,
email: Option<String>,
otp: Option<String>,
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {

47
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,14 +71,18 @@ impl SendTokens {
pub async fn generate_tokens(
access_id: &str,
password: Option<String>,
email: Option<String>,
otp: Option<String>,
ip: &ClientIp,
conn: &DbConn,
) -> ApiResult<SendTokens> {
let now = Utc::now().naive_utc();
let Some(send_id) = Self::as_send_id(access_id) else {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
};
let Some(mut 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);
};
@ -86,7 +93,7 @@ impl SendTokens {
}
if let Some(expiration) = send.expiration_date
&& Utc::now().naive_utc() >= expiration
&& now >= expiration
{
return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true);
}
@ -99,6 +106,40 @@ impl SendTokens {
return Self::invalid_error(&format!("Send {send_id}, disabled"), "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(',')) {
match email.as_ref() {
Some(e) if split.any(|s| s == e) => {
match (otp, o_otp) {
(Some(code), Some(db_otp)) if 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 */ }

4
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
@ -1724,6 +1727,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");

2
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;

159
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<i32>,
pub access_count: i32,
pub emails: Option<String>,
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<String>,
data: String,
akey: String,
max_access_count: Option<i32>,
emails: Option<String>,
expiration_date: Option<NaiveDateTime>,
deletion_date: NaiveDateTime,
disabled: bool,
hide_email: Option<bool>,
) -> 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,
}
}
@ -162,9 +179,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),
@ -202,24 +220,16 @@ impl Send {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(sends::table)
mysql {
diesel::insert_into(sends::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(sends::table)
.filter(sends::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving send")
}
Err(e) => Err(e.into()),
}.map_res("Error saving send")
.map_res("Error saving send")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(sends::table)
.values(&*self)
.on_conflict(sends::uuid)
@ -374,3 +384,94 @@ impl AsRef<Path> 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<Self>)> {
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<Self>)>::as_select())
.filter(sends::uuid.eq(uuid))
.first::<(Send, Option<Self>)>(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")
}
}
}

14
src/db/schema.rs

@ -144,6 +144,7 @@ table! {
password_iter -> Nullable<Integer>,
max_access_count -> Nullable<Integer>,
access_count -> Integer,
emails -> Nullable<Text>,
creation_date -> Timestamp,
revision_date -> Timestamp,
expiration_date -> Nullable<Timestamp>,
@ -153,6 +154,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,
@ -366,6 +378,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));
@ -396,6 +409,7 @@ allow_tables_to_appear_in_same_query!(
org_policies,
organizations,
sends,
sends_otp,
sso_users,
twofactor,
users,

13
src/mail.rs

@ -650,6 +650,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 {

7
src/main.rs

@ -745,6 +745,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

6
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 }}

16
src/static/templates/email/sends_otp.html.hbs

@ -0,0 +1,16 @@
Your Vaultwarden Sends Verification Code
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Your email verification code is: <b data-testid="2fa">{{token}}</b>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Use this code to access the shared secret in Vaultwarden.
</td>
</tr>
</table>
{{> email/email_footer }}
Loading…
Cancel
Save