Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2024-11-24 05:30:28 +01:00
Add Emergency contact feature
Signed-off-by: thelittlefireman <thelittlefireman@users.noreply.github.com>
Dieser Commit ist enthalten in:
Ursprung
8c10de3edd
Commit
4ab9362971
37 geänderte Dateien mit 1619 neuen und 13 gelöschten Zeilen
|
@ -77,6 +77,14 @@
|
|||
## Cron schedule of the job that checks for trashed items to delete permanently.
|
||||
## Defaults to daily (5 minutes after midnight). Set blank to disable this job.
|
||||
# TRASH_PURGE_SCHEDULE="0 5 0 * * *"
|
||||
##
|
||||
## Cron schedule of the job that sends expiration reminders to emergency request grantors.
|
||||
## Defaults to hourly (10 minutes after the hour). Set blank to disable this job.
|
||||
# EMERGENCY_NOTIFICATION_REMINDER_SCHEDULE="0 10 * * * *"
|
||||
##
|
||||
## Cron schedule of the job that checks for expired (i.e granted by timeout) emergency requests.
|
||||
## Defaults to hourly (15 minutes after the hour). Set blank to disable this job.
|
||||
# EMERGENCY_REQUEST_TIMEOUT_SCHEDULE="0 15 * * * *"
|
||||
|
||||
## Enable extended logging, which shows timestamps and targets in the logs
|
||||
# EXTENDED_LOGGING=true
|
||||
|
@ -312,6 +320,9 @@
|
|||
## If sending the email fails the login attempt will fail!!
|
||||
# REQUIRE_DEVICE_EMAIL=false
|
||||
|
||||
## Emergency access enable. Enable or disable the emergency access feature for all users
|
||||
# EMERGENCY_ACCESS_ALLOWED=false
|
||||
|
||||
## HIBP Api Key
|
||||
## HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
||||
# HIBP_API_KEY=
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE emergency_access;
|
14
migrations/mysql/2021-02-10-174254_create_emergency_access/up.sql
Normale Datei
14
migrations/mysql/2021-02-10-174254_create_emergency_access/up.sql
Normale Datei
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE emergency_access (
|
||||
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||
grantor_uuid CHAR(36) REFERENCES users (uuid),
|
||||
grantee_uuid CHAR(36) REFERENCES users (uuid),
|
||||
email VARCHAR(255),
|
||||
key_encrypted TEXT,
|
||||
atype INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
wait_time_days INTEGER NOT NULL,
|
||||
recovery_initiated_at DATETIME,
|
||||
last_notification_at DATETIME,
|
||||
updated_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE emergency_access;
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE emergency_access (
|
||||
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||
grantor_uuid CHAR(36) REFERENCES users (uuid),
|
||||
grantee_uuid CHAR(36) REFERENCES users (uuid),
|
||||
email VARCHAR(255),
|
||||
key_encrypted TEXT,
|
||||
atype INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
wait_time_days INTEGER NOT NULL,
|
||||
recovery_initiated_at TIMESTAMP,
|
||||
last_notification_at TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE emergency_access;
|
14
migrations/sqlite/2021-02-10-174254_create_emergency_access/up.sql
Normale Datei
14
migrations/sqlite/2021-02-10-174254_create_emergency_access/up.sql
Normale Datei
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE emergency_access (
|
||||
uuid TEXT NOT NULL PRIMARY KEY,
|
||||
grantor_uuid TEXT REFERENCES users (uuid),
|
||||
grantee_uuid TEXT REFERENCES users (uuid),
|
||||
email TEXT,
|
||||
key_encrypted TEXT,
|
||||
atype INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
wait_time_days INTEGER NOT NULL,
|
||||
recovery_initiated_at DATETIME,
|
||||
last_notification_at DATETIME,
|
||||
updated_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
|
@ -89,7 +89,12 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
|
|||
|
||||
user
|
||||
} else if CONFIG.is_signup_allowed(&email) {
|
||||
err!("Account with this email already exists")
|
||||
// check if it's invited by emergency contact
|
||||
if EmergencyAccess::find_invited_by_grantee_email(&data.Email, &conn).is_some() {
|
||||
user
|
||||
} else {
|
||||
err!("Account with this email already exists")
|
||||
}
|
||||
} else {
|
||||
err!("Registration not allowed or user already exists")
|
||||
}
|
||||
|
|
|
@ -1,24 +1,841 @@
|
|||
use chrono::{Duration, Utc};
|
||||
use rocket::Route;
|
||||
use rocket_contrib::json::Json;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use crate::{api::JsonResult, auth::Headers, db::DbConn};
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString},
|
||||
auth::{decode_emergency_access_invite, Headers},
|
||||
db::{models::*, DbConn, DbPool},
|
||||
mail, CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![get_contacts,]
|
||||
routes![
|
||||
get_contacts,
|
||||
get_grantees,
|
||||
get_emergency_access,
|
||||
put_emergency_access,
|
||||
delete_emergency_access,
|
||||
post_delete_emergency_access,
|
||||
send_invite,
|
||||
resend_invite,
|
||||
accept_invite,
|
||||
confirm_emergency_access,
|
||||
initiate_emergency_access,
|
||||
approve_emergency_access,
|
||||
reject_emergency_access,
|
||||
takeover_emergency_access,
|
||||
password_emergency_access,
|
||||
view_emergency_access,
|
||||
policies_emergency_access,
|
||||
]
|
||||
}
|
||||
|
||||
/// This endpoint is expected to return at least something.
|
||||
/// If we return an error message that will trigger error toasts for the user.
|
||||
/// To prevent this we just return an empty json result with no Data.
|
||||
/// When this feature is going to be implemented it also needs to return this empty Data
|
||||
/// instead of throwing an error/4XX unless it really is an error.
|
||||
// region get
|
||||
|
||||
#[get("/emergency-access/trusted")]
|
||||
fn get_contacts(_headers: Headers, _conn: DbConn) -> JsonResult {
|
||||
debug!("Emergency access is not supported.");
|
||||
fn get_contacts(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let emergency_access_list = EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn);
|
||||
|
||||
let emergency_access_list_json: Vec<Value> =
|
||||
emergency_access_list.iter().map(|e| e.to_json_grantee_details(&conn)).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": [],
|
||||
"Data": emergency_access_list_json,
|
||||
"Object": "list",
|
||||
"ContinuationToken": null
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/emergency-access/granted")]
|
||||
fn get_grantees(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let emergency_access_list = EmergencyAccess::find_all_by_grantee_uuid(&headers.user.uuid, &conn);
|
||||
|
||||
let emergency_access_list_json: Vec<Value> =
|
||||
emergency_access_list.iter().map(|e| e.to_json_grantor_details(&conn)).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": emergency_access_list_json,
|
||||
"Object": "list",
|
||||
"ContinuationToken": null
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/emergency-access/<emer_id>")]
|
||||
fn get_emergency_access(emer_id: String, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emergency_access) => Ok(Json(emergency_access.to_json_grantee_details(&conn))),
|
||||
None => err!("Emergency access not valid."),
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region put/post
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct EmergencyAccessUpdateData {
|
||||
Type: NumberOrString,
|
||||
WaitTimeDays: i32,
|
||||
KeyEncrypted: Option<String>,
|
||||
}
|
||||
|
||||
#[put("/emergency-access/<emer_id>", data = "<data>")]
|
||||
fn put_emergency_access(emer_id: String, data: JsonUpcase<EmergencyAccessUpdateData>, conn: DbConn) -> JsonResult {
|
||||
post_emergency_access(emer_id, data, conn)
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>", data = "<data>")]
|
||||
fn post_emergency_access(emer_id: String, data: JsonUpcase<EmergencyAccessUpdateData>, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let data: EmergencyAccessUpdateData = data.into_inner().data;
|
||||
|
||||
let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emergency_access) => emergency_access,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
let new_type = match EmergencyAccessType::from_str(&data.Type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid emergency access type."),
|
||||
};
|
||||
|
||||
emergency_access.atype = new_type;
|
||||
emergency_access.wait_time_days = data.WaitTimeDays;
|
||||
emergency_access.key_encrypted = data.KeyEncrypted;
|
||||
|
||||
emergency_access.save(&conn)?;
|
||||
Ok(Json(emergency_access.to_json()))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region delete
|
||||
|
||||
#[delete("/emergency-access/<emer_id>")]
|
||||
fn delete_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let grantor_user = headers.user;
|
||||
|
||||
let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => {
|
||||
if emer.grantor_uuid != grantor_user.uuid && emer.grantee_uuid != Some(grantor_user.uuid) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
emer
|
||||
}
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
emergency_access.delete(&conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/delete")]
|
||||
fn post_delete_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
delete_emergency_access(emer_id, headers, conn)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region invite
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct EmergencyAccessInviteData {
|
||||
Email: String,
|
||||
Type: NumberOrString,
|
||||
WaitTimeDays: i32,
|
||||
}
|
||||
|
||||
#[post("/emergency-access/invite", data = "<data>")]
|
||||
fn send_invite(data: JsonUpcase<EmergencyAccessInviteData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let data: EmergencyAccessInviteData = data.into_inner().data;
|
||||
let email = data.Email.to_lowercase();
|
||||
let wait_time_days = data.WaitTimeDays;
|
||||
|
||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||
|
||||
let new_type = match EmergencyAccessType::from_str(&data.Type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid emergency access type."),
|
||||
};
|
||||
|
||||
let grantor_user = headers.user;
|
||||
|
||||
// avoid setting yourself as emergency contact
|
||||
if email == grantor_user.email {
|
||||
err!("You can not set yourself as an emergency contact.")
|
||||
}
|
||||
|
||||
let grantee_user = match User::find_by_mail(&email, &conn) {
|
||||
None => {
|
||||
if !CONFIG.signups_allowed() {
|
||||
err!(format!("Grantee user does not exist: {}", email))
|
||||
}
|
||||
|
||||
if !CONFIG.is_email_domain_allowed(&email) {
|
||||
err!("Email domain not eligible for invitations")
|
||||
}
|
||||
|
||||
if !CONFIG.mail_enabled() {
|
||||
let invitation = Invitation::new(email.clone());
|
||||
invitation.save(&conn)?;
|
||||
}
|
||||
|
||||
let mut user = User::new(email.clone());
|
||||
user.save(&conn)?;
|
||||
user
|
||||
}
|
||||
Some(user) => user,
|
||||
};
|
||||
|
||||
if EmergencyAccess::find_by_grantor_uuid_and_grantee_uuid_or_email(
|
||||
&grantor_user.uuid,
|
||||
&grantee_user.uuid,
|
||||
&grantee_user.email,
|
||||
&conn,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
err!(format!("Grantee user already invited: {}", email))
|
||||
}
|
||||
|
||||
let mut new_emergency_access = EmergencyAccess::new(
|
||||
grantor_user.uuid.clone(),
|
||||
Some(grantee_user.email.clone()),
|
||||
emergency_access_status,
|
||||
new_type,
|
||||
wait_time_days,
|
||||
);
|
||||
new_emergency_access.save(&conn)?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_emergency_access_invite(
|
||||
&grantee_user.email,
|
||||
&grantee_user.uuid,
|
||||
Some(new_emergency_access.uuid),
|
||||
Some(grantor_user.name.clone()),
|
||||
Some(grantor_user.email),
|
||||
)?;
|
||||
} else {
|
||||
// Automatically mark user as accepted if no email invites
|
||||
match User::find_by_mail(&email, &conn) {
|
||||
Some(user) => {
|
||||
match accept_invite_process(user.uuid, new_emergency_access.uuid, Some(email), conn.borrow()) {
|
||||
Ok(v) => (v),
|
||||
Err(e) => err!(e.to_string()),
|
||||
}
|
||||
}
|
||||
None => err!("Grantee user not found."),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/reinvite")]
|
||||
fn resend_invite(emer_id: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if emergency_access.grantor_uuid != headers.user.uuid {
|
||||
err!("Emergency access not valid.");
|
||||
}
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
||||
err!("The grantee user is already accepted or confirmed to the organization");
|
||||
}
|
||||
|
||||
let email = match emergency_access.email.clone() {
|
||||
Some(email) => email,
|
||||
None => err!("Email not valid."),
|
||||
};
|
||||
|
||||
if !CONFIG.is_email_domain_allowed(&email) {
|
||||
err!("Email domain not eligible for invitations.")
|
||||
}
|
||||
|
||||
let grantee_user = match User::find_by_mail(&email, &conn) {
|
||||
None => err!("Grantee user not found."),
|
||||
Some(user) => user,
|
||||
};
|
||||
|
||||
let grantor_user = headers.user;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_emergency_access_invite(
|
||||
&email,
|
||||
&grantor_user.uuid,
|
||||
Some(emergency_access.uuid),
|
||||
Some(grantor_user.name.clone()),
|
||||
Some(grantor_user.email),
|
||||
)?;
|
||||
} else {
|
||||
if Invitation::find_by_mail(&email, &conn).is_none() {
|
||||
let invitation = Invitation::new(email);
|
||||
invitation.save(&conn)?;
|
||||
}
|
||||
|
||||
// Automatically mark user as accepted if no email invites
|
||||
match accept_invite_process(grantee_user.uuid, emergency_access.uuid, emergency_access.email, conn.borrow()) {
|
||||
Ok(v) => (v),
|
||||
Err(e) => err!(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct AcceptData {
|
||||
Token: String,
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/accept", data = "<data>")]
|
||||
fn accept_invite(emer_id: String, data: JsonUpcase<AcceptData>, conn: DbConn) -> EmptyResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let data: AcceptData = data.into_inner().data;
|
||||
let token = &data.Token;
|
||||
let claims = decode_emergency_access_invite(token)?;
|
||||
|
||||
let grantee_user = match User::find_by_mail(&claims.email, &conn) {
|
||||
Some(user) => {
|
||||
Invitation::take(&claims.email, &conn);
|
||||
user
|
||||
}
|
||||
None => err!("Invited user not found"),
|
||||
};
|
||||
|
||||
let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
// get grantor user to send Accepted email
|
||||
let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
};
|
||||
|
||||
if (claims.emer_id.is_some() && emer_id == claims.emer_id.unwrap())
|
||||
&& (claims.grantor_name.is_some() && grantor_user.name == claims.grantor_name.unwrap())
|
||||
&& (claims.grantor_email.is_some() && grantor_user.email == claims.grantor_email.unwrap())
|
||||
{
|
||||
match accept_invite_process(grantee_user.uuid.clone(), emer_id, Some(grantee_user.email.clone()), &conn) {
|
||||
Ok(v) => (v),
|
||||
Err(e) => err!(e.to_string()),
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if !CONFIG.is_email_domain_allowed(&grantor_user.email) {
|
||||
err!("Email domain not valid.")
|
||||
}
|
||||
|
||||
mail::send_emergency_access_invite_accepted(&grantor_user.email, &grantee_user.email)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
err!("Emergency access invitation error.")
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_invite_process(grantee_uuid: String, emer_id: String, email: Option<String>, conn: &DbConn) -> EmptyResult {
|
||||
let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
let emer_email = emergency_access.email;
|
||||
if emer_email.is_none() || emer_email != email {
|
||||
err!("User email does not match invite.");
|
||||
}
|
||||
|
||||
if emergency_access.status == EmergencyAccessStatus::Accepted as i32 {
|
||||
err!("Emergency contact already accepted.");
|
||||
}
|
||||
|
||||
emergency_access.status = EmergencyAccessStatus::Accepted as i32;
|
||||
emergency_access.grantee_uuid = Some(grantee_uuid);
|
||||
emergency_access.email = None;
|
||||
emergency_access.save(conn)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ConfirmData {
|
||||
Key: String,
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/confirm", data = "<data>")]
|
||||
fn confirm_emergency_access(
|
||||
emer_id: String,
|
||||
data: JsonUpcase<ConfirmData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let confirming_user = headers.user;
|
||||
let data: ConfirmData = data.into_inner().data;
|
||||
let key = data.Key;
|
||||
|
||||
let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::Accepted as i32
|
||||
|| emergency_access.grantor_uuid != confirming_user.uuid
|
||||
{
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&confirming_user.uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
};
|
||||
|
||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||
let grantee_user = match User::find_by_uuid(grantee_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantee user not found."),
|
||||
};
|
||||
|
||||
emergency_access.status = EmergencyAccessStatus::Confirmed as i32;
|
||||
emergency_access.key_encrypted = Some(key);
|
||||
emergency_access.email = None;
|
||||
|
||||
emergency_access.save(&conn)?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if !CONFIG.is_email_domain_allowed(&grantee_user.email) {
|
||||
err!("Email domain not valid.")
|
||||
}
|
||||
|
||||
mail::send_emergency_access_invite_confirmed(&grantee_user.email, &grantor_user.name)?;
|
||||
}
|
||||
Ok(Json(emergency_access.to_json()))
|
||||
} else {
|
||||
err!("Grantee user not found.")
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region access emergency access
|
||||
|
||||
#[post("/emergency-access/<emer_id>/initiate")]
|
||||
fn initiate_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let initiating_user = headers.user;
|
||||
let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32
|
||||
|| emergency_access.grantee_uuid != Some(initiating_user.uuid.clone())
|
||||
{
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
};
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
emergency_access.status = EmergencyAccessStatus::RecoveryInitiated as i32;
|
||||
emergency_access.updated_at = now;
|
||||
emergency_access.recovery_initiated_at = Some(now);
|
||||
emergency_access.last_notification_at = Some(now);
|
||||
emergency_access.save(&conn)?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if !CONFIG.is_email_domain_allowed(&grantor_user.email) {
|
||||
err!("Email domain not valid.")
|
||||
}
|
||||
|
||||
mail::send_emergency_access_recovery_initiated(
|
||||
&grantor_user.email,
|
||||
&initiating_user.name,
|
||||
emergency_access.get_atype_as_str(),
|
||||
&emergency_access.wait_time_days.clone().to_string(),
|
||||
)?;
|
||||
}
|
||||
Ok(Json(emergency_access.to_json()))
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/approve")]
|
||||
fn approve_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let approving_user = headers.user;
|
||||
let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32
|
||||
|| emergency_access.grantor_uuid != approving_user.uuid
|
||||
{
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&approving_user.uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
};
|
||||
|
||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||
let grantee_user = match User::find_by_uuid(grantee_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantee user not found."),
|
||||
};
|
||||
|
||||
emergency_access.status = EmergencyAccessStatus::RecoveryApproved as i32;
|
||||
emergency_access.save(&conn)?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if !CONFIG.is_email_domain_allowed(&grantee_user.email) {
|
||||
err!("Email domain not valid.")
|
||||
}
|
||||
|
||||
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name)?;
|
||||
}
|
||||
Ok(Json(emergency_access.to_json()))
|
||||
} else {
|
||||
err!("Grantee user not found.")
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/reject")]
|
||||
fn reject_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let rejecting_user = headers.user;
|
||||
let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if (emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32
|
||||
&& emergency_access.status != EmergencyAccessStatus::RecoveryApproved as i32)
|
||||
|| emergency_access.grantor_uuid != rejecting_user.uuid
|
||||
{
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&rejecting_user.uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
};
|
||||
|
||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||
let grantee_user = match User::find_by_uuid(grantee_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantee user not found."),
|
||||
};
|
||||
|
||||
emergency_access.status = EmergencyAccessStatus::Confirmed as i32;
|
||||
emergency_access.key_encrypted = None;
|
||||
emergency_access.save(&conn)?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if !CONFIG.is_email_domain_allowed(&grantee_user.email) {
|
||||
err!("Email domain not valid.")
|
||||
}
|
||||
|
||||
mail::send_emergency_access_recovery_rejected(&grantee_user.email, &grantor_user.name)?;
|
||||
}
|
||||
Ok(Json(emergency_access.to_json()))
|
||||
} else {
|
||||
err!("Grantee user not found.")
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region action
|
||||
|
||||
#[post("/emergency-access/<emer_id>/view")]
|
||||
fn view_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let requesting_user = headers.user;
|
||||
let host = headers.host;
|
||||
let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, requesting_user.uuid, EmergencyAccessType::View) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let ciphers = Cipher::find_owned_by_user(&emergency_access.grantor_uuid, &conn);
|
||||
|
||||
let ciphers_json: Vec<Value> =
|
||||
ciphers.iter().map(|c| c.to_json(&host, &emergency_access.grantor_uuid, &conn)).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Ciphers": ciphers_json,
|
||||
"KeyEncrypted": &emergency_access.key_encrypted,
|
||||
"Object": "emergencyAccessView",
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/takeover")]
|
||||
fn takeover_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let requesting_user = headers.user;
|
||||
let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, requesting_user.uuid, EmergencyAccessType::Takeover) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"Kdf": grantor_user.client_kdf_type,
|
||||
"KdfIterations": grantor_user.client_kdf_iter,
|
||||
"KeyEncrypted": &emergency_access.key_encrypted,
|
||||
"Object": "emergencyAccessTakeover",
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct EmergencyAccessPasswordData {
|
||||
NewMasterPasswordHash: String,
|
||||
Key: String,
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
|
||||
fn password_emergency_access(
|
||||
emer_id: String,
|
||||
data: JsonUpcase<EmergencyAccessPasswordData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> EmptyResult {
|
||||
check_emergency_access_allowed()?;
|
||||
|
||||
let data: EmergencyAccessPasswordData = data.into_inner().data;
|
||||
let new_master_password_hash = &data.NewMasterPasswordHash;
|
||||
let key = data.Key;
|
||||
|
||||
let requesting_user = headers.user;
|
||||
let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, requesting_user.uuid, EmergencyAccessType::Takeover) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let mut grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
};
|
||||
|
||||
// change grantor_user password
|
||||
grantor_user.set_password(new_master_password_hash, None);
|
||||
grantor_user.akey = key;
|
||||
grantor_user.save(&conn)?;
|
||||
|
||||
// Disable TwoFactor providers since they will otherwise block logins
|
||||
TwoFactor::delete_all_by_user(&grantor_user.uuid, &conn)?;
|
||||
|
||||
// Removing owner, check that there are at least another owner
|
||||
let user_org_grantor = UserOrganization::find_any_state_by_user(&grantor_user.uuid, &conn);
|
||||
|
||||
// Remove grantor from all organisations unless Owner
|
||||
for user_org in user_org_grantor {
|
||||
if user_org.atype != UserOrgType::Owner as i32 {
|
||||
user_org.delete(&conn)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
#[get("/emergency-access/<emer_id>/policies")]
|
||||
fn policies_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let requesting_user = headers.user;
|
||||
let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, requesting_user.uuid, EmergencyAccessType::Takeover) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
};
|
||||
|
||||
let policies = OrgPolicy::find_by_user(&grantor_user.uuid, &conn);
|
||||
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": policies_json,
|
||||
"Object": "list",
|
||||
"ContinuationToken": null
|
||||
})))
|
||||
}
|
||||
|
||||
fn is_valid_request(
|
||||
emergency_access: &EmergencyAccess,
|
||||
requesting_user_uuid: String,
|
||||
requested_access_type: EmergencyAccessType,
|
||||
) -> bool {
|
||||
emergency_access.grantee_uuid == Some(requesting_user_uuid)
|
||||
&& emergency_access.status == EmergencyAccessStatus::RecoveryApproved as i32
|
||||
&& emergency_access.atype == requested_access_type as i32
|
||||
}
|
||||
|
||||
fn check_emergency_access_allowed() -> EmptyResult {
|
||||
if !CONFIG.emergency_access_allowed() {
|
||||
err!("Emergency access is not allowed.")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn emergency_request_timeout_job(pool: DbPool) {
|
||||
debug!("Start emergency_request_timeout_job");
|
||||
if !CONFIG.emergency_access_allowed() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(conn) = pool.get() {
|
||||
let emergency_access_list = EmergencyAccess::find_all_recoveries(&conn);
|
||||
|
||||
if emergency_access_list.is_empty() {
|
||||
debug!("No emergency request timeout to approve");
|
||||
}
|
||||
|
||||
for mut emer in emergency_access_list {
|
||||
if emer.recovery_initiated_at.is_some()
|
||||
&& Utc::now().naive_utc()
|
||||
>= emer.recovery_initiated_at.unwrap() + Duration::days(emer.wait_time_days as i64)
|
||||
{
|
||||
emer.status = EmergencyAccessStatus::RecoveryApproved as i32;
|
||||
emer.save(&conn).expect("Cannot save emergency access on job");
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
// get grantor user to send Accepted email
|
||||
let grantor_user = User::find_by_uuid(&emer.grantor_uuid, &conn).expect("Grantor user not found.");
|
||||
|
||||
// get grantee user to send Accepted email
|
||||
let grantee_user =
|
||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid."), &conn)
|
||||
.expect("Grantee user not found.");
|
||||
|
||||
if !CONFIG.is_email_domain_allowed(&grantor_user.email) {
|
||||
error!("Email domain not valid.")
|
||||
}
|
||||
|
||||
mail::send_emergency_access_recovery_timed_out(
|
||||
&grantor_user.email,
|
||||
&grantee_user.name.clone(),
|
||||
emer.get_atype_as_str(),
|
||||
)
|
||||
.expect("Error on sending email");
|
||||
|
||||
if !CONFIG.is_email_domain_allowed(&grantee_user.email) {
|
||||
error!("Email not valid.")
|
||||
}
|
||||
|
||||
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name.clone())
|
||||
.expect("Error on sending email");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Failed to get DB connection while searching emergency request timed out")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emergency_notification_reminder_job(pool: DbPool) {
|
||||
debug!("Start emergency_notification_reminder_job");
|
||||
if !CONFIG.emergency_access_allowed() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(conn) = pool.get() {
|
||||
let emergency_access_list = EmergencyAccess::find_all_recoveries(&conn);
|
||||
|
||||
if emergency_access_list.is_empty() {
|
||||
debug!("No emergency request reminder notification to send");
|
||||
}
|
||||
|
||||
for mut emer in emergency_access_list {
|
||||
if (emer.recovery_initiated_at.is_some()
|
||||
&& Utc::now().naive_utc()
|
||||
>= emer.recovery_initiated_at.unwrap() + Duration::days((emer.wait_time_days as i64) - 1))
|
||||
&& (emer.last_notification_at.is_none()
|
||||
|| (emer.last_notification_at.is_some()
|
||||
&& Utc::now().naive_utc() >= emer.last_notification_at.unwrap() + Duration::days(1)))
|
||||
{
|
||||
emer.save(&conn).expect("Cannot save emergency access on job");
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
// get grantor user to send Accepted email
|
||||
let grantor_user = User::find_by_uuid(&emer.grantor_uuid, &conn).expect("Grantor user not found.");
|
||||
|
||||
if !CONFIG.is_email_domain_allowed(&grantor_user.email) {
|
||||
error!("Email not valid.")
|
||||
}
|
||||
|
||||
// get grantee user to send Accepted email
|
||||
let grantee_user =
|
||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid."), &conn)
|
||||
.expect("Grantee user not found.");
|
||||
|
||||
mail::send_emergency_access_recovery_reminder(
|
||||
&grantor_user.email,
|
||||
&grantee_user.name.clone(),
|
||||
emer.get_atype_as_str(),
|
||||
&emer.wait_time_days.to_string(),
|
||||
)
|
||||
.expect("Error on sending email");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Failed to get DB connection while searching emergency notification reminder")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ mod sends;
|
|||
pub mod two_factor;
|
||||
|
||||
pub use ciphers::purge_trashed_ciphers;
|
||||
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
||||
pub use sends::purge_sends;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
|
|
|
@ -13,6 +13,7 @@ pub use crate::api::{
|
|||
core::purge_sends,
|
||||
core::purge_trashed_ciphers,
|
||||
core::routes as core_routes,
|
||||
core::{emergency_notification_reminder_job, emergency_request_timeout_job},
|
||||
icons::routes as icons_routes,
|
||||
identity::routes as identity_routes,
|
||||
notifications::routes as notifications_routes,
|
||||
|
|
44
src/auth.rs
44
src/auth.rs
|
@ -22,6 +22,8 @@ static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
|
|||
|
||||
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
|
||||
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
||||
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> =
|
||||
Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
|
||||
static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin()));
|
||||
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
|
||||
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
||||
|
@ -75,6 +77,10 @@ pub fn decode_invite(token: &str) -> Result<InviteJwtClaims, Error> {
|
|||
decode_jwt(token, JWT_INVITE_ISSUER.to_string())
|
||||
}
|
||||
|
||||
pub fn decode_emergency_access_invite(token: &str) -> Result<EmergencyAccessInviteJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string())
|
||||
}
|
||||
|
||||
pub fn decode_delete(token: &str) -> Result<BasicJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_DELETE_ISSUER.to_string())
|
||||
}
|
||||
|
@ -159,6 +165,44 @@ pub fn generate_invite_claims(
|
|||
}
|
||||
}
|
||||
|
||||
// var token = _dataProtector.Protect($"EmergencyAccessInvite {emergencyAccess.Id} {emergencyAccess.Email} {nowMillis}");
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmergencyAccessInviteJwtClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
|
||||
pub email: String,
|
||||
pub emer_id: Option<String>,
|
||||
pub grantor_name: Option<String>,
|
||||
pub grantor_email: Option<String>,
|
||||
}
|
||||
|
||||
pub fn generate_emergency_access_invite_claims(
|
||||
uuid: String,
|
||||
email: String,
|
||||
emer_id: Option<String>,
|
||||
grantor_name: Option<String>,
|
||||
grantor_email: Option<String>,
|
||||
) -> EmergencyAccessInviteJwtClaims {
|
||||
let time_now = Utc::now().naive_utc();
|
||||
EmergencyAccessInviteJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + Duration::days(5)).timestamp(),
|
||||
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
||||
sub: uuid,
|
||||
email,
|
||||
emer_id,
|
||||
grantor_name,
|
||||
grantor_email,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BasicJwtClaims {
|
||||
// Not before
|
||||
|
|
|
@ -333,6 +333,12 @@ make_config! {
|
|||
/// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently.
|
||||
/// Defaults to daily. Set blank to disable this job.
|
||||
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string();
|
||||
/// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency request grantors.
|
||||
/// Defaults to hourly. Set blank to disable this job.
|
||||
emergency_notification_reminder_schedule: String, false, def, "0 10 * * * *".to_string();
|
||||
/// Emergency request timeout schedule |> Cron schedule of the job that checks for expired (i.e granted by timeout) emergency requests.
|
||||
/// Defaults to hourly. Set blank to disable this job.
|
||||
emergency_request_timeout_schedule: String, false, def, "0 15 * * * *".to_string();
|
||||
},
|
||||
|
||||
/// General settings
|
||||
|
@ -385,6 +391,8 @@ make_config! {
|
|||
org_creation_users: String, true, def, "".to_string();
|
||||
/// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are otherwise disabled
|
||||
invitations_allowed: bool, true, def, true;
|
||||
/// Allow emergency access |> Controls whether users can enable emergency access to their accounts
|
||||
emergency_access_allowed: bool, true, def, true;
|
||||
/// Password iterations |> Number of server-side passwords hashing iterations.
|
||||
/// The changes only apply when a user changes their password. Not recommended to lower the value
|
||||
password_iterations: i32, true, def, 100_000;
|
||||
|
@ -855,11 +863,19 @@ where
|
|||
reg!("email/delete_account", ".html");
|
||||
reg!("email/invite_accepted", ".html");
|
||||
reg!("email/invite_confirmed", ".html");
|
||||
reg!("email/emergency_access_invite_accepted", ".html");
|
||||
reg!("email/emergency_access_invite_confirmed", ".html");
|
||||
reg!("email/emergency_access_recovery_approved", ".html");
|
||||
reg!("email/emergency_access_recovery_initiated", ".html");
|
||||
reg!("email/emergency_access_recovery_rejected", ".html");
|
||||
reg!("email/emergency_access_recovery_reminder", ".html");
|
||||
reg!("email/emergency_access_recovery_timed_out", ".html");
|
||||
reg!("email/new_device_logged_in", ".html");
|
||||
reg!("email/pw_hint_none", ".html");
|
||||
reg!("email/pw_hint_some", ".html");
|
||||
reg!("email/send_2fa_removed_from_org", ".html");
|
||||
reg!("email/send_org_invite", ".html");
|
||||
reg!("email/send_emergency_access_invite", ".html");
|
||||
reg!("email/twofactor_email", ".html");
|
||||
reg!("email/verify_email", ".html");
|
||||
reg!("email/welcome", ".html");
|
||||
|
|
288
src/db/models/emergency_access.rs
Normale Datei
288
src/db/models/emergency_access.rs
Normale Datei
|
@ -0,0 +1,288 @@
|
|||
use chrono::{NaiveDateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::User;
|
||||
|
||||
db_object! {
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
|
||||
#[table_name = "emergency_access"]
|
||||
#[changeset_options(treat_none_as_null="true")]
|
||||
#[belongs_to(User, foreign_key = "grantor_uuid")]
|
||||
#[primary_key(uuid)]
|
||||
pub struct EmergencyAccess {
|
||||
pub uuid: String,
|
||||
pub grantor_uuid: String,
|
||||
pub grantee_uuid: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub key_encrypted: Option<String>,
|
||||
pub atype: i32, //EmergencyAccessType
|
||||
pub status: i32, //EmergencyAccessStatus
|
||||
pub wait_time_days: i32,
|
||||
pub recovery_initiated_at: Option<NaiveDateTime>,
|
||||
pub last_notification_at: Option<NaiveDateTime>,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
|
||||
impl EmergencyAccess {
|
||||
pub fn new(grantor_uuid: String, email: Option<String>, status: i32, atype: i32, wait_time_days: i32) -> Self {
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
grantor_uuid,
|
||||
grantee_uuid: None,
|
||||
email,
|
||||
status,
|
||||
atype,
|
||||
wait_time_days,
|
||||
recovery_initiated_at: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
key_encrypted: None,
|
||||
last_notification_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_atype_as_str(&self) -> &'static str {
|
||||
if self.atype == EmergencyAccessType::View as i32 {
|
||||
"View"
|
||||
} else {
|
||||
"Takeover"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"Object": "emergencyAccess",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_json_grantor_details(&self, conn: &DbConn) -> Value {
|
||||
// find grantor
|
||||
let grantor_user = User::find_by_uuid(&self.grantor_uuid, conn).unwrap();
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"GrantorId": grantor_user.uuid,
|
||||
"Email": grantor_user.email,
|
||||
"Name": grantor_user.name,
|
||||
"Object": "emergencyAccessGrantorDetails",})
|
||||
}
|
||||
|
||||
pub fn to_json_grantee_details(&self, conn: &DbConn) -> Value {
|
||||
if self.grantee_uuid.is_some() {
|
||||
let grantee_user =
|
||||
User::find_by_uuid(&self.grantee_uuid.clone().unwrap(), conn).expect("Grantee user not found.");
|
||||
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"GranteeId": grantee_user.uuid,
|
||||
"Email": grantee_user.email,
|
||||
"Name": grantee_user.name,
|
||||
"Object": "emergencyAccessGranteeDetails",})
|
||||
} else if self.email.is_some() {
|
||||
let grantee_user = User::find_by_mail(&self.email.clone().unwrap(), conn).expect("Grantee user not found.");
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"GranteeId": grantee_user.uuid,
|
||||
"Email": grantee_user.email,
|
||||
"Name": grantee_user.name,
|
||||
"Object": "emergencyAccessGranteeDetails",})
|
||||
} else {
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"GranteeId": "",
|
||||
"Email": "",
|
||||
"Name": "",
|
||||
"Object": "emergencyAccessGranteeDetails",})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
|
||||
pub enum EmergencyAccessType {
|
||||
View = 0,
|
||||
Takeover = 1,
|
||||
}
|
||||
|
||||
impl EmergencyAccessType {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"0" | "View" => Some(EmergencyAccessType::View),
|
||||
"1" | "Takeover" => Some(EmergencyAccessType::Takeover),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<i32> for EmergencyAccessType {
|
||||
fn eq(&self, other: &i32) -> bool {
|
||||
*other == *self as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<EmergencyAccessType> for i32 {
|
||||
fn eq(&self, other: &EmergencyAccessType) -> bool {
|
||||
*self == *other as i32
|
||||
}
|
||||
}
|
||||
|
||||
pub enum EmergencyAccessStatus {
|
||||
Invited = 0,
|
||||
Accepted = 1,
|
||||
Confirmed = 2,
|
||||
RecoveryInitiated = 3,
|
||||
RecoveryApproved = 4,
|
||||
}
|
||||
|
||||
// region Database methods
|
||||
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::error::MapResult;
|
||||
|
||||
impl EmergencyAccess {
|
||||
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(&self.grantor_uuid, conn);
|
||||
self.updated_at = Utc::now().naive_utc();
|
||||
|
||||
db_run! { conn:
|
||||
sqlite, mysql {
|
||||
match diesel::replace_into(emergency_access::table)
|
||||
.values(EmergencyAccessDb::to_db(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(emergency_access::table)
|
||||
.filter(emergency_access::uuid.eq(&self.uuid))
|
||||
.set(EmergencyAccessDb::to_db(self))
|
||||
.execute(conn)
|
||||
.map_res("Error updating emergency access")
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}.map_res("Error saving emergency access")
|
||||
}
|
||||
postgresql {
|
||||
let value = EmergencyAccessDb::to_db(self);
|
||||
diesel::insert_into(emergency_access::table)
|
||||
.values(&value)
|
||||
.on_conflict(emergency_access::uuid)
|
||||
.do_update()
|
||||
.set(&value)
|
||||
.execute(conn)
|
||||
.map_res("Error saving emergency access")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
|
||||
for user_org in Self::find_all_by_grantor_uuid(user_uuid, conn) {
|
||||
user_org.delete(conn)?;
|
||||
}
|
||||
for user_org in Self::find_all_by_grantee_uuid(user_uuid, conn) {
|
||||
user_org.delete(conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(&self.grantor_uuid, conn);
|
||||
|
||||
db_run! { conn: {
|
||||
diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error removing user from organization")
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
.first::<EmergencyAccessDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_by_grantor_uuid_and_grantee_uuid_or_email(
|
||||
grantor_uuid: &str,
|
||||
grantee_uuid: &str,
|
||||
email: &str,
|
||||
conn: &DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email)))
|
||||
.first::<EmergencyAccessDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_all_recoveries(conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32))
|
||||
.load::<EmergencyAccessDb>(conn).expect("Error loading emergency_access").from_db()
|
||||
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_by_uuid_and_grantor_uuid(uuid: &str, grantor_uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.first::<EmergencyAccessDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_all_by_grantee_uuid(grantee_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
||||
.load::<EmergencyAccessDb>(conn).expect("Error loading emergency_access").from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::email.eq(grantee_email))
|
||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
|
||||
.first::<EmergencyAccessDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_all_by_grantor_uuid(grantor_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.load::<EmergencyAccessDb>(conn).expect("Error loading emergency_access").from_db()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
|
@ -2,6 +2,7 @@ mod attachment;
|
|||
mod cipher;
|
||||
mod collection;
|
||||
mod device;
|
||||
mod emergency_access;
|
||||
mod favorite;
|
||||
mod folder;
|
||||
mod org_policy;
|
||||
|
@ -14,6 +15,7 @@ pub use self::attachment::Attachment;
|
|||
pub use self::cipher::Cipher;
|
||||
pub use self::collection::{Collection, CollectionCipher, CollectionUser};
|
||||
pub use self::device::Device;
|
||||
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessStatus, EmergencyAccessType};
|
||||
pub use self::favorite::Favorite;
|
||||
pub use self::folder::{Folder, FolderCipher};
|
||||
pub use self::org_policy::{OrgPolicy, OrgPolicyType};
|
||||
|
|
|
@ -176,7 +176,7 @@ impl User {
|
|||
}
|
||||
}
|
||||
|
||||
use super::{Cipher, Device, Favorite, Folder, Send, TwoFactor, UserOrgType, UserOrganization};
|
||||
use super::{Cipher, Device, EmergencyAccess, Favorite, Folder, Send, TwoFactor, UserOrgType, UserOrganization};
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
|
@ -266,6 +266,7 @@ impl User {
|
|||
}
|
||||
|
||||
Send::delete_all_by_user(&self.uuid, conn)?;
|
||||
EmergencyAccess::delete_all_by_user(&self.uuid, conn)?;
|
||||
UserOrganization::delete_all_by_user(&self.uuid, conn)?;
|
||||
Cipher::delete_all_by_user(&self.uuid, conn)?;
|
||||
Favorite::delete_all_by_user(&self.uuid, conn)?;
|
||||
|
|
|
@ -192,6 +192,23 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
grantor_uuid -> Text,
|
||||
grantee_uuid -> Nullable<Text>,
|
||||
email -> Nullable<Text>,
|
||||
key_encrypted -> Nullable<Text>,
|
||||
atype -> Integer,
|
||||
status -> Integer,
|
||||
wait_time_days -> Integer,
|
||||
recovery_initiated_at -> Nullable<Timestamp>,
|
||||
last_notification_at -> Nullable<Timestamp>,
|
||||
updated_at -> Timestamp,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(attachments -> ciphers (cipher_uuid));
|
||||
joinable!(ciphers -> organizations (organization_uuid));
|
||||
joinable!(ciphers -> users (user_uuid));
|
||||
|
@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid));
|
|||
joinable!(users_collections -> users (user_uuid));
|
||||
joinable!(users_organizations -> organizations (org_uuid));
|
||||
joinable!(users_organizations -> users (user_uuid));
|
||||
joinable!(emergency_access -> users (grantor_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
|
@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!(
|
|||
users,
|
||||
users_collections,
|
||||
users_organizations,
|
||||
emergency_access,
|
||||
);
|
||||
|
|
|
@ -192,6 +192,23 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
grantor_uuid -> Text,
|
||||
grantee_uuid -> Nullable<Text>,
|
||||
email -> Nullable<Text>,
|
||||
key_encrypted -> Nullable<Text>,
|
||||
atype -> Integer,
|
||||
status -> Integer,
|
||||
wait_time_days -> Integer,
|
||||
recovery_initiated_at -> Nullable<Timestamp>,
|
||||
last_notification_at -> Nullable<Timestamp>,
|
||||
updated_at -> Timestamp,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(attachments -> ciphers (cipher_uuid));
|
||||
joinable!(ciphers -> organizations (organization_uuid));
|
||||
joinable!(ciphers -> users (user_uuid));
|
||||
|
@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid));
|
|||
joinable!(users_collections -> users (user_uuid));
|
||||
joinable!(users_organizations -> organizations (org_uuid));
|
||||
joinable!(users_organizations -> users (user_uuid));
|
||||
joinable!(emergency_access -> users (grantor_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
|
@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!(
|
|||
users,
|
||||
users_collections,
|
||||
users_organizations,
|
||||
emergency_access,
|
||||
);
|
||||
|
|
|
@ -192,6 +192,23 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
grantor_uuid -> Text,
|
||||
grantee_uuid -> Nullable<Text>,
|
||||
email -> Nullable<Text>,
|
||||
key_encrypted -> Nullable<Text>,
|
||||
atype -> Integer,
|
||||
status -> Integer,
|
||||
wait_time_days -> Integer,
|
||||
recovery_initiated_at -> Nullable<Timestamp>,
|
||||
last_notification_at -> Nullable<Timestamp>,
|
||||
updated_at -> Timestamp,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(attachments -> ciphers (cipher_uuid));
|
||||
joinable!(ciphers -> organizations (organization_uuid));
|
||||
joinable!(ciphers -> users (user_uuid));
|
||||
|
@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid));
|
|||
joinable!(users_collections -> users (user_uuid));
|
||||
joinable!(users_organizations -> organizations (org_uuid));
|
||||
joinable!(users_organizations -> users (user_uuid));
|
||||
joinable!(emergency_access -> users (grantor_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
|
@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!(
|
|||
users,
|
||||
users_collections,
|
||||
users_organizations,
|
||||
emergency_access,
|
||||
);
|
||||
|
|
135
src/mail.rs
135
src/mail.rs
|
@ -13,7 +13,10 @@ use lettre::{
|
|||
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
auth::{encode_jwt, generate_delete_claims, generate_invite_claims, generate_verify_email_claims},
|
||||
auth::{
|
||||
encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims,
|
||||
generate_verify_email_claims,
|
||||
},
|
||||
error::Error,
|
||||
CONFIG,
|
||||
};
|
||||
|
@ -224,6 +227,136 @@ pub fn send_invite(
|
|||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_invite(
|
||||
address: &str,
|
||||
uuid: &str,
|
||||
emer_id: Option<String>,
|
||||
grantor_name: Option<String>,
|
||||
grantor_email: Option<String>,
|
||||
) -> EmptyResult {
|
||||
let claims = generate_emergency_access_invite_claims(
|
||||
uuid.to_string(),
|
||||
String::from(address),
|
||||
emer_id.clone(),
|
||||
grantor_name.clone(),
|
||||
grantor_email,
|
||||
);
|
||||
|
||||
let invite_token = encode_jwt(&claims);
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/send_emergency_access_invite",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"emer_id": emer_id.unwrap_or_else(|| "_".to_string()),
|
||||
"email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(),
|
||||
"grantor_name": grantor_name,
|
||||
"token": invite_token,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_invite_accepted(address: &str, grantee_email: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_invite_accepted",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantee_email": grantee_email,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_invite_confirmed",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantor_name": grantor_name,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_approved(address: &str, grantor_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_approved",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantor_name": grantor_name,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_initiated(
|
||||
address: &str,
|
||||
grantee_name: &str,
|
||||
atype: &str,
|
||||
wait_time_days: &str,
|
||||
) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_initiated",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantee_name": grantee_name,
|
||||
"atype": atype,
|
||||
"wait_time_days": wait_time_days,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_reminder(
|
||||
address: &str,
|
||||
grantee_name: &str,
|
||||
atype: &str,
|
||||
wait_time_days: &str,
|
||||
) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_reminder",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantee_name": grantee_name,
|
||||
"atype": atype,
|
||||
"wait_time_days": wait_time_days,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_rejected(address: &str, grantor_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_rejected",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantor_name": grantor_name,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_timed_out(address: &str, grantee_name: &str, atype: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_timed_out",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantee_name": grantee_name,
|
||||
"atype": atype,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/invite_accepted",
|
||||
|
|
12
src/main.rs
12
src/main.rs
|
@ -345,6 +345,18 @@ fn schedule_jobs(pool: db::DbPool) {
|
|||
}));
|
||||
}
|
||||
|
||||
if !CONFIG.emergency_request_timeout_schedule().is_empty() {
|
||||
sched.add(Job::new(CONFIG.emergency_request_timeout_schedule().parse().unwrap(), || {
|
||||
api::emergency_request_timeout_job(pool.clone());
|
||||
}));
|
||||
}
|
||||
|
||||
if !CONFIG.emergency_notification_reminder_schedule().is_empty() {
|
||||
sched.add(Job::new(CONFIG.emergency_notification_reminder_schedule().parse().unwrap(), || {
|
||||
api::emergency_notification_reminder_job(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
|
||||
|
|
8
src/static/templates/email/emergency_access_invite_accepted.hbs
Normale Datei
8
src/static/templates/email/emergency_access_invite_accepted.hbs
Normale Datei
|
@ -0,0 +1,8 @@
|
|||
Emergency contact {{{grantee_email}}} accepted
|
||||
<!---------------->
|
||||
This email is to notify you that {{grantee_email}} has accepted your invitation to become an emergency access contact.
|
||||
|
||||
To confirm this user, Log into {{url}} the Bitwarden web vault, go to settings and confirm the user.
|
||||
|
||||
If you do not wish to confirm this user, you can also remove them on the same page.
|
||||
{{> email/email_footer_text }}
|
21
src/static/templates/email/emergency_access_invite_accepted.html.hbs
Normale Datei
21
src/static/templates/email/emergency_access_invite_accepted.html.hbs
Normale Datei
|
@ -0,0 +1,21 @@
|
|||
Emergency contact {{{grantee_email}}} accepted
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
This email is to notify you that {{grantee_email}} has accepted your invitation to become an emergency access contact.
|
||||
</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">
|
||||
To confirm this user, <a href="{{url}}/">log into</a> the vaultwarden web vault, go to settings and confirm the user.
|
||||
</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 last" 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; -webkit-text-size-adjust: none;" valign="top">
|
||||
If you do not wish to confirm this user, you can also remove them on the same page.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
6
src/static/templates/email/emergency_access_invite_confirmed.hbs
Normale Datei
6
src/static/templates/email/emergency_access_invite_confirmed.hbs
Normale Datei
|
@ -0,0 +1,6 @@
|
|||
Emergency contact for {{{grantor_name}}} confirmed
|
||||
<!---------------->
|
||||
This email is to notify you that you have been confirmed as an emergency access contact for *{{grantor_name}}* was confirmed.
|
||||
|
||||
You can now initiate emergency access requests from the web vault. Log in {{url}}.
|
||||
{{> email/email_footer_text }}
|
|
@ -0,0 +1,17 @@
|
|||
Emergency contact for {{{grantor_name}}} confirmed
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
This email is to notify you that you have been confirmed as an emergency access contact for <b 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;">{{grantor_name}}</b> was confirmed.
|
||||
</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 last" 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; -webkit-text-size-adjust: none;" valign="top">
|
||||
You can now initiate emergency access requests from the web vault. <br>
|
||||
<a href="{{url}}/">Log in</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
|
@ -0,0 +1,4 @@
|
|||
Emergency contact request for {{{grantor_name}}} approved
|
||||
<!---------------->
|
||||
{{grantor_name}} has approved your emergency request. You may now login {{url}} on the web vault and access their account.
|
||||
{{> email/email_footer_text }}
|
|
@ -0,0 +1,11 @@
|
|||
Emergency contact for {{{grantor_name}}} approved
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
<b 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;">{{grantor_name}}</b> has approved your emergency request. You may now <a href="{{url}}/">login</a> on the web vault and access their account.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
|
@ -0,0 +1,6 @@
|
|||
Emergency access request by {{{grantee_name}}} initiated
|
||||
<!---------------->
|
||||
{{grantee_name}} has initiated an emergency request to *{{atype}}* your account. You may login on the web vault and manually approve or reject this request.
|
||||
|
||||
If you do nothing, the request will automatically be approved after {{wait_time_days}} day(s).
|
||||
{{> email/email_footer_text }}
|
|
@ -0,0 +1,16 @@
|
|||
Emergency access request by {{{grantee_name}}} initiated
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
<b 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;">{{grantee_name}}</b> has initiated an emergency request to <b 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;">{{atype}}</b> your account. You may login on the web vault and manually approve or reject this request.
|
||||
</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 last" 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; -webkit-text-size-adjust: none;" valign="top">
|
||||
If you do nothing, the request will automatically be approved after <b 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;">{{wait_time_days}}</b> day(s).
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
|
@ -0,0 +1,4 @@
|
|||
Emergency access request to {{{grantor_name}}} rejected
|
||||
<!---------------->
|
||||
{{grantor_name}} has rejected your emergency request.
|
||||
{{> email/email_footer_text }}
|
|
@ -0,0 +1,11 @@
|
|||
Emergency access request to {{{grantor_name}}} rejected
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
<b 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;">{{grantor_name}}</b> has rejected your emergency request.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
|
@ -0,0 +1,6 @@
|
|||
Emergency access request by {{{grantee_name}}} is pending
|
||||
<!---------------->
|
||||
{{grantee_name}} has a pending emergency request to *{{atype}}* your account. You may login on the web vault and manually approve or reject this request.
|
||||
|
||||
If you do nothing, the request will automatically be approved after {{wait_time_days}} day(s).
|
||||
{{> email/email_footer_text }}
|
|
@ -0,0 +1,16 @@
|
|||
Emergency access request by {{{grantee_name}}} is pending
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
<b 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;">{{grantee_name}}</b> has a pending emergency request to <b 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;">{{atype}}</b> your account. You may login on the web vault and manually approve or reject this request.
|
||||
</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 last" 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; -webkit-text-size-adjust: none;" valign="top">
|
||||
If you do nothing, the request will automatically be approved after <b 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;">{{wait_time_days}}</b> day(s).
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
|
@ -0,0 +1,4 @@
|
|||
Emergency access request by {{{grantee_name}}} granted
|
||||
<!---------------->
|
||||
{{grantee_name}} has been granted emergency request to *{{atype}}* your account. You may login on the web vault and manually revoke this request.
|
||||
{{> email/email_footer_text }}
|
|
@ -0,0 +1,11 @@
|
|||
Emergency access request by {{{grantee_name}}} granted
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
<b 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;">{{grantee_name}}</b> has been granted emergency request to <b 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;">{{atype}}</b> your account. You may login on the web vault and manually revoke this request.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
8
src/static/templates/email/send_emergency_access_invite.hbs
Normale Datei
8
src/static/templates/email/send_emergency_access_invite.hbs
Normale Datei
|
@ -0,0 +1,8 @@
|
|||
Emergency access for {{{grantor_name}}}
|
||||
<!---------------->
|
||||
You have been invited to become an emergency contact for {{grantor_name}}. To accept this invite, click the following link:
|
||||
|
||||
Click here to join: {{url}}/#/accept-emergency/?id={{emer_id}}&name={{grantor_name}}&email={{email}}&token={{token}}
|
||||
|
||||
If you do not wish to become an emergency contact for {{grantor_name}}, you can safely ignore this email.
|
||||
{{> email/email_footer_text }}
|
24
src/static/templates/email/send_emergency_access_invite.html.hbs
Normale Datei
24
src/static/templates/email/send_emergency_access_invite.html.hbs
Normale Datei
|
@ -0,0 +1,24 @@
|
|||
Emergency access for {{{grantor_name}}}
|
||||
<!---------------->
|
||||
{{> 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; text-align: center;" valign="top" align="center">
|
||||
You have been invited to become an emergency contact for <b 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;">{{grantor_name}}</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; text-align: center;" valign="top" align="center">
|
||||
<a href="{{url}}/#/accept-emergency/?id={{emer_id}}&name={{grantor_name}}&email={{email}}&token={{token}}"
|
||||
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
Become emergency contact
|
||||
</a>
|
||||
</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 last" 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; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
|
||||
If you do not wish to become an emergency contact for {{grantor_name}}, you can safely ignore this email.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
Laden …
In neuem Issue referenzieren