diff --git a/CHANGELOG.md b/CHANGELOG.md index c425a6fc3..d3f95728e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - jsonrpc: add `MessageNotificationInfo` & `messageGetNotificationInfo()` #3614 - jsonrpc: add `chat_get_neighboring_media` function #3610 +### Added +- `dclogin:` scheme to allow configuration from a qr code (data inside qrcode, contrary to `dcaccount:` which points to an api to create an account) #3541 + ### Changes - truncate incoming messages by lines instead of just length #3480 - emit separate `DC_EVENT_MSGS_CHANGED` for each expired message, diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 0bdd797a7..c9eaf19f4 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -468,10 +468,10 @@ int dc_set_stock_translation(dc_context_t* context, uint32_t stock_i /** * Set configuration values from a QR code. * Before this function is called, dc_check_qr() should confirm the type of the - * QR code is DC_QR_ACCOUNT or DC_QR_WEBRTC_INSTANCE. + * QR code is DC_QR_ACCOUNT, DC_QR_LOGIN or DC_QR_WEBRTC_INSTANCE. * * Internally, the function will call dc_set_config() with the appropriate keys, - * e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT + * e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT and DC_QR_LOGIN * or `webrtc_instance` for DC_QR_WEBRTC_INSTANCE. * * @memberof dc_context_t @@ -2270,6 +2270,7 @@ void dc_stop_ongoing_process (dc_context_t* context); #define DC_QR_WITHDRAW_VERIFYGROUP 502 // text1=groupname #define DC_QR_REVIVE_VERIFYCONTACT 510 #define DC_QR_REVIVE_VERIFYGROUP 512 // text1=groupname +#define DC_QR_LOGIN 520 // text1=email_address /** * Check a scanned QR code. @@ -2342,6 +2343,10 @@ void dc_stop_ongoing_process (dc_context_t* context); * ask the user if they want to revive the withdrawn group-invite code; * if so, call dc_set_config_from_qr(). * + * - DC_QR_LOGIN with dc_lot_t::text1=email_address: + * ask the user if they want to login with the email_address, + * if so, call dc_set_config_from_qr() and then dc_configure(). + * * @memberof dc_context_t * @param context The context object. * @param qr The text of the scanned QR code. diff --git a/deltachat-ffi/src/lot.rs b/deltachat-ffi/src/lot.rs index 2f1a1ff76..0f3dadc3b 100644 --- a/deltachat-ffi/src/lot.rs +++ b/deltachat-ffi/src/lot.rs @@ -58,6 +58,7 @@ impl Lot { Qr::WithdrawVerifyGroup { grpname, .. } => Some(grpname), Qr::ReviveVerifyContact { .. } => None, Qr::ReviveVerifyGroup { grpname, .. } => Some(grpname), + Qr::Login { address, .. } => Some(address), }, Self::Error(err) => Some(err), } @@ -108,6 +109,7 @@ impl Lot { Qr::WithdrawVerifyGroup { .. } => LotState::QrWithdrawVerifyGroup, Qr::ReviveVerifyContact { .. } => LotState::QrReviveVerifyContact, Qr::ReviveVerifyGroup { .. } => LotState::QrReviveVerifyGroup, + Qr::Login { .. } => LotState::QrLogin, }, Self::Error(_err) => LotState::QrError, } @@ -131,6 +133,7 @@ impl Lot { Qr::WithdrawVerifyGroup { .. } => Default::default(), Qr::ReviveVerifyContact { contact_id, .. } => contact_id.to_u32(), Qr::ReviveVerifyGroup { .. } => Default::default(), + Qr::Login { .. } => Default::default(), }, Self::Error(_) => Default::default(), } @@ -195,6 +198,9 @@ pub enum LotState { /// text1=groupname QrReviveVerifyGroup = 512, + /// text1=email_address + QrLogin = 520, + // Message States MsgInFresh = 10, MsgInNoticed = 13, diff --git a/deltachat-jsonrpc/src/api/types/mod.rs b/deltachat-jsonrpc/src/api/types/mod.rs index a8edc4a27..3d08d3ca6 100644 --- a/deltachat-jsonrpc/src/api/types/mod.rs +++ b/deltachat-jsonrpc/src/api/types/mod.rs @@ -94,6 +94,9 @@ pub enum QrObject { invitenumber: String, authcode: String, }, + Login { + address: String, + }, } impl From for QrObject { @@ -224,6 +227,7 @@ impl From for QrObject { authcode, } } + Qr::Login { address, .. } => QrObject::Login { address }, } } } diff --git a/deltachat-jsonrpc/typescript/generated/types.ts b/deltachat-jsonrpc/typescript/generated/types.ts index f24fc4117..9d9456643 100644 --- a/deltachat-jsonrpc/typescript/generated/types.ts +++ b/deltachat-jsonrpc/typescript/generated/types.ts @@ -3,7 +3,7 @@ export type U32=number; export type Account=(({"type":"Configured";}&{"id":U32;"displayName":(string|null);"addr":(string|null);"profileImage":(string|null);"color":string;})|({"type":"Unconfigured";}&{"id":U32;})); export type ProviderInfo={"beforeLoginHint":string;"overviewPage":string;"status":U32;}; -export type Qr=(({"type":"askVerifyContact";}&{"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"askVerifyGroup";}&{"grpname":string;"grpid":string;"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"fprOk";}&{"contact_id":U32;})|({"type":"fprMismatch";}&{"contact_id":(U32|null);})|({"type":"fprWithoutAddr";}&{"fingerprint":string;})|({"type":"account";}&{"domain":string;})|({"type":"webrtcInstance";}&{"domain":string;"instance_pattern":string;})|({"type":"addr";}&{"contact_id":U32;"draft":(string|null);})|({"type":"url";}&{"url":string;})|({"type":"text";}&{"text":string;})|({"type":"withdrawVerifyContact";}&{"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"withdrawVerifyGroup";}&{"grpname":string;"grpid":string;"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"reviveVerifyContact";}&{"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"reviveVerifyGroup";}&{"grpname":string;"grpid":string;"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})); +export type Qr=(({"type":"askVerifyContact";}&{"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"askVerifyGroup";}&{"grpname":string;"grpid":string;"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"fprOk";}&{"contact_id":U32;})|({"type":"fprMismatch";}&{"contact_id":(U32|null);})|({"type":"fprWithoutAddr";}&{"fingerprint":string;})|({"type":"account";}&{"domain":string;})|({"type":"webrtcInstance";}&{"domain":string;"instance_pattern":string;})|({"type":"addr";}&{"contact_id":U32;"draft":(string|null);})|({"type":"url";}&{"url":string;})|({"type":"text";}&{"text":string;})|({"type":"withdrawVerifyContact";}&{"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"withdrawVerifyGroup";}&{"grpname":string;"grpid":string;"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"reviveVerifyContact";}&{"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"reviveVerifyGroup";}&{"grpname":string;"grpid":string;"contact_id":U32;"fingerprint":string;"invitenumber":string;"authcode":string;})|({"type":"login";}&{"address":string;})); export type Usize=number; export type ChatListEntry=[U32,U32]; export type I64=number; diff --git a/node/constants.js b/node/constants.js index 3ec594460..510b22a60 100644 --- a/node/constants.js +++ b/node/constants.js @@ -103,6 +103,7 @@ module.exports = { DC_QR_FPR_MISMATCH: 220, DC_QR_FPR_OK: 210, DC_QR_FPR_WITHOUT_ADDR: 230, + DC_QR_LOGIN: 520, DC_QR_REVIVE_VERIFYCONTACT: 510, DC_QR_REVIVE_VERIFYGROUP: 512, DC_QR_TEXT: 330, diff --git a/node/lib/constants.ts b/node/lib/constants.ts index 1fa423d85..0a25d69c4 100644 --- a/node/lib/constants.ts +++ b/node/lib/constants.ts @@ -103,6 +103,7 @@ export enum C { DC_QR_FPR_MISMATCH = 220, DC_QR_FPR_OK = 210, DC_QR_FPR_WITHOUT_ADDR = 230, + DC_QR_LOGIN = 520, DC_QR_REVIVE_VERIFYCONTACT = 510, DC_QR_REVIVE_VERIFYGROUP = 512, DC_QR_TEXT = 330, diff --git a/src/login_param.rs b/src/login_param.rs index 25596f803..6d3d724fe 100644 --- a/src/login_param.rs +++ b/src/login_param.rs @@ -15,7 +15,7 @@ use crate::constants::{DC_LP_AUTH_FLAGS, DC_LP_AUTH_NORMAL, DC_LP_AUTH_OAUTH2}; use crate::provider::{get_provider_by_id, Provider}; use crate::{context::Context, provider::Socket}; -#[derive(Copy, Clone, Debug, Display, FromPrimitive, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, Display, FromPrimitive, ToPrimitive, PartialEq, Eq)] #[repr(u32)] #[strum(serialize_all = "snake_case")] pub enum CertificateChecks { diff --git a/src/qr.rs b/src/qr.rs index 0055bb728..80724953c 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -1,5 +1,8 @@ //! # QR code module. +mod dclogin_scheme; +pub use dclogin_scheme::LoginOptions; + use anyhow::{anyhow, bail, ensure, Context as _, Error, Result}; use once_cell::sync::Lazy; use percent_encoding::percent_decode_str; @@ -17,8 +20,11 @@ use crate::peerstate::Peerstate; use crate::tools::time; use crate::{token, EventType}; +use self::dclogin_scheme::configure_from_login_qr; + const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; // yes: uppercase const DCACCOUNT_SCHEME: &str = "DCACCOUNT:"; +pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:"; const DCWEBRTC_SCHEME: &str = "DCWEBRTC:"; const MAILTO_SCHEME: &str = "mailto:"; const MATMSG_SCHEME: &str = "MATMSG:"; @@ -97,6 +103,10 @@ pub enum Qr { invitenumber: String, authcode: String, }, + Login { + address: String, + options: LoginOptions, + }, } fn starts_with_ignore_case(string: &str, pattern: &str) -> bool { @@ -115,6 +125,8 @@ pub async fn check_qr(context: &Context, qr: &str) -> Result { .context("failed to decode OPENPGP4FPR QR code")? } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) { decode_account(qr)? + } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) { + dclogin_scheme::decode_login(qr)? } else if starts_with_ignore_case(qr, DCWEBRTC_SCHEME) { decode_webrtc_instance(context, qr)? } else if qr.starts_with(MAILTO_SCHEME) { @@ -462,6 +474,9 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { context.sync_qr_code_tokens(chat_id).await?; context.send_sync_msg().await?; } + Qr::Login { address, options } => { + configure_from_login_qr(context, &address, options).await? + } _ => bail!("qr code {:?} does not contain config", qr), } @@ -1024,6 +1039,103 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_decode_and_apply_dclogin() -> Result<()> { + let ctx = TestContext::new().await; + + let result = check_qr(&ctx.ctx, "dclogin:usename+extension@host?p=1234&v=1").await?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "usename+extension@host".to_owned()); + + if let LoginOptions::V1 { mail_pw, .. } = options { + assert_eq!(mail_pw, "1234".to_owned()); + } else { + bail!("wrong type") + } + } else { + bail!("wrong type") + } + + assert!(ctx.ctx.get_config(Config::Addr).await?.is_none()); + assert!(ctx.ctx.get_config(Config::MailPw).await?.is_none()); + + set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&v=1").await?; + assert_eq!( + ctx.ctx.get_config(Config::Addr).await?, + Some("username+extension@host".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailPw).await?, + Some("1234".to_owned()) + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_decode_and_apply_dclogin_advanced_options() -> Result<()> { + let ctx = TestContext::new().await; + set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&spw=4321&sh=send.host&sp=7273&su=SendUser&ih=host.tld&ip=4343&iu=user&ipw=password&is=ssl&ic=1&sc=3&ss=plain&v=1").await?; + assert_eq!( + ctx.ctx.get_config(Config::Addr).await?, + Some("username+extension@host".to_owned()) + ); + + // `p=1234` is ignored, because `ipw=password` is set + + assert_eq!( + ctx.ctx.get_config(Config::MailServer).await?, + Some("host.tld".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailPort).await?, + Some("4343".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailUser).await?, + Some("user".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailPw).await?, + Some("password".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailSecurity).await?, + Some("1".to_owned()) // ssl + ); + assert_eq!( + ctx.ctx.get_config(Config::ImapCertificateChecks).await?, + Some("1".to_owned()) + ); + + assert_eq!( + ctx.ctx.get_config(Config::SendPw).await?, + Some("4321".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SendServer).await?, + Some("send.host".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SendPort).await?, + Some("7273".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SendUser).await?, + Some("SendUser".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SmtpCertificateChecks).await?, + Some("3".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SendSecurity).await?, + Some("3".to_owned()) // plain + ); + + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_account() -> Result<()> { let ctx = TestContext::new().await; diff --git a/src/qr/dclogin_scheme.rs b/src/qr/dclogin_scheme.rs new file mode 100644 index 000000000..c7e9e8a76 --- /dev/null +++ b/src/qr/dclogin_scheme.rs @@ -0,0 +1,388 @@ +use std::collections::HashMap; + +use crate::config::Config; +use crate::context::Context; +use crate::provider::Socket; +use crate::{contact, login_param::CertificateChecks}; +use anyhow::{bail, Context as _, Result}; +use num_traits::cast::ToPrimitive; + +use super::{Qr, DCLOGIN_SCHEME}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoginOptions { + UnsuportedVersion(u32), + V1 { + mail_pw: String, + imap_host: Option, + imap_port: Option, + imap_username: Option, + imap_password: Option, + imap_security: Option, + imap_certificate_checks: Option, + smtp_host: Option, + smtp_port: Option, + smtp_username: Option, + smtp_password: Option, + smtp_security: Option, + smtp_certificate_checks: Option, + }, +} + +/// scheme: `dclogin://user@host/?p=password&v=1[&options]` +/// read more about the scheme at +pub(super) fn decode_login(qr: &str) -> Result { + let url = url::Url::parse(qr).with_context(|| format!("Malformed url: {:?}", qr))?; + + let url_without_scheme = qr + .get(DCLOGIN_SCHEME.len()..) + .context("invalid DCLOGIN payload E1")?; + let payload = url_without_scheme + .strip_prefix("//") + .unwrap_or(url_without_scheme); + + let addr = payload + .split(|c| c == '?' || c == '/') + .next() + .context("invalid DCLOGIN payload E3")?; + + if url.scheme().eq_ignore_ascii_case("dclogin") { + let options = url.query_pairs(); + if options.count() == 0 { + bail!("invalid DCLOGIN payload E4") + } + // load options into hashmap + let parameter_map: HashMap = options + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + + // check if username is there + if !contact::may_be_valid_addr(addr) { + bail!("invalid DCLOGIN payload: invalid username E5"); + } + + // apply to result struct + let options: LoginOptions = match parameter_map.get("v").map(|i| i.parse::()) { + Some(Ok(1)) => LoginOptions::V1 { + mail_pw: parameter_map + .get("p") + .map(|s| s.to_owned()) + .context("password missing")?, + imap_host: parameter_map.get("ih").map(|s| s.to_owned()), + imap_port: parse_port(parameter_map.get("ip")) + .context("could not parse imap port")?, + imap_username: parameter_map.get("iu").map(|s| s.to_owned()), + imap_password: parameter_map.get("ipw").map(|s| s.to_owned()), + imap_security: parse_socket_security(parameter_map.get("is"))?, + imap_certificate_checks: parse_certificate_checks(parameter_map.get("ic"))?, + smtp_host: parameter_map.get("sh").map(|s| s.to_owned()), + smtp_port: parse_port(parameter_map.get("sp")) + .context("could not parse smtp port")?, + smtp_username: parameter_map.get("su").map(|s| s.to_owned()), + smtp_password: parameter_map.get("spw").map(|s| s.to_owned()), + smtp_security: parse_socket_security(parameter_map.get("ss"))?, + smtp_certificate_checks: parse_certificate_checks(parameter_map.get("sc"))?, + }, + Some(Ok(v)) => LoginOptions::UnsuportedVersion(v), + Some(Err(_)) => bail!("version could not be parsed as number E6"), + None => bail!("invalid DCLOGIN payload: version missing E7"), + }; + + Ok(Qr::Login { + address: addr.to_owned(), + options, + }) + } else { + bail!("Bad scheme for account URL: {:?}.", payload); + } +} + +fn parse_port(port: Option<&String>) -> core::result::Result, std::num::ParseIntError> { + match port { + Some(p) => Ok(Some(p.parse::()?)), + None => Ok(None), + } +} + +fn parse_socket_security(security: Option<&String>) -> Result> { + Ok(match security.map(|s| s.as_str()) { + Some("ssl") => Some(Socket::Ssl), + Some("starttls") => Some(Socket::Starttls), + Some("default") => Some(Socket::Automatic), + Some("plain") => Some(Socket::Plain), + Some(other) => bail!("Unknown security level: {}", other), + None => None, + }) +} + +fn parse_certificate_checks( + certificate_checks: Option<&String>, +) -> Result> { + Ok(match certificate_checks.map(|s| s.as_str()) { + Some("0") => Some(CertificateChecks::Automatic), + Some("1") => Some(CertificateChecks::Strict), + Some("3") => Some(CertificateChecks::AcceptInvalidCertificates), + Some(other) => bail!("Unknown certificatecheck level: {}", other), + None => None, + }) +} + +pub(crate) async fn configure_from_login_qr( + context: &Context, + address: &str, + options: LoginOptions, +) -> Result<()> { + context.set_config(Config::Addr, Some(address)).await?; + + match options { + LoginOptions::V1 { + mail_pw, + imap_host, + imap_port, + imap_username, + imap_password, + imap_security, + imap_certificate_checks, + smtp_host, + smtp_port, + smtp_username, + smtp_password, + smtp_security, + smtp_certificate_checks, + } => { + context.set_config(Config::MailPw, Some(&mail_pw)).await?; + if let Some(value) = imap_host { + context.set_config(Config::MailServer, Some(&value)).await?; + } + if let Some(value) = imap_port { + context + .set_config(Config::MailPort, Some(&value.to_string())) + .await?; + } + if let Some(value) = imap_username { + context.set_config(Config::MailUser, Some(&value)).await?; + } + if let Some(value) = imap_password { + context.set_config(Config::MailPw, Some(&value)).await?; + } + if let Some(value) = imap_security { + let code = value + .to_u8() + .context("could not convert imap security value to number")?; + context + .set_config(Config::MailSecurity, Some(&code.to_string())) + .await?; + } + if let Some(value) = imap_certificate_checks { + let code = value + .to_u32() + .context("could not convert imap certificate checks value to number")?; + context + .set_config(Config::ImapCertificateChecks, Some(&code.to_string())) + .await?; + } + if let Some(value) = smtp_host { + context.set_config(Config::SendServer, Some(&value)).await?; + } + if let Some(value) = smtp_port { + context + .set_config(Config::SendPort, Some(&value.to_string())) + .await?; + } + if let Some(value) = smtp_username { + context.set_config(Config::SendUser, Some(&value)).await?; + } + if let Some(value) = smtp_password { + context.set_config(Config::SendPw, Some(&value)).await?; + } + if let Some(value) = smtp_security { + let code = value + .to_u8() + .context("could not convert smtp security value to number")?; + context + .set_config(Config::SendSecurity, Some(&code.to_string())) + .await?; + } + if let Some(value) = smtp_certificate_checks { + let code = value + .to_u32() + .context("could not convert smtp certificate checks value to number")?; + context + .set_config(Config::SmtpCertificateChecks, Some(&code.to_string())) + .await?; + } + Ok(()) + } + _ => bail!( + "DeltaChat does not understand this QR Code yet, please update the app and try again." + ), + } +} + +#[cfg(test)] +mod test { + use super::{decode_login, LoginOptions}; + use crate::{login_param::CertificateChecks, provider::Socket, qr::Qr}; + use anyhow::{self, bail}; + + macro_rules! login_options_just_pw { + ($pw: expr) => { + LoginOptions::V1 { + mail_pw: $pw, + imap_host: None, + imap_port: None, + imap_username: None, + imap_password: None, + imap_security: None, + imap_certificate_checks: None, + smtp_host: None, + smtp_port: None, + smtp_username: None, + smtp_password: None, + smtp_security: None, + smtp_certificate_checks: None, + } + }; + } + + #[test] + fn minimal_no_options() -> anyhow::Result<()> { + let result = decode_login("dclogin://email@host.tld?p=123&v=1")?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "email@host.tld".to_owned()); + assert_eq!(options, login_options_just_pw!("123".to_owned())); + } else { + bail!("wrong type") + } + let result = decode_login("dclogin://email@host.tld/?p=123456&v=1")?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "email@host.tld".to_owned()); + assert_eq!(options, login_options_just_pw!("123456".to_owned())); + } else { + bail!("wrong type") + } + let result = decode_login("dclogin://email@host.tld/ignored/path?p=123456&v=1")?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "email@host.tld".to_owned()); + assert_eq!(options, login_options_just_pw!("123456".to_owned())); + } else { + bail!("wrong type") + } + Ok(()) + } + #[test] + fn minimal_no_options_no_double_slash() -> anyhow::Result<()> { + let result = decode_login("dclogin:email@host.tld?p=123&v=1")?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "email@host.tld".to_owned()); + assert_eq!(options, login_options_just_pw!("123".to_owned())); + } else { + bail!("wrong type") + } + let result = decode_login("dclogin:email@host.tld/?p=123456&v=1")?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "email@host.tld".to_owned()); + assert_eq!(options, login_options_just_pw!("123456".to_owned())); + } else { + bail!("wrong type") + } + let result = decode_login("dclogin:email@host.tld/ignored/path?p=123456&v=1")?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "email@host.tld".to_owned()); + assert_eq!(options, login_options_just_pw!("123456".to_owned())); + } else { + bail!("wrong type") + } + Ok(()) + } + + #[test] + fn no_version_set() { + assert!(decode_login("dclogin:email@host.tld?p=123").is_err()); + } + + #[test] + fn invalid_version_set() { + assert!(decode_login("dclogin:email@host.tld?p=123&v=").is_err()); + assert!(decode_login("dclogin:email@host.tld?p=123&v=%40").is_err()); + assert!(decode_login("dclogin:email@host.tld?p=123&v=-20").is_err()); + assert!(decode_login("dclogin:email@host.tld?p=123&v=hi").is_err()); + } + + #[test] + fn version_too_new() -> anyhow::Result<()> { + let result = decode_login("dclogin:email@host.tld/?p=123456&v=2")?; + if let Qr::Login { options, .. } = result { + assert_eq!(options, LoginOptions::UnsuportedVersion(2)); + } else { + bail!("wrong type"); + } + let result = decode_login("dclogin:email@host.tld/?p=123456&v=5")?; + if let Qr::Login { options, .. } = result { + assert_eq!(options, LoginOptions::UnsuportedVersion(5)); + } else { + bail!("wrong type"); + } + Ok(()) + } + + #[test] + fn all_advanced_options() -> anyhow::Result<()> { + let result = decode_login( + "dclogin:email@host.tld?p=secret&v=1&ih=imap.host.tld&ip=4000&iu=max&ipw=87654&is=ssl&ic=1&sh=mail.host.tld&sp=3000&su=max@host.tld&spw=3242HS&ss=plain&sc=3", + )?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "email@host.tld".to_owned()); + assert_eq!( + options, + LoginOptions::V1 { + mail_pw: "secret".to_owned(), + imap_host: Some("imap.host.tld".to_owned()), + imap_port: Some(4000), + imap_username: Some("max".to_owned()), + imap_password: Some("87654".to_owned()), + imap_security: Some(Socket::Ssl), + imap_certificate_checks: Some(CertificateChecks::Strict), + smtp_host: Some("mail.host.tld".to_owned()), + smtp_port: Some(3000), + smtp_username: Some("max@host.tld".to_owned()), + smtp_password: Some("3242HS".to_owned()), + smtp_security: Some(Socket::Plain), + smtp_certificate_checks: Some(CertificateChecks::AcceptInvalidCertificates), + } + ); + } else { + bail!("wrong type") + } + Ok(()) + } + + #[test] + fn uri_encoded_password() -> anyhow::Result<()> { + let result = decode_login( + "dclogin:email@host.tld?p=%7BDaehFl%3B%22as%40%21fhdodn5%24234%22%7B%7Dfg&v=1", + )?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "email@host.tld".to_owned()); + assert_eq!( + options, + login_options_just_pw!("{DaehFl;\"as@!fhdodn5$234\"{}fg".to_owned()) + ); + } else { + bail!("wrong type") + } + Ok(()) + } + + #[test] + fn email_with_plus_extension() -> anyhow::Result<()> { + let result = decode_login("dclogin:usename+extension@host?p=1234&v=1")?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "usename+extension@host".to_owned()); + assert_eq!(options, login_options_just_pw!("1234".to_owned())); + } else { + bail!("wrong type") + } + Ok(()) + } +}