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, EnteredLoginParam, EnteredServerLoginParam};
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 = 4;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Qr {
49 AskVerifyContact {
53 contact_id: ContactId,
55
56 fingerprint: Fingerprint,
58
59 invitenumber: String,
61
62 authcode: String,
64
65 is_v3: bool,
67 },
68
69 AskVerifyGroup {
71 grpname: String,
73
74 grpid: String,
76
77 contact_id: ContactId,
79
80 fingerprint: Fingerprint,
82
83 invitenumber: String,
85
86 authcode: String,
88
89 is_v3: bool,
91 },
92
93 AskJoinBroadcast {
95 name: String,
97
98 grpid: String,
104
105 contact_id: ContactId,
107
108 fingerprint: Fingerprint,
110
111 invitenumber: String,
113 authcode: String,
115
116 is_v3: bool,
118 },
119
120 FprOk {
124 contact_id: ContactId,
126 },
127
128 FprMismatch {
130 contact_id: Option<ContactId>,
132 },
133
134 FprWithoutAddr {
136 fingerprint: String,
138 },
139
140 Account {
142 domain: String,
144 },
145
146 Backup2 {
148 node_addr: iroh::NodeAddr,
150
151 auth_token: String,
153 },
154
155 BackupTooNew {},
157
158 Proxy {
168 url: String,
172
173 host: String,
175
176 port: u16,
178 },
179
180 Addr {
185 contact_id: ContactId,
187
188 draft: Option<String>,
190 },
191
192 Url {
196 url: String,
198 },
199
200 Text {
204 text: String,
206 },
207
208 WithdrawVerifyContact {
210 contact_id: ContactId,
212
213 fingerprint: Fingerprint,
215
216 invitenumber: String,
218
219 authcode: String,
221 },
222
223 WithdrawVerifyGroup {
225 grpname: String,
227
228 grpid: String,
230
231 contact_id: ContactId,
233
234 fingerprint: Fingerprint,
236
237 invitenumber: String,
239
240 authcode: String,
242 },
243
244 WithdrawJoinBroadcast {
246 name: String,
248
249 grpid: String,
255
256 contact_id: ContactId,
258
259 fingerprint: Fingerprint,
261
262 invitenumber: String,
264
265 authcode: String,
267 },
268
269 ReviveVerifyContact {
271 contact_id: ContactId,
273
274 fingerprint: Fingerprint,
276
277 invitenumber: String,
279
280 authcode: String,
282 },
283
284 ReviveVerifyGroup {
286 grpname: String,
288
289 grpid: String,
291
292 contact_id: ContactId,
294
295 fingerprint: Fingerprint,
297
298 invitenumber: String,
300
301 authcode: String,
303 },
304
305 ReviveJoinBroadcast {
307 name: String,
309
310 grpid: String,
316
317 contact_id: ContactId,
319
320 fingerprint: Fingerprint,
322
323 invitenumber: String,
325
326 authcode: String,
328 },
329
330 Login {
334 address: String,
336
337 options: LoginOptions,
339 },
340}
341
342fn fix_add_second_device_qr(qr: &str) -> String {
345 qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
346 .replacen(r#""]}}"#, r#""]}"#, 1)
347}
348
349fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
350 string.to_lowercase().starts_with(&pattern.to_lowercase())
351}
352
353pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
358 let qr = qr.trim();
359 let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
360 decode_openpgp(context, qr)
361 .await
362 .context("failed to decode OPENPGP4FPR QR code")?
363 } else if qr.starts_with(IDELTACHAT_SCHEME) {
364 decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
365 } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
366 decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
367 } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
368 decode_account(qr)?
369 } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
370 dclogin_scheme::decode_login(qr)?
371 } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
372 decode_tg_socks_proxy(context, qr)?
373 } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
374 decode_shadowsocks_proxy(qr)?
375 } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
376 let qr_fixed = fix_add_second_device_qr(qr);
377 decode_backup2(&qr_fixed)?
378 } else if qr.starts_with(MAILTO_SCHEME) {
379 decode_mailto(context, qr).await?
380 } else if qr.starts_with(SMTP_SCHEME) {
381 decode_smtp(context, qr).await?
382 } else if qr.starts_with(MATMSG_SCHEME) {
383 decode_matmsg(context, qr).await?
384 } else if qr.starts_with(VCARD_SCHEME) {
385 decode_vcard(context, qr).await?
386 } else if let Ok(url) = url::Url::parse(qr) {
387 match url.scheme() {
388 "socks5" => Qr::Proxy {
389 url: qr.to_string(),
390 host: url.host_str().context("URL has no host")?.to_string(),
391 port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
392 },
393 "http" | "https" => {
394 let url = if let Some(rest) = qr.strip_prefix("http://") {
398 url::Url::parse(&format!("foobarbaz://{rest}"))?
399 } else if let Some(rest) = qr.strip_prefix("https://") {
400 url::Url::parse(&format!("foobarbaz://{rest}"))?
401 } else {
402 url
404 };
405
406 if url.port().is_none() | (url.path() != "") | url.query().is_some() {
407 Qr::Url {
409 url: qr.to_string(),
410 }
411 } else {
412 Qr::Proxy {
413 url: qr.to_string(),
414 host: url.host_str().context("URL has no host")?.to_string(),
415 port: url
416 .port_or_known_default()
417 .context("HTTP(S) URLs are guaranteed to return Some port")?,
418 }
419 }
420 }
421 _ => Qr::Url {
422 url: qr.to_string(),
423 },
424 }
425 } else {
426 Qr::Text {
427 text: qr.to_string(),
428 }
429 };
430 Ok(qrcode)
431}
432
433pub fn format_backup(qr: &Qr) -> Result<String> {
440 match qr {
441 Qr::Backup2 {
442 node_addr,
443 auth_token,
444 } => {
445 let node_addr = serde_json::to_string(node_addr)?;
446 Ok(format!(
447 "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
448 ))
449 }
450 _ => Err(anyhow!("Not a backup QR code")),
451 }
452}
453
454async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
459 let payload = qr
460 .get(OPENPGP4FPR_SCHEME.len()..)
461 .context("Invalid OPENPGP4FPR scheme")?;
462
463 let (fingerprint, fragment) = match payload
466 .split_once('#')
467 .or_else(|| payload.split_once("%23"))
468 {
469 Some(pair) => pair,
470 None => (payload, ""),
471 };
472 let fingerprint: Fingerprint = fingerprint
473 .parse()
474 .context("Failed to parse fingerprint in the QR code")?;
475
476 let param: BTreeMap<&str, &str> = fragment
477 .split('&')
478 .filter_map(|s| {
479 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
480 Some((key, value))
481 } else {
482 None
483 }
484 })
485 .collect();
486
487 let addr = if let Some(addr) = param.get("a") {
488 Some(normalize_address(addr)?)
489 } else {
490 None
491 };
492
493 let name = decode_name(¶m, "n")?.unwrap_or_default();
494
495 let mut invitenumber = param
496 .get("i")
497 .or_else(|| param.get("j"))
499 .filter(|&s| validate_id(s))
500 .map(|s| s.to_string());
501 let authcode = param
502 .get("s")
503 .filter(|&s| validate_id(s))
504 .map(|s| s.to_string());
505 let grpid = param
506 .get("x")
507 .filter(|&s| validate_id(s))
508 .map(|s| s.to_string());
509
510 let grpname = decode_name(¶m, "g")?;
511 let broadcast_name = decode_name(¶m, "b")?;
512
513 let mut is_v3 = param.get("v") == Some(&"3");
514
515 if authcode.is_some() && invitenumber.is_none() {
516 is_v3 = true;
520 invitenumber = Some("".to_string());
521 }
522
523 if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
524 let addr = ContactAddress::new(addr)?;
525 let (contact_id, _) = Contact::add_or_lookup_ex(
526 context,
527 &name,
528 &addr,
529 &fingerprint.hex(),
530 Origin::UnhandledSecurejoinQrScan,
531 )
532 .await
533 .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
534
535 if let (Some(grpid), Some(grpname)) = (grpid.clone(), grpname) {
536 if context
537 .is_self_addr(&addr)
538 .await
539 .with_context(|| format!("can't check if address {addr:?} is our address"))?
540 {
541 if token::exists(context, token::Namespace::Auth, &authcode).await? {
542 Ok(Qr::WithdrawVerifyGroup {
543 grpname,
544 grpid,
545 contact_id,
546 fingerprint,
547 invitenumber,
548 authcode,
549 })
550 } else {
551 Ok(Qr::ReviveVerifyGroup {
552 grpname,
553 grpid,
554 contact_id,
555 fingerprint,
556 invitenumber,
557 authcode,
558 })
559 }
560 } else {
561 Ok(Qr::AskVerifyGroup {
562 grpname,
563 grpid,
564 contact_id,
565 fingerprint,
566 invitenumber,
567 authcode,
568 is_v3,
569 })
570 }
571 } else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
572 if context
573 .is_self_addr(&addr)
574 .await
575 .with_context(|| format!("Can't check if {addr:?} is our address"))?
576 {
577 if token::exists(context, token::Namespace::Auth, &authcode).await? {
578 Ok(Qr::WithdrawJoinBroadcast {
579 name,
580 grpid,
581 contact_id,
582 fingerprint,
583 invitenumber,
584 authcode,
585 })
586 } else {
587 Ok(Qr::ReviveJoinBroadcast {
588 name,
589 grpid,
590 contact_id,
591 fingerprint,
592 invitenumber,
593 authcode,
594 })
595 }
596 } else {
597 Ok(Qr::AskJoinBroadcast {
598 name,
599 grpid,
600 contact_id,
601 fingerprint,
602 invitenumber,
603 authcode,
604 is_v3,
605 })
606 }
607 } else if context.is_self_addr(&addr).await? {
608 if token::exists(context, token::Namespace::Auth, &authcode).await? {
609 Ok(Qr::WithdrawVerifyContact {
610 contact_id,
611 fingerprint,
612 invitenumber,
613 authcode,
614 })
615 } else {
616 Ok(Qr::ReviveVerifyContact {
617 contact_id,
618 fingerprint,
619 invitenumber,
620 authcode,
621 })
622 }
623 } else {
624 Ok(Qr::AskVerifyContact {
625 contact_id,
626 fingerprint,
627 invitenumber,
628 authcode,
629 is_v3,
630 })
631 }
632 } else if let Some(addr) = addr {
633 let fingerprint = fingerprint.hex();
634 let (contact_id, _) =
635 Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan)
636 .await?;
637 let contact = Contact::get_by_id(context, contact_id).await?;
638
639 if contact.public_key(context).await?.is_some() {
640 Ok(Qr::FprOk { contact_id })
641 } else {
642 Ok(Qr::FprMismatch {
643 contact_id: Some(contact_id),
644 })
645 }
646 } else {
647 Ok(Qr::FprWithoutAddr {
648 fingerprint: fingerprint.to_string(),
649 })
650 }
651}
652
653fn decode_name(param: &BTreeMap<&str, &str>, key: &str) -> Result<Option<String>> {
654 if let Some(encoded_name) = param.get(key) {
655 let encoded_name = encoded_name.replace('+', "%20"); let mut name = match percent_decode_str(&encoded_name).decode_utf8() {
657 Ok(name) => name.to_string(),
658 Err(err) => bail!("Invalid QR param {key}: {err}"),
659 };
660 if let Some(n) = name.strip_suffix('_') {
661 name = format!("{n}…");
662 }
663 Ok(Some(name))
664 } else {
665 Ok(None)
666 }
667}
668
669async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
671 let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
672 let qr = qr.replacen('&', "#", 1);
673 decode_openpgp(context, &qr)
674 .await
675 .with_context(|| format!("failed to decode {prefix} QR code"))
676}
677
678fn decode_account(qr: &str) -> Result<Qr> {
682 let payload = qr
683 .get(DCACCOUNT_SCHEME.len()..)
684 .context("Invalid DCACCOUNT payload")?;
685
686 let payload = payload.strip_prefix("//").unwrap_or(payload);
688 if payload.is_empty() {
689 bail!("dcaccount payload is empty");
690 }
691 if payload.starts_with("https://") {
692 let url = url::Url::parse(payload).context("Invalid account URL")?;
693 if url.scheme() == "https" {
694 Ok(Qr::Account {
695 domain: url
696 .host_str()
697 .context("can't extract account setup domain")?
698 .to_string(),
699 })
700 } else {
701 bail!("Bad scheme for account URL: {:?}.", url.scheme());
702 }
703 } else {
704 if payload.starts_with("/") {
705 bail!("Hostname in dcaccount URL cannot start with /");
709 }
710 Ok(Qr::Account {
711 domain: payload.to_string(),
712 })
713 }
714}
715
716#[expect(clippy::arithmetic_side_effects)]
718fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
719 let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
720
721 let mut host: Option<String> = None;
722 let mut port: u16 = DEFAULT_SOCKS_PORT;
723 let mut user: Option<String> = None;
724 let mut pass: Option<String> = None;
725 for (key, value) in url.query_pairs() {
726 if key == "server" {
727 host = Some(value.to_string());
728 } else if key == "port" {
729 port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
730 } else if key == "user" {
731 user = Some(value.to_string());
732 } else if key == "pass" {
733 pass = Some(value.to_string());
734 }
735 }
736
737 let Some(host) = host else {
738 bail!("Bad t.me/socks url: {url:?}");
739 };
740
741 let mut url = "socks5://".to_string();
742 if let Some(pass) = pass {
743 url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
744 url += ":";
745 url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
746 url += "@";
747 };
748 url += &host;
749 url += ":";
750 url += &port.to_string();
751
752 Ok(Qr::Proxy { url, host, port })
753}
754
755fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
757 let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
758 let addr = server_config.addr();
759 let host = addr.host().to_string();
760 let port = addr.port();
761 Ok(Qr::Proxy {
762 url: qr.to_string(),
763 host,
764 port,
765 })
766}
767
768fn decode_backup2(qr: &str) -> Result<Qr> {
770 let version_and_payload = qr
771 .strip_prefix(DCBACKUP_SCHEME_PREFIX)
772 .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
773 let (version, payload) = version_and_payload
774 .split_once(':')
775 .context("DCBACKUP scheme separator missing")?;
776 let version: i32 = version.parse().context("Not a valid number")?;
777 if version > DCBACKUP_VERSION {
778 return Ok(Qr::BackupTooNew {});
779 }
780
781 let (auth_token, node_addr) = payload
782 .split_once('&')
783 .context("Backup QR code has no separator")?;
784 let auth_token = auth_token.to_string();
785 let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
786 .context("Invalid node addr in backup QR code")?;
787
788 Ok(Qr::Backup2 {
789 node_addr,
790 auth_token,
791 })
792}
793
794#[derive(Debug, Deserialize)]
795struct CreateAccountSuccessResponse {
796 email: String,
798
799 password: String,
801}
802#[derive(Debug, Deserialize)]
803struct CreateAccountErrorResponse {
804 reason: String,
806}
807
808pub(crate) async fn login_param_from_account_qr(
812 context: &Context,
813 qr: &str,
814) -> Result<EnteredLoginParam> {
815 let payload = qr
816 .get(DCACCOUNT_SCHEME.len()..)
817 .context("Invalid DCACCOUNT scheme")?;
818
819 if !payload.starts_with(HTTPS_SCHEME) {
820 let rng = &mut rand::rngs::OsRng.unwrap_err();
821 let username = Alphanumeric.sample_string(rng, 9);
822 let addr = username + "@" + payload;
823 let password = Alphanumeric.sample_string(rng, 50);
824
825 let param = EnteredLoginParam {
826 addr,
827 imap: EnteredServerLoginParam {
828 password,
829 ..Default::default()
830 },
831 smtp: Default::default(),
832 certificate_checks: EnteredCertificateChecks::Strict,
833 oauth2: false,
834 };
835 return Ok(param);
836 }
837
838 let (response_text, response_success) = post_empty(context, payload).await?;
839 if response_success {
840 let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
841 .with_context(|| {
842 format!("Cannot create account, response is malformed:\n{response_text:?}")
843 })?;
844
845 let param = EnteredLoginParam {
846 addr: email,
847 imap: EnteredServerLoginParam {
848 password,
849 ..Default::default()
850 },
851 smtp: Default::default(),
852 certificate_checks: EnteredCertificateChecks::Strict,
853 oauth2: false,
854 };
855
856 Ok(param)
857 } else {
858 match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
859 Ok(error) => Err(anyhow!(error.reason)),
860 Err(parse_error) => {
861 error!(
862 context,
863 "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
864 );
865 bail!("Cannot create account, unexpected server response:\n{response_text:?}")
866 }
867 }
868 }
869}
870
871pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
875 match check_qr(context, qr).await? {
876 Qr::Account { .. } => {
877 let mut param = login_param_from_account_qr(context, qr).await?;
878 context.add_transport_inner(&mut param).await?
879 }
880 Qr::Proxy { url, .. } => {
881 let old_proxy_url_value = context
882 .get_config(Config::ProxyUrl)
883 .await?
884 .unwrap_or_default();
885
886 let url = ProxyConfig::from_url(&url)?.to_url();
888
889 let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
890 .chain(
891 old_proxy_url_value
892 .split('\n')
893 .filter(|s| !s.is_empty() && *s != url),
894 )
895 .collect();
896 context
897 .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
898 .await?;
899 context.set_config_bool(Config::ProxyEnabled, true).await?;
900 }
901 Qr::WithdrawVerifyContact {
902 invitenumber,
903 authcode,
904 ..
905 } => {
906 token::delete(context, "").await?;
907 context
908 .sync_qr_code_token_deletion(invitenumber, authcode)
909 .await?;
910 }
911 Qr::WithdrawVerifyGroup {
912 grpid,
913 invitenumber,
914 authcode,
915 ..
916 }
917 | Qr::WithdrawJoinBroadcast {
918 grpid,
919 invitenumber,
920 authcode,
921 ..
922 } => {
923 token::delete(context, &grpid).await?;
924 context
925 .sync_qr_code_token_deletion(invitenumber, authcode)
926 .await?;
927 }
928 Qr::ReviveVerifyContact {
929 invitenumber,
930 authcode,
931 ..
932 } => {
933 let timestamp = time();
934 token::save(
935 context,
936 token::Namespace::InviteNumber,
937 None,
938 &invitenumber,
939 timestamp,
940 )
941 .await?;
942 token::save(context, token::Namespace::Auth, None, &authcode, timestamp).await?;
943 context.sync_qr_code_tokens(None).await?;
944 context.scheduler.interrupt_smtp().await;
945 }
946 Qr::ReviveVerifyGroup {
947 invitenumber,
948 authcode,
949 grpid,
950 ..
951 }
952 | Qr::ReviveJoinBroadcast {
953 invitenumber,
954 authcode,
955 grpid,
956 ..
957 } => {
958 let timestamp = time();
959 token::save(
960 context,
961 token::Namespace::InviteNumber,
962 Some(&grpid),
963 &invitenumber,
964 timestamp,
965 )
966 .await?;
967 token::save(
968 context,
969 token::Namespace::Auth,
970 Some(&grpid),
971 &authcode,
972 timestamp,
973 )
974 .await?;
975 context.sync_qr_code_tokens(Some(&grpid)).await?;
976 context.scheduler.interrupt_smtp().await;
977 }
978 Qr::Login { address, options } => {
979 let mut param = login_param_from_login_qr(&address, options)?;
980 context.add_transport_inner(&mut param).await?
981 }
982 _ => bail!("QR code does not contain config"),
983 }
984
985 Ok(())
986}
987
988async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
992 let payload = qr
993 .get(MAILTO_SCHEME.len()..)
994 .context("Invalid mailto: scheme")?;
995
996 let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
997
998 let param: BTreeMap<&str, &str> = query
999 .split('&')
1000 .filter_map(|s| {
1001 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
1002 Some((key, value))
1003 } else {
1004 None
1005 }
1006 })
1007 .collect();
1008
1009 let subject = if let Some(subject) = param.get("subject") {
1010 subject.to_string()
1011 } else {
1012 "".to_string()
1013 };
1014 let draft = if let Some(body) = param.get("body") {
1015 if subject.is_empty() {
1016 body.to_string()
1017 } else {
1018 subject + "\n" + body
1019 }
1020 } else {
1021 subject
1022 };
1023 let draft = draft.replace('+', "%20"); let draft = match percent_decode_str(&draft).decode_utf8() {
1025 Ok(decoded_draft) => decoded_draft.to_string(),
1026 Err(_err) => draft,
1027 };
1028
1029 let addr = normalize_address(addr)?;
1030 let name = "";
1031 Qr::from_address(
1032 context,
1033 name,
1034 &addr,
1035 if draft.is_empty() { None } else { Some(draft) },
1036 )
1037 .await
1038}
1039
1040async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
1044 let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
1045
1046 let (addr, _rest) = payload
1047 .split_once(':')
1048 .context("Invalid SMTP scheme payload")?;
1049 let addr = normalize_address(addr)?;
1050 let name = "";
1051 Qr::from_address(context, name, &addr, None).await
1052}
1053
1054#[expect(clippy::arithmetic_side_effects)]
1060async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
1061 let addr = if let Some(to_index) = qr.find("TO:") {
1064 let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
1065 if let Some(semi_index) = addr.find(';') {
1066 addr.get(..semi_index).unwrap_or_default().trim()
1067 } else {
1068 addr
1069 }
1070 } else {
1071 bail!("Invalid MATMSG found");
1072 };
1073
1074 let addr = normalize_address(addr)?;
1075 let name = "";
1076 Qr::from_address(context, name, &addr, None).await
1077}
1078
1079static VCARD_NAME_RE: LazyLock<regex::Regex> =
1080 LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
1081static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
1082 LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
1083
1084async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
1088 let name = VCARD_NAME_RE
1089 .captures(qr)
1090 .and_then(|caps| {
1091 let last_name = caps.get(1)?.as_str().trim();
1092 let first_name = caps.get(2)?.as_str().trim();
1093
1094 Some(format!("{first_name} {last_name}"))
1095 })
1096 .unwrap_or_default();
1097
1098 let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
1099 normalize_address(cap.as_str().trim())?
1100 } else {
1101 bail!("Bad e-mail address");
1102 };
1103
1104 Qr::from_address(context, &name, &addr, None).await
1105}
1106
1107impl Qr {
1108 pub async fn from_address(
1112 context: &Context,
1113 name: &str,
1114 addr: &str,
1115 draft: Option<String>,
1116 ) -> Result<Self> {
1117 let addr = ContactAddress::new(addr)?;
1118 let (contact_id, _) =
1119 Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
1120 Ok(Qr::Addr { contact_id, draft })
1121 }
1122}
1123
1124fn normalize_address(addr: &str) -> Result<String> {
1126 let new_addr = percent_decode_str(addr).decode_utf8()?;
1128 let new_addr = addr_normalize(&new_addr);
1129
1130 ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
1131
1132 Ok(new_addr.to_string())
1133}
1134
1135#[cfg(test)]
1136mod qr_tests;