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::events::EventType;
20use crate::key::Fingerprint;
21use crate::login_param::{EnteredCertificateChecks, EnteredLoginParam, EnteredServerLoginParam};
22use crate::net::http::post_empty;
23use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig};
24use crate::token;
25use crate::tools::{time, validate_id};
26
27const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
29const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#";
30const DCACCOUNT_SCHEME: &str = "DCACCOUNT:";
31pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:";
32const TG_SOCKS_SCHEME: &str = "https://t.me/socks";
33const MAILTO_SCHEME: &str = "mailto:";
34const MATMSG_SCHEME: &str = "MATMSG:";
35const VCARD_SCHEME: &str = "BEGIN:VCARD";
36const SMTP_SCHEME: &str = "SMTP:";
37const HTTPS_SCHEME: &str = "https://";
38const SHADOWSOCKS_SCHEME: &str = "ss://";
39
40pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
42
43pub(crate) const DCBACKUP_VERSION: i32 = 3;
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Qr {
50 AskVerifyContact {
54 contact_id: ContactId,
56
57 fingerprint: Fingerprint,
59
60 invitenumber: String,
62
63 authcode: String,
65 },
66
67 AskVerifyGroup {
69 grpname: String,
71
72 grpid: String,
74
75 contact_id: ContactId,
77
78 fingerprint: Fingerprint,
80
81 invitenumber: String,
83
84 authcode: String,
86 },
87
88 FprOk {
92 contact_id: ContactId,
94 },
95
96 FprMismatch {
98 contact_id: Option<ContactId>,
100 },
101
102 FprWithoutAddr {
104 fingerprint: String,
106 },
107
108 Account {
110 domain: String,
112 },
113
114 Backup2 {
116 node_addr: iroh::NodeAddr,
118
119 auth_token: String,
121 },
122
123 BackupTooNew {},
125
126 Proxy {
136 url: String,
140
141 host: String,
143
144 port: u16,
146 },
147
148 Addr {
153 contact_id: ContactId,
155
156 draft: Option<String>,
158 },
159
160 Url {
164 url: String,
166 },
167
168 Text {
172 text: String,
174 },
175
176 WithdrawVerifyContact {
178 contact_id: ContactId,
180
181 fingerprint: Fingerprint,
183
184 invitenumber: String,
186
187 authcode: String,
189 },
190
191 WithdrawVerifyGroup {
193 grpname: String,
195
196 grpid: String,
198
199 contact_id: ContactId,
201
202 fingerprint: Fingerprint,
204
205 invitenumber: String,
207
208 authcode: String,
210 },
211
212 ReviveVerifyContact {
214 contact_id: ContactId,
216
217 fingerprint: Fingerprint,
219
220 invitenumber: String,
222
223 authcode: String,
225 },
226
227 ReviveVerifyGroup {
229 grpname: String,
231
232 grpid: String,
234
235 contact_id: ContactId,
237
238 fingerprint: Fingerprint,
240
241 invitenumber: String,
243
244 authcode: String,
246 },
247
248 Login {
252 address: String,
254
255 options: LoginOptions,
257 },
258}
259
260fn fix_add_second_device_qr(qr: &str) -> String {
263 qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
264 .replacen(r#""]}}"#, r#""]}"#, 1)
265}
266
267fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
268 string.to_lowercase().starts_with(&pattern.to_lowercase())
269}
270
271pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
276 let qr = qr.trim();
277 let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
278 decode_openpgp(context, qr)
279 .await
280 .context("failed to decode OPENPGP4FPR QR code")?
281 } else if qr.starts_with(IDELTACHAT_SCHEME) {
282 decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
283 } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
284 decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
285 } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
286 decode_account(qr)?
287 } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
288 dclogin_scheme::decode_login(qr)?
289 } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
290 decode_tg_socks_proxy(context, qr)?
291 } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
292 decode_shadowsocks_proxy(qr)?
293 } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
294 let qr_fixed = fix_add_second_device_qr(qr);
295 decode_backup2(&qr_fixed)?
296 } else if qr.starts_with(MAILTO_SCHEME) {
297 decode_mailto(context, qr).await?
298 } else if qr.starts_with(SMTP_SCHEME) {
299 decode_smtp(context, qr).await?
300 } else if qr.starts_with(MATMSG_SCHEME) {
301 decode_matmsg(context, qr).await?
302 } else if qr.starts_with(VCARD_SCHEME) {
303 decode_vcard(context, qr).await?
304 } else if let Ok(url) = url::Url::parse(qr) {
305 match url.scheme() {
306 "socks5" => Qr::Proxy {
307 url: qr.to_string(),
308 host: url.host_str().context("URL has no host")?.to_string(),
309 port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
310 },
311 "http" | "https" => {
312 let url = if let Some(rest) = qr.strip_prefix("http://") {
316 url::Url::parse(&format!("foobarbaz://{rest}"))?
317 } else if let Some(rest) = qr.strip_prefix("https://") {
318 url::Url::parse(&format!("foobarbaz://{rest}"))?
319 } else {
320 url
322 };
323
324 if url.port().is_none() | (url.path() != "") | url.query().is_some() {
325 Qr::Url {
327 url: qr.to_string(),
328 }
329 } else {
330 Qr::Proxy {
331 url: qr.to_string(),
332 host: url.host_str().context("URL has no host")?.to_string(),
333 port: url
334 .port_or_known_default()
335 .context("HTTP(S) URLs are guaranteed to return Some port")?,
336 }
337 }
338 }
339 _ => Qr::Url {
340 url: qr.to_string(),
341 },
342 }
343 } else {
344 Qr::Text {
345 text: qr.to_string(),
346 }
347 };
348 Ok(qrcode)
349}
350
351pub fn format_backup(qr: &Qr) -> Result<String> {
358 match qr {
359 Qr::Backup2 {
360 node_addr,
361 auth_token,
362 } => {
363 let node_addr = serde_json::to_string(node_addr)?;
364 Ok(format!(
365 "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
366 ))
367 }
368 _ => Err(anyhow!("Not a backup QR code")),
369 }
370}
371
372async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
376 let payload = qr
377 .get(OPENPGP4FPR_SCHEME.len()..)
378 .context("Invalid OPENPGP4FPR scheme")?;
379
380 let (fingerprint, fragment) = match payload
383 .split_once('#')
384 .or_else(|| payload.split_once("%23"))
385 {
386 Some(pair) => pair,
387 None => (payload, ""),
388 };
389 let fingerprint: Fingerprint = fingerprint
390 .parse()
391 .context("Failed to parse fingerprint in the QR code")?;
392
393 let param: BTreeMap<&str, &str> = fragment
394 .split('&')
395 .filter_map(|s| {
396 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
397 Some((key, value))
398 } else {
399 None
400 }
401 })
402 .collect();
403
404 let addr = if let Some(addr) = param.get("a") {
405 Some(normalize_address(addr)?)
406 } else {
407 None
408 };
409
410 let name = if let Some(encoded_name) = param.get("n") {
411 let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
413 Ok(name) => name.to_string(),
414 Err(err) => bail!("Invalid name: {err}"),
415 }
416 } else {
417 "".to_string()
418 };
419
420 let invitenumber = param
421 .get("i")
422 .filter(|&s| validate_id(s))
423 .map(|s| s.to_string());
424 let authcode = param
425 .get("s")
426 .filter(|&s| validate_id(s))
427 .map(|s| s.to_string());
428 let grpid = param
429 .get("x")
430 .filter(|&s| validate_id(s))
431 .map(|s| s.to_string());
432
433 let grpname = if grpid.is_some() {
434 if let Some(encoded_name) = param.get("g") {
435 let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
437 Ok(name) => Some(name.to_string()),
438 Err(err) => bail!("Invalid group name: {err}"),
439 }
440 } else {
441 None
442 }
443 } else {
444 None
445 };
446
447 if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
448 let addr = ContactAddress::new(addr)?;
449 let (contact_id, _) = Contact::add_or_lookup_ex(
450 context,
451 &name,
452 &addr,
453 &fingerprint.hex(),
454 Origin::UnhandledSecurejoinQrScan,
455 )
456 .await
457 .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
458
459 if let (Some(grpid), Some(grpname)) = (grpid, grpname) {
460 if context
461 .is_self_addr(&addr)
462 .await
463 .with_context(|| format!("can't check if address {addr:?} is our address"))?
464 {
465 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
466 Ok(Qr::WithdrawVerifyGroup {
467 grpname,
468 grpid,
469 contact_id,
470 fingerprint,
471 invitenumber,
472 authcode,
473 })
474 } else {
475 Ok(Qr::ReviveVerifyGroup {
476 grpname,
477 grpid,
478 contact_id,
479 fingerprint,
480 invitenumber,
481 authcode,
482 })
483 }
484 } else {
485 Ok(Qr::AskVerifyGroup {
486 grpname,
487 grpid,
488 contact_id,
489 fingerprint,
490 invitenumber,
491 authcode,
492 })
493 }
494 } else if context.is_self_addr(&addr).await? {
495 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
496 Ok(Qr::WithdrawVerifyContact {
497 contact_id,
498 fingerprint,
499 invitenumber,
500 authcode,
501 })
502 } else {
503 Ok(Qr::ReviveVerifyContact {
504 contact_id,
505 fingerprint,
506 invitenumber,
507 authcode,
508 })
509 }
510 } else {
511 Ok(Qr::AskVerifyContact {
512 contact_id,
513 fingerprint,
514 invitenumber,
515 authcode,
516 })
517 }
518 } else if let Some(addr) = addr {
519 let fingerprint = fingerprint.hex();
520 let (contact_id, _) =
521 Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan)
522 .await?;
523 let contact = Contact::get_by_id(context, contact_id).await?;
524
525 if contact.public_key(context).await?.is_some() {
526 Ok(Qr::FprOk { contact_id })
527 } else {
528 Ok(Qr::FprMismatch {
529 contact_id: Some(contact_id),
530 })
531 }
532 } else {
533 Ok(Qr::FprWithoutAddr {
534 fingerprint: fingerprint.to_string(),
535 })
536 }
537}
538
539async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
541 let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
542 let qr = qr.replacen('&', "#", 1);
543 decode_openpgp(context, &qr)
544 .await
545 .with_context(|| format!("failed to decode {prefix} QR code"))
546}
547
548fn decode_account(qr: &str) -> Result<Qr> {
552 let payload = qr
553 .get(DCACCOUNT_SCHEME.len()..)
554 .context("Invalid DCACCOUNT payload")?;
555 if payload.starts_with("https://") {
556 let url = url::Url::parse(payload).context("Invalid account URL")?;
557 if url.scheme() == "https" {
558 Ok(Qr::Account {
559 domain: url
560 .host_str()
561 .context("can't extract account setup domain")?
562 .to_string(),
563 })
564 } else {
565 bail!("Bad scheme for account URL: {:?}.", url.scheme());
566 }
567 } else {
568 Ok(Qr::Account {
569 domain: payload.to_string(),
570 })
571 }
572}
573
574fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
576 let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
577
578 let mut host: Option<String> = None;
579 let mut port: u16 = DEFAULT_SOCKS_PORT;
580 let mut user: Option<String> = None;
581 let mut pass: Option<String> = None;
582 for (key, value) in url.query_pairs() {
583 if key == "server" {
584 host = Some(value.to_string());
585 } else if key == "port" {
586 port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
587 } else if key == "user" {
588 user = Some(value.to_string());
589 } else if key == "pass" {
590 pass = Some(value.to_string());
591 }
592 }
593
594 let Some(host) = host else {
595 bail!("Bad t.me/socks url: {url:?}");
596 };
597
598 let mut url = "socks5://".to_string();
599 if let Some(pass) = pass {
600 url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
601 url += ":";
602 url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
603 url += "@";
604 };
605 url += &host;
606 url += ":";
607 url += &port.to_string();
608
609 Ok(Qr::Proxy { url, host, port })
610}
611
612fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
614 let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
615 let addr = server_config.addr();
616 let host = addr.host().to_string();
617 let port = addr.port();
618 Ok(Qr::Proxy {
619 url: qr.to_string(),
620 host,
621 port,
622 })
623}
624
625fn decode_backup2(qr: &str) -> Result<Qr> {
627 let version_and_payload = qr
628 .strip_prefix(DCBACKUP_SCHEME_PREFIX)
629 .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
630 let (version, payload) = version_and_payload
631 .split_once(':')
632 .context("DCBACKUP scheme separator missing")?;
633 let version: i32 = version.parse().context("Not a valid number")?;
634 if version > DCBACKUP_VERSION {
635 return Ok(Qr::BackupTooNew {});
636 }
637
638 let (auth_token, node_addr) = payload
639 .split_once('&')
640 .context("Backup QR code has no separator")?;
641 let auth_token = auth_token.to_string();
642 let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
643 .context("Invalid node addr in backup QR code")?;
644
645 Ok(Qr::Backup2 {
646 node_addr,
647 auth_token,
648 })
649}
650
651#[derive(Debug, Deserialize)]
652struct CreateAccountSuccessResponse {
653 email: String,
655
656 password: String,
658}
659#[derive(Debug, Deserialize)]
660struct CreateAccountErrorResponse {
661 reason: String,
663}
664
665pub(crate) async fn login_param_from_account_qr(
669 context: &Context,
670 qr: &str,
671) -> Result<EnteredLoginParam> {
672 let payload = qr
673 .get(DCACCOUNT_SCHEME.len()..)
674 .context("Invalid DCACCOUNT scheme")?;
675
676 if !payload.starts_with(HTTPS_SCHEME) {
677 let rng = &mut rand::rngs::OsRng.unwrap_err();
678 let username = Alphanumeric.sample_string(rng, 9);
679 let addr = username + "@" + payload;
680 let password = Alphanumeric.sample_string(rng, 50);
681
682 let param = EnteredLoginParam {
683 addr,
684 imap: EnteredServerLoginParam {
685 password,
686 ..Default::default()
687 },
688 smtp: Default::default(),
689 certificate_checks: EnteredCertificateChecks::Strict,
690 oauth2: false,
691 };
692 return Ok(param);
693 }
694
695 let (response_text, response_success) = post_empty(context, payload).await?;
696 if response_success {
697 let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
698 .with_context(|| {
699 format!("Cannot create account, response is malformed:\n{response_text:?}")
700 })?;
701
702 let param = EnteredLoginParam {
703 addr: email,
704 imap: EnteredServerLoginParam {
705 password,
706 ..Default::default()
707 },
708 smtp: Default::default(),
709 certificate_checks: EnteredCertificateChecks::Strict,
710 oauth2: false,
711 };
712
713 Ok(param)
714 } else {
715 match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
716 Ok(error) => Err(anyhow!(error.reason)),
717 Err(parse_error) => {
718 context.emit_event(EventType::Error(format!(
719 "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
720 )));
721 bail!("Cannot create account, unexpected server response:\n{response_text:?}")
722 }
723 }
724 }
725}
726
727pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
729 match check_qr(context, qr).await? {
730 Qr::Account { .. } => {
731 let mut param = login_param_from_account_qr(context, qr).await?;
732 context.add_transport_inner(&mut param).await?
733 }
734 Qr::Proxy { url, .. } => {
735 let old_proxy_url_value = context
736 .get_config(Config::ProxyUrl)
737 .await?
738 .unwrap_or_default();
739
740 let url = ProxyConfig::from_url(&url)?.to_url();
742
743 let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
744 .chain(
745 old_proxy_url_value
746 .split('\n')
747 .filter(|s| !s.is_empty() && *s != url),
748 )
749 .collect();
750 context
751 .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
752 .await?;
753 context.set_config_bool(Config::ProxyEnabled, true).await?;
754 }
755 Qr::WithdrawVerifyContact {
756 invitenumber,
757 authcode,
758 ..
759 } => {
760 token::delete(context, "").await?;
761 context
762 .sync_qr_code_token_deletion(invitenumber, authcode)
763 .await?;
764 }
765 Qr::WithdrawVerifyGroup {
766 grpid,
767 invitenumber,
768 authcode,
769 ..
770 } => {
771 token::delete(context, &grpid).await?;
772 context
773 .sync_qr_code_token_deletion(invitenumber, authcode)
774 .await?;
775 }
776 Qr::ReviveVerifyContact {
777 invitenumber,
778 authcode,
779 ..
780 } => {
781 let timestamp = time();
782 token::save(
783 context,
784 token::Namespace::InviteNumber,
785 None,
786 &invitenumber,
787 timestamp,
788 )
789 .await?;
790 token::save(context, token::Namespace::Auth, None, &authcode, timestamp).await?;
791 context.sync_qr_code_tokens(None).await?;
792 context.scheduler.interrupt_inbox().await;
793 }
794 Qr::ReviveVerifyGroup {
795 invitenumber,
796 authcode,
797 grpid,
798 ..
799 } => {
800 let timestamp = time();
801 token::save(
802 context,
803 token::Namespace::InviteNumber,
804 Some(&grpid),
805 &invitenumber,
806 timestamp,
807 )
808 .await?;
809 token::save(
810 context,
811 token::Namespace::Auth,
812 Some(&grpid),
813 &authcode,
814 timestamp,
815 )
816 .await?;
817 context.sync_qr_code_tokens(Some(&grpid)).await?;
818 context.scheduler.interrupt_inbox().await;
819 }
820 Qr::Login { address, options } => {
821 let mut param = login_param_from_login_qr(&address, options)?;
822 context.add_transport_inner(&mut param).await?
823 }
824 _ => bail!("QR code does not contain config"),
825 }
826
827 Ok(())
828}
829
830async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
834 let payload = qr
835 .get(MAILTO_SCHEME.len()..)
836 .context("Invalid mailto: scheme")?;
837
838 let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
839
840 let param: BTreeMap<&str, &str> = query
841 .split('&')
842 .filter_map(|s| {
843 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
844 Some((key, value))
845 } else {
846 None
847 }
848 })
849 .collect();
850
851 let subject = if let Some(subject) = param.get("subject") {
852 subject.to_string()
853 } else {
854 "".to_string()
855 };
856 let draft = if let Some(body) = param.get("body") {
857 if subject.is_empty() {
858 body.to_string()
859 } else {
860 subject + "\n" + body
861 }
862 } else {
863 subject
864 };
865 let draft = draft.replace('+', "%20"); let draft = match percent_decode_str(&draft).decode_utf8() {
867 Ok(decoded_draft) => decoded_draft.to_string(),
868 Err(_err) => draft,
869 };
870
871 let addr = normalize_address(addr)?;
872 let name = "";
873 Qr::from_address(
874 context,
875 name,
876 &addr,
877 if draft.is_empty() { None } else { Some(draft) },
878 )
879 .await
880}
881
882async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
886 let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
887
888 let (addr, _rest) = payload
889 .split_once(':')
890 .context("Invalid SMTP scheme payload")?;
891 let addr = normalize_address(addr)?;
892 let name = "";
893 Qr::from_address(context, name, &addr, None).await
894}
895
896async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
902 let addr = if let Some(to_index) = qr.find("TO:") {
905 let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
906 if let Some(semi_index) = addr.find(';') {
907 addr.get(..semi_index).unwrap_or_default().trim()
908 } else {
909 addr
910 }
911 } else {
912 bail!("Invalid MATMSG found");
913 };
914
915 let addr = normalize_address(addr)?;
916 let name = "";
917 Qr::from_address(context, name, &addr, None).await
918}
919
920static VCARD_NAME_RE: LazyLock<regex::Regex> =
921 LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
922static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
923 LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
924
925async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
929 let name = VCARD_NAME_RE
930 .captures(qr)
931 .and_then(|caps| {
932 let last_name = caps.get(1)?.as_str().trim();
933 let first_name = caps.get(2)?.as_str().trim();
934
935 Some(format!("{first_name} {last_name}"))
936 })
937 .unwrap_or_default();
938
939 let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
940 normalize_address(cap.as_str().trim())?
941 } else {
942 bail!("Bad e-mail address");
943 };
944
945 Qr::from_address(context, &name, &addr, None).await
946}
947
948impl Qr {
949 pub async fn from_address(
953 context: &Context,
954 name: &str,
955 addr: &str,
956 draft: Option<String>,
957 ) -> Result<Self> {
958 let addr = ContactAddress::new(addr)?;
959 let (contact_id, _) =
960 Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
961 Ok(Qr::Addr { contact_id, draft })
962 }
963}
964
965fn normalize_address(addr: &str) -> Result<String> {
967 let new_addr = percent_decode_str(addr).decode_utf8()?;
969 let new_addr = addr_normalize(&new_addr);
970
971 ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
972
973 Ok(new_addr.to_string())
974}
975
976#[cfg(test)]
977mod qr_tests;