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