diff --git a/Cargo.lock b/Cargo.lock index 6bb1b7b..e2031e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,7 +448,7 @@ dependencies = [ [[package]] name = "bichon" -version = "0.0.4" +version = "0.1.0" dependencies = [ "ahash", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index ed164db..ddcaa62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bichon" -version = "0.0.4" +version = "0.1.0" edition = "2021" [[bin]] diff --git a/src/modules/account/migration.rs b/src/modules/account/migration.rs index d702140..42ac6e6 100644 --- a/src/modules/account/migration.rs +++ b/src/modules/account/migration.rs @@ -55,7 +55,7 @@ use crate::modules::rest::response::DataPage; use crate::modules::token::AccessToken; use crate::raise_error; -pub type AccountModel = AccountV1; +pub type AccountModel = AccountV2; #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)] pub enum AccountType { @@ -86,11 +86,41 @@ pub struct AccountV1 { pub updated_at: i64, pub use_proxy: Option, } - impl AccountV1 { fn pk(&self) -> String { format!("{}_{}", self.created_at, self.id) } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +#[native_model(id = 4, version = 2, from = AccountV1)] +#[native_db(primary_key(pk -> String))] +pub struct AccountV2 { + #[secondary_key(unique)] + pub id: u64, + pub imap: Option, + pub enabled: bool, + #[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))] + pub email: String, + pub name: Option, + pub capabilities: Option>, + pub date_since: Option, + pub folder_limit: Option, + pub sync_folders: Option>, + pub account_type: AccountType, + pub sync_interval_min: Option, + pub known_folders: Option>, + pub created_at: i64, + pub updated_at: i64, + pub use_proxy: Option, + pub use_dangerous: bool, + pub pgp_key: Option, +} + +impl AccountV2 { + fn pk(&self) -> String { + format!("{}_{}", self.created_at, self.id) + } pub fn new(request: AccountCreateRequest) -> BichonResult { Ok(Self { @@ -109,12 +139,14 @@ impl AccountV1 { updated_at: utc_now!(), use_proxy: request.use_proxy, folder_limit: request.folder_limit, + use_dangerous: request.use_dangerous, + pgp_key: request.pgp_key, }) } pub async fn check_account_exists(account_id: u64) -> BichonResult { let account = - secondary_find_impl::(DB_MANAGER.meta_db(), AccountV1Key::id, account_id) + secondary_find_impl::(DB_MANAGER.meta_db(), AccountV2Key::id, account_id) .await? .ok_or_else(|| { raise_error!( @@ -144,7 +176,7 @@ impl AccountV1 { } pub async fn find(account_id: u64) -> BichonResult> { - secondary_find_impl::(DB_MANAGER.meta_db(), AccountV1Key::id, account_id) + secondary_find_impl::(DB_MANAGER.meta_db(), AccountV2Key::id, account_id) .await } @@ -198,7 +230,7 @@ impl AccountV1 { async fn delete_account(account_id: u64) -> BichonResult<()> { delete_impl(DB_MANAGER.meta_db(), move|rw|{ - rw.get().secondary::(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound)) }).await } @@ -228,7 +260,7 @@ impl AccountV1 { sync_folders: Vec, ) -> BichonResult<()> { update_impl(DB_MANAGER.meta_db(), move |rw| { - rw.get().secondary::(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound)) }, |current|{ let mut updated = current.clone(); @@ -243,7 +275,7 @@ impl AccountV1 { known_folders: BTreeSet, ) -> BichonResult<()> { update_impl(DB_MANAGER.meta_db(), move |rw| { - rw.get().secondary::(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound)) }, |current|{ let mut updated = current.clone(); @@ -258,7 +290,7 @@ impl AccountV1 { capabilities: Vec, ) -> BichonResult<()> { update_impl(DB_MANAGER.meta_db(), move |rw| { - rw.get().secondary::(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound)) }, |current|{ let mut updated = current.clone(); @@ -287,7 +319,7 @@ impl AccountV1 { } pub async fn count() -> BichonResult { - count_by_unique_secondary_key_impl::(DB_MANAGER.meta_db(), AccountV1Key::id) + count_by_unique_secondary_key_impl::(DB_MANAGER.meta_db(), AccountV2Key::id) .await } @@ -355,7 +387,62 @@ impl AccountV1 { if let Some(enabled) = request.enabled { new.enabled = enabled; } + + if let Some(use_dangerous) = request.use_dangerous { + new.use_dangerous = use_dangerous; + } + + if let Some(pgp_key) = request.pgp_key { + new.pgp_key = Some(pgp_key); + } + new.updated_at = utc_now!(); Ok(new) } } + +impl From for AccountV2 { + fn from(value: AccountV1) -> Self { + Self { + id: value.id, + imap: value.imap, + enabled: value.enabled, + email: value.email, + name: value.name, + capabilities: value.capabilities, + date_since: value.date_since, + folder_limit: value.folder_limit, + sync_folders: value.sync_folders, + account_type: value.account_type, + sync_interval_min: value.sync_interval_min, + known_folders: value.known_folders, + created_at: value.created_at, + updated_at: value.updated_at, + use_proxy: value.use_proxy, + use_dangerous: false, + pgp_key: None, + } + } +} + +impl From for AccountV1 { + fn from(value: AccountV2) -> Self { + Self { + id: value.id, + imap: value.imap, + enabled: value.enabled, + email: value.email, + name: value.name, + capabilities: value.capabilities, + date_since: value.date_since, + folder_limit: value.folder_limit, + sync_folders: value.sync_folders, + account_type: value.account_type, + sync_interval_min: value.sync_interval_min, + known_folders: value.known_folders, + created_at: value.created_at, + updated_at: value.updated_at, + use_proxy: value.use_proxy, + } + } +} diff --git a/src/modules/account/payload.rs b/src/modules/account/payload.rs index 91c70e9..adc2f2c 100644 --- a/src/modules/account/payload.rs +++ b/src/modules/account/payload.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use std::collections::BTreeSet; use crate::modules::account::entity::ImapConfig; @@ -43,6 +42,8 @@ pub struct AccountCreateRequest { #[oai(validator(minimum(value = "10"), maximum(value = "480")))] pub sync_interval_min: Option, pub use_proxy: Option, + pub use_dangerous: bool, + pub pgp_key: Option, } impl AccountCreateRequest { @@ -132,6 +133,10 @@ pub struct AccountUpdateRequest { /// - If `None` or not provided, the client will connect directly to the API server. /// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests. pub use_proxy: Option, + + pub use_dangerous: Option, + + pub pgp_key: Option, } impl AccountUpdateRequest { diff --git a/src/modules/database/manager.rs b/src/modules/database/manager.rs index 4cbf33c..af1bf9c 100644 --- a/src/modules/database/manager.rs +++ b/src/modules/database/manager.rs @@ -16,7 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - +use crate::modules::account::migration::AccountModel; use crate::modules::cache::imap::MAILBOX_MODELS; use crate::modules::error::{code::ErrorCode, BichonError}; use crate::modules::settings::cli::SETTINGS; @@ -71,6 +71,8 @@ impl DatabaseManager { let rw = database .rw_transaction() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + rw.migrate::() + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; rw.commit() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; diff --git a/src/modules/database/mod.rs b/src/modules/database/mod.rs index 66f62c1..3ed0ea9 100644 --- a/src/modules/database/mod.rs +++ b/src/modules/database/mod.rs @@ -16,8 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - -use crate::modules::account::migration::AccountV1; +use crate::modules::account::migration::{AccountV1, AccountV2}; use crate::modules::autoconfig::CachedMailSettings; use crate::modules::error::code::ErrorCode; use crate::modules::error::BichonResult; @@ -63,6 +62,7 @@ impl ModelsAdapter { self.register_model::(); self.register_model::(); self.register_model::(); + self.register_model::(); self.register_model::(); self.register_model::(); self.register_model::(); diff --git a/src/modules/imap/client.rs b/src/modules/imap/client.rs index f62e7c8..1e91589 100644 --- a/src/modules/imap/client.rs +++ b/src/modules/imap/client.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::modules::account::entity::Encryption; use crate::modules::error::code::ErrorCode; use crate::modules::error::BichonResult; @@ -100,15 +99,17 @@ impl Client { encryption: &Encryption, port: u16, use_proxy: Option, + dangerous: bool, ) -> BichonResult { let resolved_addr = Self::resolve_to_socket_addr(domain, port)?; debug!("Attempting IMAP connection to {domain} ({resolved_addr})."); match encryption { Encryption::Ssl => { - Self::establish_secure_connection(resolved_addr, domain, use_proxy).await + Self::establish_secure_connection(resolved_addr, domain, use_proxy, dangerous).await } Encryption::StartTls => { - Self::establish_starttls_connection(resolved_addr, domain, use_proxy).await + Self::establish_starttls_connection(resolved_addr, domain, use_proxy, dangerous) + .await } Encryption::None => Self::establish_insecure_connection(resolved_addr, use_proxy).await, } @@ -118,11 +119,17 @@ impl Client { address: SocketAddr, server_hostname: &str, use_proxy: Option, + dangerous: bool, ) -> BichonResult { // Establish the TLS connection with the specified parameters - let tls_stream = - establish_tls_connection(address, server_hostname, alpn(address.port()), use_proxy) - .await?; + let tls_stream = establish_tls_connection( + address, + server_hostname, + alpn(address.port()), + use_proxy, + dangerous, + ) + .await?; let stats_stream = StatsWrapper::new(tls_stream); // Wrap the TLS stream in a buffered writer for efficient IO let buffered_stream = BufWriter::new(stats_stream); @@ -180,6 +187,7 @@ impl Client { address: SocketAddr, server_hostname: &str, use_proxy: Option, + dangerous: bool, ) -> BichonResult { // Establish the initial TCP connection let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?; @@ -217,7 +225,7 @@ impl Client { let buffered_tcp_stream = client.into_inner(); let tcp_stream = buffered_tcp_stream.into_inner(); // Wrap the TCP stream in TLS encryption - let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream).await?; + let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream, dangerous).await?; // Wrap the TLS stream in a buffered writer let buffered_stream = BufWriter::new(tls_stream); // Create a SessionStream trait object for further communication diff --git a/src/modules/imap/manager.rs b/src/modules/imap/manager.rs index d207b78..adc512a 100644 --- a/src/modules/imap/manager.rs +++ b/src/modules/imap/manager.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::modules::account::dispatcher::STATUS_DISPATCHER; use crate::modules::account::entity::AuthType; use crate::modules::account::migration::{AccountModel, AccountType}; @@ -51,7 +50,14 @@ impl ImapConnectionManager { async fn create_client(&self, account: &AccountModel) -> BichonResult { assert_eq!(account.account_type, AccountType::IMAP); let imap = account.imap.as_ref().unwrap(); - Client::connection(&imap.host, &imap.encryption, imap.port, imap.use_proxy).await + Client::connection( + &imap.host, + &imap.encryption, + imap.port, + imap.use_proxy, + account.use_dangerous, + ) + .await } async fn authenticate( diff --git a/src/modules/imap/tests.rs b/src/modules/imap/tests.rs index ade5bc3..f403cb7 100644 --- a/src/modules/imap/tests.rs +++ b/src/modules/imap/tests.rs @@ -16,19 +16,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use mail_parser::MessageParser; -use crate::{base64_encode_url_safe, modules::{account::entity::Encryption, imap::client::Client}}; +use crate::{ + base64_encode_url_safe, + modules::{account::entity::Encryption, imap::client::Client}, +}; #[tokio::test] async fn testxx() { rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()) .unwrap(); - let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None) + let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None, false) .await .unwrap(); - let mut session = client.login("pollybase@zohomail.com", "xxx").await.unwrap(); + let mut session = client.login("xx@zohomail.com", "xxx").await.unwrap(); session.select("INBOX").await.unwrap(); let result = session.uid_search("LARGER 1024").await.unwrap(); println!("{:#?}", result); diff --git a/src/modules/utils/net.rs b/src/modules/utils/net.rs index e11b51e..1d02d67 100644 --- a/src/modules/utils/net.rs +++ b/src/modules/utils/net.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::modules::error::code::ErrorCode; use crate::modules::settings::proxy::Proxy; use crate::modules::utils::tls::establish_tls_stream; @@ -56,17 +55,19 @@ pub(crate) async fn establish_tcp_connection_with_timeout( Ok(Box::pin(timeout_stream)) } -pub(crate) async fn establish_tls_connection( +pub async fn establish_tls_connection( address: SocketAddr, server_hostname: &str, alpn_protocols: &[&str], use_proxy: Option, + dangerous: bool, ) -> BichonResult { // Establish the TCP connection with timeout let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?; // Wrap the TCP stream with TLS encryption - let tls_stream = establish_tls_stream(server_hostname, alpn_protocols, tcp_stream).await?; + let tls_stream = + establish_tls_stream(server_hostname, alpn_protocols, tcp_stream, dangerous).await?; // Return the TLS stream wrapped in a SessionStream Ok(tls_stream) diff --git a/src/modules/utils/tls.rs b/src/modules/utils/tls.rs index 5f11587..af91c30 100644 --- a/src/modules/utils/tls.rs +++ b/src/modules/utils/tls.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::{ modules::{ error::{code::ErrorCode, BichonResult}, @@ -24,38 +23,44 @@ use crate::{ }, raise_error, }; -use rustls::RootCertStore; +use rustls::{ + client::danger::{ServerCertVerified, ServerCertVerifier}, + RootCertStore, +}; use std::sync::Arc; pub async fn establish_tls_stream( server_hostname: &str, alpn_protocols: &[&str], stream: impl SessionStream + 'static, + dangerous: bool, ) -> BichonResult { - let tls_stream = establish_rustls_stream(server_hostname, alpn_protocols, stream).await?; + let tls_stream = + establish_rustls_stream(server_hostname, alpn_protocols, stream, dangerous).await?; let boxed_stream: Box = Box::new(tls_stream); Ok(boxed_stream) } -pub async fn establish_rustls_stream( +async fn establish_rustls_stream( server_hostname: &str, alpn_protocols: &[&str], stream: impl SessionStream, + dangerous: bool, ) -> BichonResult { // Create a root certificate store and add default trusted roots let root_store = RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.into(), }; - - // Configure the Rustls client with the root certs and no client authentication - let mut config = rustls::ClientConfig::builder() - //builder_with_provider( - // rustls::crypto::ring::default_provider().into(), - // ) - // .with_protocol_versions(&[&rustls::version::TLS13]) - // .unwrap() - .with_root_certificates(root_store) - .with_no_client_auth(); + let mut config = if dangerous { + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(SkipCertVerification)) + .with_no_client_auth() + } else { + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth() + }; // Set the ALPN protocols config.alpn_protocols = alpn_protocols @@ -76,3 +81,56 @@ pub async fn establish_rustls_stream( Ok(tls_stream) } + +#[derive(Debug)] +struct SkipCertVerification; + +impl ServerCertVerifier for SkipCertVerification { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + // Always return a valid certificate verification result + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA1, + rustls::SignatureScheme::ECDSA_SHA1_Legacy, + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP521_SHA512, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::ED448, + ] + } +} diff --git a/web/src/features/accounts/components/action-dialog.tsx b/web/src/features/accounts/components/action-dialog.tsx index 523f8d4..c4bf617 100644 --- a/web/src/features/accounts/components/action-dialog.tsx +++ b/web/src/features/accounts/components/action-dialog.tsx @@ -99,6 +99,7 @@ export type Account = { use_proxy?: number; }; enabled: boolean; + use_dangerous: boolean; date_since?: { fixed?: string; relative?: { @@ -116,6 +117,7 @@ const getAccountSchema = (isEdit: boolean, t: (key: string) => string) => email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }), imap: getImapConfigSchema(isEdit, t), enabled: z.boolean(), + use_dangerous: z.boolean(), date_since: getDateSelectionSchema(t).optional(), folder_limit: z .number({ invalid_type_error: t('validation.folderLimitMustBeNumber') }) @@ -137,7 +139,7 @@ export type Steps = [ const getSteps = (t: (key: string) => string): Steps => [ { id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] }, - { id: "step-2", name: t('accounts.steps.imap'), fields: ["imap"] }, + { id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous"] }, { id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] }, { id: "step-4", name: t('accounts.steps.summary'), fields: [] }, ]; @@ -164,6 +166,7 @@ const defaultValues: Account = { use_proxy: undefined }, enabled: true, + use_dangerous: false, date_since: undefined, folder_limit: undefined, sync_interval_min: 10, @@ -189,6 +192,7 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => { email: currentRow.email, imap, enabled: currentRow.enabled, + use_dangerous: currentRow.use_dangerous, date_since: currentRow.date_since ?? undefined, folder_limit: currentRow.folder_limit ?? undefined, sync_interval_min: currentRow.sync_interval_min ?? 10, @@ -266,6 +270,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) { }, }, enabled: data.enabled, + use_dangerous: data.use_dangerous, date_since: data.date_since, folder_limit: data.folder_limit, sync_interval_min: data.sync_interval_min, diff --git a/web/src/features/accounts/components/running-state-dialog.tsx b/web/src/features/accounts/components/running-state-dialog.tsx index fede0ce..cf810c0 100644 --- a/web/src/features/accounts/components/running-state-dialog.tsx +++ b/web/src/features/accounts/components/running-state-dialog.tsx @@ -147,7 +147,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) { ) : state.initial_sync_start_time ? ( - {t('runningState.inProgress')} + {t('accounts.runningState.inProgress')} ) : ( {t('accounts.runningState.notStarted')} diff --git a/web/src/features/accounts/components/step2.tsx b/web/src/features/accounts/components/step2.tsx index aa805f0..f45a61a 100644 --- a/web/src/features/accounts/components/step2.tsx +++ b/web/src/features/accounts/components/step2.tsx @@ -38,6 +38,7 @@ import { Account } from "./action-dialog"; import { PasswordInput } from "@/components/password-input"; import useProxyList from "@/hooks/use-proxy"; import { useTranslation } from "react-i18next"; +import { Checkbox } from "@/components/ui/checkbox"; interface StepProps { isEdit: boolean; @@ -110,6 +111,23 @@ export default function Step2({ isEdit }: StepProps) { )} /> + ( + + {t('accounts.useDangerous')}: + + + + {t('accounts.useDangerousDescription')} + + )} + /> )} /> - {t('accounts.dateSince')}: