1mod dclogin_scheme;
4use std::collections::BTreeMap;
5use std::sync::LazyLock;
6
7use anyhow::{Context as _, Result, anyhow, bail, ensure};
8pub use dclogin_scheme::LoginOptions;
9pub(crate) use dclogin_scheme::login_param_from_login_qr;
10use deltachat_contact_tools::{ContactAddress, addr_normalize, may_be_valid_addr};
11use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
12use rand::TryRngCore as _;
13use rand::distr::{Alphanumeric, SampleString};
14use serde::Deserialize;
15
16use crate::config::Config;
17use crate::contact::{Contact, ContactId, Origin};
18use crate::context::Context;
19use crate::key::Fingerprint;
20use crate::login_param::{EnteredCertificateChecks, EnteredImapLoginParam, EnteredLoginParam};
21use crate::net::http::post_empty;
22use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig};
23use crate::token;
24use crate::tools::{time, validate_id};
25
26const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
28const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#";
29const DCACCOUNT_SCHEME: &str = "DCACCOUNT:";
30pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:";
31const TG_SOCKS_SCHEME: &str = "https://t.me/socks";
32const MAILTO_SCHEME: &str = "mailto:";
33const MATMSG_SCHEME: &str = "MATMSG:";
34const VCARD_SCHEME: &str = "BEGIN:VCARD";
35const SMTP_SCHEME: &str = "SMTP:";
36const HTTPS_SCHEME: &str = "https://";
37const SHADOWSOCKS_SCHEME: &str = "ss://";
38
39pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
41
42pub(crate) const DCBACKUP_VERSION: i32 = 5;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Qr {
49 AskVerifyContact {
53 contact_id: ContactId,
55
56 fingerprint: Fingerprint,
58
59 addrs: Vec<String>,
61
62 invitenumber: String,
64
65 authcode: String,
67
68 is_v3: bool,
70 },
71
72 AskVerifyGroup {
74 grpname: String,
76
77 grpid: String,
79
80 contact_id: ContactId,
82
83 fingerprint: Fingerprint,
85
86 addrs: Vec<String>,
88
89 invitenumber: String,
91
92 authcode: String,
94
95 is_v3: bool,
97 },
98
99 AskJoinBroadcast {
101 name: String,
103
104 grpid: String,
110
111 contact_id: ContactId,
113
114 fingerprint: Fingerprint,
116
117 addrs: Vec<String>,
119
120 invitenumber: String,
122 authcode: String,
124
125 is_v3: bool,
127 },
128
129 FprOk {
133 contact_id: ContactId,
135 },
136
137 FprMismatch {
139 contact_id: Option<ContactId>,
141 },
142
143 FprWithoutAddr {
145 fingerprint: String,
147 },
148
149 Account {
151 domain: String,
153 },
154
155 Backup2 {
157 node_addr: iroh::NodeAddr,
159
160 auth_token: String,
162 },
163
164 BackupTooNew {},
166
167 Proxy {
177 url: String,
181
182 host: String,
184
185 port: u16,
187 },
188
189 Addr {
194 contact_id: ContactId,
196
197 draft: Option<String>,
199 },
200
201 Url {
205 url: String,
207 },
208
209 Text {
213 text: String,
215 },
216
217 WithdrawVerifyContact {
219 contact_id: ContactId,
221
222 fingerprint: Fingerprint,
224
225 invitenumber: String,
227
228 authcode: String,
230 },
231
232 WithdrawVerifyGroup {
234 grpname: String,
236
237 grpid: String,
239
240 contact_id: ContactId,
242
243 fingerprint: Fingerprint,
245
246 invitenumber: String,
248
249 authcode: String,
251 },
252
253 WithdrawJoinBroadcast {
255 name: String,
257
258 grpid: String,
264
265 contact_id: ContactId,
267
268 fingerprint: Fingerprint,
270
271 invitenumber: String,
273
274 authcode: String,
276 },
277
278 ReviveVerifyContact {
280 contact_id: ContactId,
282
283 fingerprint: Fingerprint,
285
286 invitenumber: String,
288
289 authcode: String,
291 },
292
293 ReviveVerifyGroup {
295 grpname: String,
297
298 grpid: String,
300
301 contact_id: ContactId,
303
304 fingerprint: Fingerprint,
306
307 invitenumber: String,
309
310 authcode: String,
312 },
313
314 ReviveJoinBroadcast {
316 name: String,
318
319 grpid: String,
325
326 contact_id: ContactId,
328
329 fingerprint: Fingerprint,
331
332 invitenumber: String,
334
335 authcode: String,
337 },
338
339 Login {
343 address: String,
345
346 options: LoginOptions,
348 },
349}
350
351fn fix_add_second_device_qr(qr: &str) -> String {
354 qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
355 .replacen(r#""]}}"#, r#""]}"#, 1)
356}
357
358fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
359 string.to_lowercase().starts_with(&pattern.to_lowercase())
360}
361
362pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
367 let qr = qr.trim();
368 let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
369 decode_openpgp(context, qr)
370 .await
371 .context("failed to decode OPENPGP4FPR QR code")?
372 } else if qr.starts_with(IDELTACHAT_SCHEME) {
373 decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
374 } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
375 decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
376 } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
377 decode_account(qr)?
378 } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
379 dclogin_scheme::decode_login(qr)?
380 } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
381 decode_tg_socks_proxy(context, qr)?
382 } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
383 decode_shadowsocks_proxy(qr)?
384 } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
385 let qr_fixed = fix_add_second_device_qr(qr);
386 decode_backup2(&qr_fixed)?
387 } else if qr.starts_with(MAILTO_SCHEME) {
388 decode_mailto(context, qr).await?
389 } else if qr.starts_with(SMTP_SCHEME) {
390 decode_smtp(context, qr).await?
391 } else if qr.starts_with(MATMSG_SCHEME) {
392 decode_matmsg(context, qr).await?
393 } else if qr.starts_with(VCARD_SCHEME) {
394 decode_vcard(context, qr).await?
395 } else if let Ok(url) = url::Url::parse(qr) {
396 match url.scheme() {
397 "socks5" => Qr::Proxy {
398 url: qr.to_string(),
399 host: url.host_str().context("URL has no host")?.to_string(),
400 port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
401 },
402 "http" | "https" => {
403 let url = if let Some(rest) = qr.strip_prefix("http://") {
407 url::Url::parse(&format!("foobarbaz://{rest}"))?
408 } else if let Some(rest) = qr.strip_prefix("https://") {
409 url::Url::parse(&format!("foobarbaz://{rest}"))?
410 } else {
411 url
413 };
414
415 if url.port().is_none() | (url.path() != "") | url.query().is_some() {
416 Qr::Url {
418 url: qr.to_string(),
419 }
420 } else {
421 Qr::Proxy {
422 url: qr.to_string(),
423 host: url.host_str().context("URL has no host")?.to_string(),
424 port: url
425 .port_or_known_default()
426 .context("HTTP(S) URLs are guaranteed to return Some port")?,
427 }
428 }
429 }
430 _ => Qr::Url {
431 url: qr.to_string(),
432 },
433 }
434 } else {
435 Qr::Text {
436 text: qr.to_string(),
437 }
438 };
439 Ok(qrcode)
440}
441
442pub fn format_backup(qr: &Qr) -> Result<String> {
449 match qr {
450 Qr::Backup2 {
451 node_addr,
452 auth_token,
453 } => {
454 let node_addr = serde_json::to_string(node_addr)?;
455 Ok(format!(
456 "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
457 ))
458 }
459 _ => Err(anyhow!("Not a backup QR code")),
460 }
461}
462
463async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
468 let payload = qr
469 .get(OPENPGP4FPR_SCHEME.len()..)
470 .context("Invalid OPENPGP4FPR scheme")?;
471
472 let (fingerprint, fragment) = match payload
475 .split_once('#')
476 .or_else(|| payload.split_once("%23"))
477 {
478 Some(pair) => pair,
479 None => (payload, ""),
480 };
481 let fingerprint: Fingerprint = fingerprint
482 .parse()
483 .context("Failed to parse fingerprint in the QR code")?;
484
485 let param: BTreeMap<&str, &str> = fragment
486 .split('&')
487 .filter_map(|s| {
488 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
489 Some((key, value))
490 } else {
491 None
492 }
493 })
494 .collect();
495
496 let addr = if let Some(addr) = param.get("a") {
497 Some(normalize_address(addr)?)
498 } else {
499 None
500 };
501
502 let name = decode_name(¶m, "n")?.unwrap_or_default();
503
504 let mut invitenumber = param
505 .get("i")
506 .or_else(|| param.get("j"))
508 .filter(|&s| validate_id(s))
509 .map(|s| s.to_string());
510 let authcode = param
511 .get("s")
512 .filter(|&s| validate_id(s))
513 .map(|s| s.to_string());
514 let grpid = param
515 .get("x")
516 .filter(|&s| validate_id(s))
517 .map(|s| s.to_string());
518
519 let grpname = decode_name(¶m, "g")?;
520 let broadcast_name = decode_name(¶m, "b")?;
521
522 let mut is_v3 = param.get("v") == Some(&"3");
523
524 if authcode.is_some() && invitenumber.is_none() {
525 is_v3 = true;
529 invitenumber = Some("".to_string());
530 }
531
532 if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
533 let addr = ContactAddress::new(addr)?;
534 let (contact_id, _) = Contact::add_or_lookup_ex(
535 context,
536 &name,
537 &addr,
538 &fingerprint.hex(),
539 Origin::UnhandledSecurejoinQrScan,
540 )
541 .await
542 .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
543
544 if let (Some(grpid), Some(grpname)) = (grpid.clone(), grpname) {
545 if context
546 .is_self_addr(&addr)
547 .await
548 .with_context(|| format!("can't check if address {addr:?} is our address"))?
549 {
550 if token::exists(context, token::Namespace::Auth, &authcode).await? {
551 Ok(Qr::WithdrawVerifyGroup {
552 grpname,
553 grpid,
554 contact_id,
555 fingerprint,
556 invitenumber,
557 authcode,
558 })
559 } else {
560 Ok(Qr::ReviveVerifyGroup {
561 grpname,
562 grpid,
563 contact_id,
564 fingerprint,
565 invitenumber,
566 authcode,
567 })
568 }
569 } else {
570 Ok(Qr::AskVerifyGroup {
571 grpname,
572 grpid,
573 contact_id,
574 fingerprint,
575 addrs: vec![addr.to_string()],
576 invitenumber,
577 authcode,
578 is_v3,
579 })
580 }
581 } else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
582 if context
583 .is_self_addr(&addr)
584 .await
585 .with_context(|| format!("Can't check if {addr:?} is our address"))?
586 {
587 if token::exists(context, token::Namespace::Auth, &authcode).await? {
588 Ok(Qr::WithdrawJoinBroadcast {
589 name,
590 grpid,
591 contact_id,
592 fingerprint,
593 invitenumber,
594 authcode,
595 })
596 } else {
597 Ok(Qr::ReviveJoinBroadcast {
598 name,
599 grpid,
600 contact_id,
601 fingerprint,
602 invitenumber,
603 authcode,
604 })
605 }
606 } else {
607 Ok(Qr::AskJoinBroadcast {
608 name,
609 grpid,
610 contact_id,
611 fingerprint,
612 addrs: vec![addr.to_string()],
613 invitenumber,
614 authcode,
615 is_v3,
616 })
617 }
618 } else if context.is_self_addr(&addr).await? {
619 if token::exists(context, token::Namespace::Auth, &authcode).await? {
620 Ok(Qr::WithdrawVerifyContact {
621 contact_id,
622 fingerprint,
623 invitenumber,
624 authcode,
625 })
626 } else {
627 Ok(Qr::ReviveVerifyContact {
628 contact_id,
629 fingerprint,
630 invitenumber,
631 authcode,
632 })
633 }
634 } else {
635 Ok(Qr::AskVerifyContact {
636 contact_id,
637 fingerprint,
638 addrs: vec![addr.to_string()],
639 invitenumber,
640 authcode,
641 is_v3,
642 })
643 }
644 } else if let Some(addr) = addr {
645 let fingerprint = fingerprint.hex();
646 let (contact_id, _) =
647 Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan)
648 .await?;
649 let contact = Contact::get_by_id(context, contact_id).await?;
650
651 if contact.public_key(context).await?.is_some() {
652 Ok(Qr::FprOk { contact_id })
653 } else {
654 Ok(Qr::FprMismatch {
655 contact_id: Some(contact_id),
656 })
657 }
658 } else {
659 Ok(Qr::FprWithoutAddr {
660 fingerprint: fingerprint.human_readable(),
661 })
662 }
663}
664
665fn decode_name(param: &BTreeMap<&str, &str>, key: &str) -> Result<Option<String>> {
666 if let Some(encoded_name) = param.get(key) {
667 let encoded_name = encoded_name.replace('+', "%20"); let mut name = match percent_decode_str(&encoded_name).decode_utf8() {
669 Ok(name) => name.to_string(),
670 Err(err) => bail!("Invalid QR param {key}: {err}"),
671 };
672 if let Some(n) = name.strip_suffix('_') {
673 name = format!("{n}…");
674 }
675 Ok(Some(name))
676 } else {
677 Ok(None)
678 }
679}
680
681async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
683 let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
684 let qr = qr.replacen('&', "#", 1);
685 decode_openpgp(context, &qr)
686 .await
687 .with_context(|| format!("failed to decode {prefix} QR code"))
688}
689
690fn decode_account(qr: &str) -> Result<Qr> {
694 let payload = qr
695 .get(DCACCOUNT_SCHEME.len()..)
696 .context("Invalid DCACCOUNT payload")?;
697
698 let payload = payload.strip_prefix("//").unwrap_or(payload);
700 if payload.is_empty() {
701 bail!("dcaccount payload is empty");
702 }
703 if payload.starts_with("https://") {
704 let url = url::Url::parse(payload).context("Invalid account URL")?;
705 if url.scheme() == "https" {
706 Ok(Qr::Account {
707 domain: url
708 .host_str()
709 .context("can't extract account setup domain")?
710 .to_string(),
711 })
712 } else {
713 bail!("Bad scheme for account URL: {:?}.", url.scheme());
714 }
715 } else {
716 if payload.starts_with("/") {
717 bail!("Hostname in dcaccount URL cannot start with /");
721 }
722 Ok(Qr::Account {
723 domain: payload.to_string(),
724 })
725 }
726}
727
728fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
730 let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
731
732 let mut host: Option<String> = None;
733 let mut port: u16 = DEFAULT_SOCKS_PORT;
734 let mut user: Option<String> = None;
735 let mut pass: Option<String> = None;
736 for (key, value) in url.query_pairs() {
737 if key == "server" {
738 host = Some(value.to_string());
739 } else if key == "port" {
740 port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
741 } else if key == "user" {
742 user = Some(value.to_string());
743 } else if key == "pass" {
744 pass = Some(value.to_string());
745 }
746 }
747
748 let Some(host) = host else {
749 bail!("Bad t.me/socks url: {url:?}");
750 };
751
752 let mut url = "socks5://".to_string();
753 if let Some(pass) = pass {
754 url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
755 url += ":";
756 url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
757 url += "@";
758 };
759 url += &host;
760 url += ":";
761 url += &port.to_string();
762
763 Ok(Qr::Proxy { url, host, port })
764}
765
766fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
768 let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
769 let addr = server_config.addr();
770 let host = addr.host().to_string();
771 let port = addr.port();
772 Ok(Qr::Proxy {
773 url: qr.to_string(),
774 host,
775 port,
776 })
777}
778
779fn decode_backup2(qr: &str) -> Result<Qr> {
781 let version_and_payload = qr
782 .strip_prefix(DCBACKUP_SCHEME_PREFIX)
783 .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
784 let (version, payload) = version_and_payload
785 .split_once(':')
786 .context("DCBACKUP scheme separator missing")?;
787 let version: i32 = version.parse().context("Not a valid number")?;
788 if version > DCBACKUP_VERSION {
789 return Ok(Qr::BackupTooNew {});
790 }
791
792 let (auth_token, node_addr) = payload
793 .split_once('&')
794 .context("Backup QR code has no separator")?;
795 let auth_token = auth_token.to_string();
796 let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
797 .context("Invalid node addr in backup QR code")?;
798
799 Ok(Qr::Backup2 {
800 node_addr,
801 auth_token,
802 })
803}
804
805#[derive(Debug, Deserialize)]
806struct CreateAccountSuccessResponse {
807 email: String,
809
810 password: String,
812}
813#[derive(Debug, Deserialize)]
814struct CreateAccountErrorResponse {
815 reason: String,
817}
818
819pub(crate) async fn login_param_from_account_qr(
823 context: &Context,
824 qr: &str,
825) -> Result<EnteredLoginParam> {
826 let payload = qr
827 .get(DCACCOUNT_SCHEME.len()..)
828 .context("Invalid DCACCOUNT scheme")?;
829
830 if !payload.starts_with(HTTPS_SCHEME) {
831 let rng = &mut rand::rngs::OsRng.unwrap_err();
832 let username = Alphanumeric.sample_string(rng, 9);
833 let addr = username + "@" + payload;
834 let password = Alphanumeric.sample_string(rng, 50);
835
836 let param = EnteredLoginParam {
837 addr,
838 imap: EnteredImapLoginParam {
839 password,
840 ..Default::default()
841 },
842 smtp: Default::default(),
843 certificate_checks: EnteredCertificateChecks::Strict,
844 oauth2: false,
845 };
846 return Ok(param);
847 }
848
849 let (response_text, response_success) = post_empty(context, payload).await?;
850 if response_success {
851 let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
852 .with_context(|| {
853 format!("Cannot create account, response is malformed:\n{response_text:?}")
854 })?;
855
856 let param = EnteredLoginParam {
857 addr: email,
858 imap: EnteredImapLoginParam {
859 password,
860 ..Default::default()
861 },
862 smtp: Default::default(),
863 certificate_checks: EnteredCertificateChecks::Strict,
864 oauth2: false,
865 };
866
867 Ok(param)
868 } else {
869 match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
870 Ok(error) => Err(anyhow!(error.reason)),
871 Err(parse_error) => {
872 error!(
873 context,
874 "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
875 );
876 bail!("Cannot create account, unexpected server response:\n{response_text:?}")
877 }
878 }
879 }
880}
881
882pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
886 match check_qr(context, qr).await? {
887 Qr::Account { .. } => {
888 let mut param = login_param_from_account_qr(context, qr).await?;
889 context.add_transport_inner(&mut param).await?
890 }
891 Qr::Proxy { url, .. } => {
892 let old_proxy_url_value = context
893 .get_config(Config::ProxyUrl)
894 .await?
895 .unwrap_or_default();
896
897 let url = ProxyConfig::from_url(&url)?.to_url();
899
900 let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
901 .chain(
902 old_proxy_url_value
903 .split('\n')
904 .filter(|s| !s.is_empty() && *s != url),
905 )
906 .collect();
907 context
908 .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
909 .await?;
910 context.set_config_bool(Config::ProxyEnabled, true).await?;
911 }
912 Qr::WithdrawVerifyContact {
913 invitenumber,
914 authcode,
915 ..
916 } => {
917 token::delete(context, "").await?;
918 context
919 .sync_qr_code_token_deletion(invitenumber, authcode)
920 .await?;
921 }
922 Qr::WithdrawVerifyGroup {
923 grpid,
924 invitenumber,
925 authcode,
926 ..
927 }
928 | Qr::WithdrawJoinBroadcast {
929 grpid,
930 invitenumber,
931 authcode,
932 ..
933 } => {
934 token::delete(context, &grpid).await?;
935 context
936 .sync_qr_code_token_deletion(invitenumber, authcode)
937 .await?;
938 }
939 Qr::ReviveVerifyContact {
940 invitenumber,
941 authcode,
942 ..
943 } => {
944 let timestamp = time();
945 token::save(
946 context,
947 token::Namespace::InviteNumber,
948 None,
949 &invitenumber,
950 timestamp,
951 )
952 .await?;
953 token::save(context, token::Namespace::Auth, None, &authcode, timestamp).await?;
954 context.sync_qr_code_tokens(None).await?;
955 context.scheduler.interrupt_smtp().await;
956 }
957 Qr::ReviveVerifyGroup {
958 invitenumber,
959 authcode,
960 grpid,
961 ..
962 }
963 | Qr::ReviveJoinBroadcast {
964 invitenumber,
965 authcode,
966 grpid,
967 ..
968 } => {
969 let timestamp = time();
970 token::save(
971 context,
972 token::Namespace::InviteNumber,
973 Some(&grpid),
974 &invitenumber,
975 timestamp,
976 )
977 .await?;
978 token::save(
979 context,
980 token::Namespace::Auth,
981 Some(&grpid),
982 &authcode,
983 timestamp,
984 )
985 .await?;
986 context.sync_qr_code_tokens(Some(&grpid)).await?;
987 context.scheduler.interrupt_smtp().await;
988 }
989 Qr::Login { address, options } => {
990 let mut param = login_param_from_login_qr(&address, options)?;
991 context.add_transport_inner(&mut param).await?
992 }
993 _ => bail!("QR code does not contain config"),
994 }
995
996 Ok(())
997}
998
999async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
1003 let payload = qr
1004 .get(MAILTO_SCHEME.len()..)
1005 .context("Invalid mailto: scheme")?;
1006
1007 let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
1008
1009 let param: BTreeMap<&str, &str> = query
1010 .split('&')
1011 .filter_map(|s| {
1012 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
1013 Some((key, value))
1014 } else {
1015 None
1016 }
1017 })
1018 .collect();
1019
1020 let subject = if let Some(subject) = param.get("subject") {
1021 subject.to_string()
1022 } else {
1023 "".to_string()
1024 };
1025 let draft = if let Some(body) = param.get("body") {
1026 if subject.is_empty() {
1027 body.to_string()
1028 } else {
1029 subject + "\n" + body
1030 }
1031 } else {
1032 subject
1033 };
1034 let draft = draft.replace('+', "%20"); let draft = match percent_decode_str(&draft).decode_utf8() {
1036 Ok(decoded_draft) => decoded_draft.to_string(),
1037 Err(_err) => draft,
1038 };
1039
1040 let addr = normalize_address(addr)?;
1041 let name = "";
1042 Qr::from_address(
1043 context,
1044 name,
1045 &addr,
1046 if draft.is_empty() { None } else { Some(draft) },
1047 )
1048 .await
1049}
1050
1051async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
1055 let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
1056
1057 let (addr, _rest) = payload
1058 .split_once(':')
1059 .context("Invalid SMTP scheme payload")?;
1060 let addr = normalize_address(addr)?;
1061 let name = "";
1062 Qr::from_address(context, name, &addr, None).await
1063}
1064
1065#[expect(clippy::arithmetic_side_effects)]
1071async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
1072 let addr = if let Some(to_index) = qr.find("TO:") {
1075 let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
1076 if let Some(semi_index) = addr.find(';') {
1077 addr.get(..semi_index).unwrap_or_default().trim()
1078 } else {
1079 addr
1080 }
1081 } else {
1082 bail!("Invalid MATMSG found");
1083 };
1084
1085 let addr = normalize_address(addr)?;
1086 let name = "";
1087 Qr::from_address(context, name, &addr, None).await
1088}
1089
1090static VCARD_NAME_RE: LazyLock<regex::Regex> =
1091 LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
1092static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
1093 LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
1094
1095async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
1099 let name = VCARD_NAME_RE
1100 .captures(qr)
1101 .and_then(|caps| {
1102 let last_name = caps.get(1)?.as_str().trim();
1103 let first_name = caps.get(2)?.as_str().trim();
1104
1105 Some(format!("{first_name} {last_name}"))
1106 })
1107 .unwrap_or_default();
1108
1109 let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
1110 normalize_address(cap.as_str().trim())?
1111 } else {
1112 bail!("Bad e-mail address");
1113 };
1114
1115 Qr::from_address(context, &name, &addr, None).await
1116}
1117
1118impl Qr {
1119 pub async fn from_address(
1123 context: &Context,
1124 name: &str,
1125 addr: &str,
1126 draft: Option<String>,
1127 ) -> Result<Self> {
1128 let addr = ContactAddress::new(addr)?;
1129 let (contact_id, _) =
1130 Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
1131 Ok(Qr::Addr { contact_id, draft })
1132 }
1133}
1134
1135fn normalize_address(addr: &str) -> Result<String> {
1137 let new_addr = percent_decode_str(addr).decode_utf8()?;
1139 let new_addr = addr_normalize(&new_addr);
1140
1141 ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
1142
1143 Ok(new_addr.to_string())
1144}
1145
1146#[cfg(test)]
1147mod qr_tests;