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 AskJoinBroadcast {
90 name: String,
92
93 grpid: String,
99
100 contact_id: ContactId,
102
103 fingerprint: Fingerprint,
105
106 invitenumber: String,
108 authcode: String,
110 },
111
112 FprOk {
116 contact_id: ContactId,
118 },
119
120 FprMismatch {
122 contact_id: Option<ContactId>,
124 },
125
126 FprWithoutAddr {
128 fingerprint: String,
130 },
131
132 Account {
134 domain: String,
136 },
137
138 Backup2 {
140 node_addr: iroh::NodeAddr,
142
143 auth_token: String,
145 },
146
147 BackupTooNew {},
149
150 Proxy {
160 url: String,
164
165 host: String,
167
168 port: u16,
170 },
171
172 Addr {
177 contact_id: ContactId,
179
180 draft: Option<String>,
182 },
183
184 Url {
188 url: String,
190 },
191
192 Text {
196 text: String,
198 },
199
200 WithdrawVerifyContact {
202 contact_id: ContactId,
204
205 fingerprint: Fingerprint,
207
208 invitenumber: String,
210
211 authcode: String,
213 },
214
215 WithdrawVerifyGroup {
217 grpname: String,
219
220 grpid: String,
222
223 contact_id: ContactId,
225
226 fingerprint: Fingerprint,
228
229 invitenumber: String,
231
232 authcode: String,
234 },
235
236 ReviveVerifyContact {
238 contact_id: ContactId,
240
241 fingerprint: Fingerprint,
243
244 invitenumber: String,
246
247 authcode: String,
249 },
250
251 ReviveVerifyGroup {
253 grpname: String,
255
256 grpid: String,
258
259 contact_id: ContactId,
261
262 fingerprint: Fingerprint,
264
265 invitenumber: String,
267
268 authcode: String,
270 },
271
272 Login {
276 address: String,
278
279 options: LoginOptions,
281 },
282}
283
284fn fix_add_second_device_qr(qr: &str) -> String {
287 qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
288 .replacen(r#""]}}"#, r#""]}"#, 1)
289}
290
291fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
292 string.to_lowercase().starts_with(&pattern.to_lowercase())
293}
294
295pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
300 let qr = qr.trim();
301 let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
302 decode_openpgp(context, qr)
303 .await
304 .context("failed to decode OPENPGP4FPR QR code")?
305 } else if qr.starts_with(IDELTACHAT_SCHEME) {
306 decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
307 } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
308 decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
309 } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
310 decode_account(qr)?
311 } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
312 dclogin_scheme::decode_login(qr)?
313 } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
314 decode_tg_socks_proxy(context, qr)?
315 } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
316 decode_shadowsocks_proxy(qr)?
317 } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
318 let qr_fixed = fix_add_second_device_qr(qr);
319 decode_backup2(&qr_fixed)?
320 } else if qr.starts_with(MAILTO_SCHEME) {
321 decode_mailto(context, qr).await?
322 } else if qr.starts_with(SMTP_SCHEME) {
323 decode_smtp(context, qr).await?
324 } else if qr.starts_with(MATMSG_SCHEME) {
325 decode_matmsg(context, qr).await?
326 } else if qr.starts_with(VCARD_SCHEME) {
327 decode_vcard(context, qr).await?
328 } else if let Ok(url) = url::Url::parse(qr) {
329 match url.scheme() {
330 "socks5" => Qr::Proxy {
331 url: qr.to_string(),
332 host: url.host_str().context("URL has no host")?.to_string(),
333 port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
334 },
335 "http" | "https" => {
336 let url = if let Some(rest) = qr.strip_prefix("http://") {
340 url::Url::parse(&format!("foobarbaz://{rest}"))?
341 } else if let Some(rest) = qr.strip_prefix("https://") {
342 url::Url::parse(&format!("foobarbaz://{rest}"))?
343 } else {
344 url
346 };
347
348 if url.port().is_none() | (url.path() != "") | url.query().is_some() {
349 Qr::Url {
351 url: qr.to_string(),
352 }
353 } else {
354 Qr::Proxy {
355 url: qr.to_string(),
356 host: url.host_str().context("URL has no host")?.to_string(),
357 port: url
358 .port_or_known_default()
359 .context("HTTP(S) URLs are guaranteed to return Some port")?,
360 }
361 }
362 }
363 _ => Qr::Url {
364 url: qr.to_string(),
365 },
366 }
367 } else {
368 Qr::Text {
369 text: qr.to_string(),
370 }
371 };
372 Ok(qrcode)
373}
374
375pub fn format_backup(qr: &Qr) -> Result<String> {
382 match qr {
383 Qr::Backup2 {
384 node_addr,
385 auth_token,
386 } => {
387 let node_addr = serde_json::to_string(node_addr)?;
388 Ok(format!(
389 "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
390 ))
391 }
392 _ => Err(anyhow!("Not a backup QR code")),
393 }
394}
395
396async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
401 let payload = qr
402 .get(OPENPGP4FPR_SCHEME.len()..)
403 .context("Invalid OPENPGP4FPR scheme")?;
404
405 let (fingerprint, fragment) = match payload
408 .split_once('#')
409 .or_else(|| payload.split_once("%23"))
410 {
411 Some(pair) => pair,
412 None => (payload, ""),
413 };
414 let fingerprint: Fingerprint = fingerprint
415 .parse()
416 .context("Failed to parse fingerprint in the QR code")?;
417
418 let param: BTreeMap<&str, &str> = fragment
419 .split('&')
420 .filter_map(|s| {
421 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
422 Some((key, value))
423 } else {
424 None
425 }
426 })
427 .collect();
428
429 let addr = if let Some(addr) = param.get("a") {
430 Some(normalize_address(addr)?)
431 } else {
432 None
433 };
434
435 let name = decode_name(¶m, "n")?.unwrap_or_default();
436
437 let invitenumber = param
438 .get("i")
439 .or_else(|| param.get("j"))
441 .filter(|&s| validate_id(s))
442 .map(|s| s.to_string());
443 let authcode = param
444 .get("s")
445 .filter(|&s| validate_id(s))
446 .map(|s| s.to_string());
447 let grpid = param
448 .get("x")
449 .filter(|&s| validate_id(s))
450 .map(|s| s.to_string());
451
452 let grpname = decode_name(¶m, "g")?;
453 let broadcast_name = decode_name(¶m, "b")?;
454
455 if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
456 let addr = ContactAddress::new(addr)?;
457 let (contact_id, _) = Contact::add_or_lookup_ex(
458 context,
459 &name,
460 &addr,
461 &fingerprint.hex(),
462 Origin::UnhandledSecurejoinQrScan,
463 )
464 .await
465 .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
466
467 if let (Some(grpid), Some(grpname)) = (grpid.clone(), grpname) {
468 if context
469 .is_self_addr(&addr)
470 .await
471 .with_context(|| format!("can't check if address {addr:?} is our address"))?
472 {
473 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
474 Ok(Qr::WithdrawVerifyGroup {
475 grpname,
476 grpid,
477 contact_id,
478 fingerprint,
479 invitenumber,
480 authcode,
481 })
482 } else {
483 Ok(Qr::ReviveVerifyGroup {
484 grpname,
485 grpid,
486 contact_id,
487 fingerprint,
488 invitenumber,
489 authcode,
490 })
491 }
492 } else {
493 Ok(Qr::AskVerifyGroup {
494 grpname,
495 grpid,
496 contact_id,
497 fingerprint,
498 invitenumber,
499 authcode,
500 })
501 }
502 } else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
503 Ok(Qr::AskJoinBroadcast {
504 name,
505 grpid,
506 contact_id,
507 fingerprint,
508 invitenumber,
509 authcode,
510 })
511 } else if context.is_self_addr(&addr).await? {
512 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
513 Ok(Qr::WithdrawVerifyContact {
514 contact_id,
515 fingerprint,
516 invitenumber,
517 authcode,
518 })
519 } else {
520 Ok(Qr::ReviveVerifyContact {
521 contact_id,
522 fingerprint,
523 invitenumber,
524 authcode,
525 })
526 }
527 } else {
528 Ok(Qr::AskVerifyContact {
529 contact_id,
530 fingerprint,
531 invitenumber,
532 authcode,
533 })
534 }
535 } else if let Some(addr) = addr {
536 let fingerprint = fingerprint.hex();
537 let (contact_id, _) =
538 Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan)
539 .await?;
540 let contact = Contact::get_by_id(context, contact_id).await?;
541
542 if contact.public_key(context).await?.is_some() {
543 Ok(Qr::FprOk { contact_id })
544 } else {
545 Ok(Qr::FprMismatch {
546 contact_id: Some(contact_id),
547 })
548 }
549 } else {
550 Ok(Qr::FprWithoutAddr {
551 fingerprint: fingerprint.to_string(),
552 })
553 }
554}
555
556fn decode_name(param: &BTreeMap<&str, &str>, key: &str) -> Result<Option<String>> {
557 if let Some(encoded_name) = param.get(key) {
558 let encoded_name = encoded_name.replace('+', "%20"); let mut name = match percent_decode_str(&encoded_name).decode_utf8() {
560 Ok(name) => name.to_string(),
561 Err(err) => bail!("Invalid QR param {key}: {err}"),
562 };
563 if let Some(n) = name.strip_suffix('_') {
564 name = format!("{n}…");
565 }
566 Ok(Some(name))
567 } else {
568 Ok(None)
569 }
570}
571
572async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
574 let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
575 let qr = qr.replacen('&', "#", 1);
576 decode_openpgp(context, &qr)
577 .await
578 .with_context(|| format!("failed to decode {prefix} QR code"))
579}
580
581fn decode_account(qr: &str) -> Result<Qr> {
585 let payload = qr
586 .get(DCACCOUNT_SCHEME.len()..)
587 .context("Invalid DCACCOUNT payload")?;
588 if payload.starts_with("https://") {
589 let url = url::Url::parse(payload).context("Invalid account URL")?;
590 if url.scheme() == "https" {
591 Ok(Qr::Account {
592 domain: url
593 .host_str()
594 .context("can't extract account setup domain")?
595 .to_string(),
596 })
597 } else {
598 bail!("Bad scheme for account URL: {:?}.", url.scheme());
599 }
600 } else {
601 Ok(Qr::Account {
602 domain: payload.to_string(),
603 })
604 }
605}
606
607fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
609 let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
610
611 let mut host: Option<String> = None;
612 let mut port: u16 = DEFAULT_SOCKS_PORT;
613 let mut user: Option<String> = None;
614 let mut pass: Option<String> = None;
615 for (key, value) in url.query_pairs() {
616 if key == "server" {
617 host = Some(value.to_string());
618 } else if key == "port" {
619 port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
620 } else if key == "user" {
621 user = Some(value.to_string());
622 } else if key == "pass" {
623 pass = Some(value.to_string());
624 }
625 }
626
627 let Some(host) = host else {
628 bail!("Bad t.me/socks url: {url:?}");
629 };
630
631 let mut url = "socks5://".to_string();
632 if let Some(pass) = pass {
633 url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
634 url += ":";
635 url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
636 url += "@";
637 };
638 url += &host;
639 url += ":";
640 url += &port.to_string();
641
642 Ok(Qr::Proxy { url, host, port })
643}
644
645fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
647 let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
648 let addr = server_config.addr();
649 let host = addr.host().to_string();
650 let port = addr.port();
651 Ok(Qr::Proxy {
652 url: qr.to_string(),
653 host,
654 port,
655 })
656}
657
658fn decode_backup2(qr: &str) -> Result<Qr> {
660 let version_and_payload = qr
661 .strip_prefix(DCBACKUP_SCHEME_PREFIX)
662 .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
663 let (version, payload) = version_and_payload
664 .split_once(':')
665 .context("DCBACKUP scheme separator missing")?;
666 let version: i32 = version.parse().context("Not a valid number")?;
667 if version > DCBACKUP_VERSION {
668 return Ok(Qr::BackupTooNew {});
669 }
670
671 let (auth_token, node_addr) = payload
672 .split_once('&')
673 .context("Backup QR code has no separator")?;
674 let auth_token = auth_token.to_string();
675 let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
676 .context("Invalid node addr in backup QR code")?;
677
678 Ok(Qr::Backup2 {
679 node_addr,
680 auth_token,
681 })
682}
683
684#[derive(Debug, Deserialize)]
685struct CreateAccountSuccessResponse {
686 email: String,
688
689 password: String,
691}
692#[derive(Debug, Deserialize)]
693struct CreateAccountErrorResponse {
694 reason: String,
696}
697
698pub(crate) async fn login_param_from_account_qr(
702 context: &Context,
703 qr: &str,
704) -> Result<EnteredLoginParam> {
705 let payload = qr
706 .get(DCACCOUNT_SCHEME.len()..)
707 .context("Invalid DCACCOUNT scheme")?;
708
709 if !payload.starts_with(HTTPS_SCHEME) {
710 let rng = &mut rand::rngs::OsRng.unwrap_err();
711 let username = Alphanumeric.sample_string(rng, 9);
712 let addr = username + "@" + payload;
713 let password = Alphanumeric.sample_string(rng, 50);
714
715 let param = EnteredLoginParam {
716 addr,
717 imap: EnteredServerLoginParam {
718 password,
719 ..Default::default()
720 },
721 smtp: Default::default(),
722 certificate_checks: EnteredCertificateChecks::Strict,
723 oauth2: false,
724 };
725 return Ok(param);
726 }
727
728 let (response_text, response_success) = post_empty(context, payload).await?;
729 if response_success {
730 let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
731 .with_context(|| {
732 format!("Cannot create account, response is malformed:\n{response_text:?}")
733 })?;
734
735 let param = EnteredLoginParam {
736 addr: email,
737 imap: EnteredServerLoginParam {
738 password,
739 ..Default::default()
740 },
741 smtp: Default::default(),
742 certificate_checks: EnteredCertificateChecks::Strict,
743 oauth2: false,
744 };
745
746 Ok(param)
747 } else {
748 match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
749 Ok(error) => Err(anyhow!(error.reason)),
750 Err(parse_error) => {
751 context.emit_event(EventType::Error(format!(
752 "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
753 )));
754 bail!("Cannot create account, unexpected server response:\n{response_text:?}")
755 }
756 }
757 }
758}
759
760pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
762 match check_qr(context, qr).await? {
763 Qr::Account { .. } => {
764 let mut param = login_param_from_account_qr(context, qr).await?;
765 context.add_transport_inner(&mut param).await?
766 }
767 Qr::Proxy { url, .. } => {
768 let old_proxy_url_value = context
769 .get_config(Config::ProxyUrl)
770 .await?
771 .unwrap_or_default();
772
773 let url = ProxyConfig::from_url(&url)?.to_url();
775
776 let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
777 .chain(
778 old_proxy_url_value
779 .split('\n')
780 .filter(|s| !s.is_empty() && *s != url),
781 )
782 .collect();
783 context
784 .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
785 .await?;
786 context.set_config_bool(Config::ProxyEnabled, true).await?;
787 }
788 Qr::WithdrawVerifyContact {
789 invitenumber,
790 authcode,
791 ..
792 } => {
793 token::delete(context, "").await?;
794 context
795 .sync_qr_code_token_deletion(invitenumber, authcode)
796 .await?;
797 }
798 Qr::WithdrawVerifyGroup {
799 grpid,
800 invitenumber,
801 authcode,
802 ..
803 } => {
804 token::delete(context, &grpid).await?;
805 context
806 .sync_qr_code_token_deletion(invitenumber, authcode)
807 .await?;
808 }
809 Qr::ReviveVerifyContact {
810 invitenumber,
811 authcode,
812 ..
813 } => {
814 let timestamp = time();
815 token::save(
816 context,
817 token::Namespace::InviteNumber,
818 None,
819 &invitenumber,
820 timestamp,
821 )
822 .await?;
823 token::save(context, token::Namespace::Auth, None, &authcode, timestamp).await?;
824 context.sync_qr_code_tokens(None).await?;
825 context.scheduler.interrupt_inbox().await;
826 }
827 Qr::ReviveVerifyGroup {
828 invitenumber,
829 authcode,
830 grpid,
831 ..
832 } => {
833 let timestamp = time();
834 token::save(
835 context,
836 token::Namespace::InviteNumber,
837 Some(&grpid),
838 &invitenumber,
839 timestamp,
840 )
841 .await?;
842 token::save(
843 context,
844 token::Namespace::Auth,
845 Some(&grpid),
846 &authcode,
847 timestamp,
848 )
849 .await?;
850 context.sync_qr_code_tokens(Some(&grpid)).await?;
851 context.scheduler.interrupt_inbox().await;
852 }
853 Qr::Login { address, options } => {
854 let mut param = login_param_from_login_qr(&address, options)?;
855 context.add_transport_inner(&mut param).await?
856 }
857 _ => bail!("QR code does not contain config"),
858 }
859
860 Ok(())
861}
862
863async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
867 let payload = qr
868 .get(MAILTO_SCHEME.len()..)
869 .context("Invalid mailto: scheme")?;
870
871 let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
872
873 let param: BTreeMap<&str, &str> = query
874 .split('&')
875 .filter_map(|s| {
876 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
877 Some((key, value))
878 } else {
879 None
880 }
881 })
882 .collect();
883
884 let subject = if let Some(subject) = param.get("subject") {
885 subject.to_string()
886 } else {
887 "".to_string()
888 };
889 let draft = if let Some(body) = param.get("body") {
890 if subject.is_empty() {
891 body.to_string()
892 } else {
893 subject + "\n" + body
894 }
895 } else {
896 subject
897 };
898 let draft = draft.replace('+', "%20"); let draft = match percent_decode_str(&draft).decode_utf8() {
900 Ok(decoded_draft) => decoded_draft.to_string(),
901 Err(_err) => draft,
902 };
903
904 let addr = normalize_address(addr)?;
905 let name = "";
906 Qr::from_address(
907 context,
908 name,
909 &addr,
910 if draft.is_empty() { None } else { Some(draft) },
911 )
912 .await
913}
914
915async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
919 let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
920
921 let (addr, _rest) = payload
922 .split_once(':')
923 .context("Invalid SMTP scheme payload")?;
924 let addr = normalize_address(addr)?;
925 let name = "";
926 Qr::from_address(context, name, &addr, None).await
927}
928
929async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
935 let addr = if let Some(to_index) = qr.find("TO:") {
938 let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
939 if let Some(semi_index) = addr.find(';') {
940 addr.get(..semi_index).unwrap_or_default().trim()
941 } else {
942 addr
943 }
944 } else {
945 bail!("Invalid MATMSG found");
946 };
947
948 let addr = normalize_address(addr)?;
949 let name = "";
950 Qr::from_address(context, name, &addr, None).await
951}
952
953static VCARD_NAME_RE: LazyLock<regex::Regex> =
954 LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
955static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
956 LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
957
958async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
962 let name = VCARD_NAME_RE
963 .captures(qr)
964 .and_then(|caps| {
965 let last_name = caps.get(1)?.as_str().trim();
966 let first_name = caps.get(2)?.as_str().trim();
967
968 Some(format!("{first_name} {last_name}"))
969 })
970 .unwrap_or_default();
971
972 let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
973 normalize_address(cap.as_str().trim())?
974 } else {
975 bail!("Bad e-mail address");
976 };
977
978 Qr::from_address(context, &name, &addr, None).await
979}
980
981impl Qr {
982 pub async fn from_address(
986 context: &Context,
987 name: &str,
988 addr: &str,
989 draft: Option<String>,
990 ) -> Result<Self> {
991 let addr = ContactAddress::new(addr)?;
992 let (contact_id, _) =
993 Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
994 Ok(Qr::Addr { contact_id, draft })
995 }
996}
997
998fn normalize_address(addr: &str) -> Result<String> {
1000 let new_addr = percent_decode_str(addr).decode_utf8()?;
1002 let new_addr = addr_normalize(&new_addr);
1003
1004 ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
1005
1006 Ok(new_addr.to_string())
1007}
1008
1009#[cfg(test)]
1010mod qr_tests;