1mod dclogin_scheme;
4use std::collections::BTreeMap;
5use std::sync::LazyLock;
6
7use anyhow::{anyhow, bail, ensure, Context as _, Result};
8pub use dclogin_scheme::LoginOptions;
9use deltachat_contact_tools::{addr_normalize, may_be_valid_addr, ContactAddress};
10use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC};
11use serde::Deserialize;
12
13pub(crate) use self::dclogin_scheme::configure_from_login_qr;
14use crate::config::Config;
15use crate::contact::{Contact, ContactId, Origin};
16use crate::context::Context;
17use crate::events::EventType;
18use crate::key::Fingerprint;
19use crate::message::Message;
20use crate::net::http::post_empty;
21use crate::net::proxy::{ProxyConfig, DEFAULT_SOCKS_PORT};
22use crate::token;
23use crate::tools::validate_id;
24
25const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
27const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#";
28const DCACCOUNT_SCHEME: &str = "DCACCOUNT:";
29pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:";
30const DCWEBRTC_SCHEME: &str = "DCWEBRTC:";
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 = 3;
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 FprOk {
91 contact_id: ContactId,
93 },
94
95 FprMismatch {
97 contact_id: Option<ContactId>,
99 },
100
101 FprWithoutAddr {
103 fingerprint: String,
105 },
106
107 Account {
109 domain: String,
111 },
112
113 Backup2 {
115 node_addr: iroh::NodeAddr,
117
118 auth_token: String,
120 },
121
122 BackupTooNew {},
124
125 WebrtcInstance {
127 domain: String,
129
130 instance_pattern: String,
132 },
133
134 Proxy {
144 url: String,
148
149 host: String,
151
152 port: u16,
154 },
155
156 Addr {
161 contact_id: ContactId,
163
164 draft: Option<String>,
166 },
167
168 Url {
172 url: String,
174 },
175
176 Text {
180 text: String,
182 },
183
184 WithdrawVerifyContact {
186 contact_id: ContactId,
188
189 fingerprint: Fingerprint,
191
192 invitenumber: String,
194
195 authcode: String,
197 },
198
199 WithdrawVerifyGroup {
201 grpname: String,
203
204 grpid: String,
206
207 contact_id: ContactId,
209
210 fingerprint: Fingerprint,
212
213 invitenumber: String,
215
216 authcode: String,
218 },
219
220 ReviveVerifyContact {
222 contact_id: ContactId,
224
225 fingerprint: Fingerprint,
227
228 invitenumber: String,
230
231 authcode: String,
233 },
234
235 ReviveVerifyGroup {
237 grpname: String,
239
240 grpid: String,
242
243 contact_id: ContactId,
245
246 fingerprint: Fingerprint,
248
249 invitenumber: String,
251
252 authcode: String,
254 },
255
256 Login {
260 address: String,
262
263 options: LoginOptions,
265 },
266}
267
268fn fix_add_second_device_qr(qr: &str) -> String {
271 qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
272 .replacen(r#""]}}"#, r#""]}"#, 1)
273}
274
275fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
276 string.to_lowercase().starts_with(&pattern.to_lowercase())
277}
278
279pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
284 let qr = qr.trim();
285 let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
286 decode_openpgp(context, qr)
287 .await
288 .context("failed to decode OPENPGP4FPR QR code")?
289 } else if qr.starts_with(IDELTACHAT_SCHEME) {
290 decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
291 } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
292 decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
293 } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
294 decode_account(qr)?
295 } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
296 dclogin_scheme::decode_login(qr)?
297 } else if starts_with_ignore_case(qr, DCWEBRTC_SCHEME) {
298 decode_webrtc_instance(context, qr)?
299 } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
300 decode_tg_socks_proxy(context, qr)?
301 } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
302 decode_shadowsocks_proxy(qr)?
303 } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
304 let qr_fixed = fix_add_second_device_qr(qr);
305 decode_backup2(&qr_fixed)?
306 } else if qr.starts_with(MAILTO_SCHEME) {
307 decode_mailto(context, qr).await?
308 } else if qr.starts_with(SMTP_SCHEME) {
309 decode_smtp(context, qr).await?
310 } else if qr.starts_with(MATMSG_SCHEME) {
311 decode_matmsg(context, qr).await?
312 } else if qr.starts_with(VCARD_SCHEME) {
313 decode_vcard(context, qr).await?
314 } else if let Ok(url) = url::Url::parse(qr) {
315 match url.scheme() {
316 "socks5" => Qr::Proxy {
317 url: qr.to_string(),
318 host: url.host_str().context("URL has no host")?.to_string(),
319 port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
320 },
321 "http" | "https" => {
322 let url = if let Some(rest) = qr.strip_prefix("http://") {
326 url::Url::parse(&format!("foobarbaz://{rest}"))?
327 } else if let Some(rest) = qr.strip_prefix("https://") {
328 url::Url::parse(&format!("foobarbaz://{rest}"))?
329 } else {
330 url
332 };
333
334 if url.port().is_none() | (url.path() != "") | url.query().is_some() {
335 Qr::Url {
337 url: qr.to_string(),
338 }
339 } else {
340 Qr::Proxy {
341 url: qr.to_string(),
342 host: url.host_str().context("URL has no host")?.to_string(),
343 port: url
344 .port_or_known_default()
345 .context("HTTP(S) URLs are guaranteed to return Some port")?,
346 }
347 }
348 }
349 _ => Qr::Url {
350 url: qr.to_string(),
351 },
352 }
353 } else {
354 Qr::Text {
355 text: qr.to_string(),
356 }
357 };
358 Ok(qrcode)
359}
360
361pub fn format_backup(qr: &Qr) -> Result<String> {
368 match qr {
369 Qr::Backup2 {
370 ref node_addr,
371 ref auth_token,
372 } => {
373 let node_addr = serde_json::to_string(node_addr)?;
374 Ok(format!(
375 "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
376 ))
377 }
378 _ => Err(anyhow!("Not a backup QR code")),
379 }
380}
381
382async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
386 let payload = qr
387 .get(OPENPGP4FPR_SCHEME.len()..)
388 .context("Invalid OPENPGP4FPR scheme")?;
389
390 let (fingerprint, fragment) = match payload
393 .split_once('#')
394 .or_else(|| payload.split_once("%23"))
395 {
396 Some(pair) => pair,
397 None => (payload, ""),
398 };
399 let fingerprint: Fingerprint = fingerprint
400 .parse()
401 .context("Failed to parse fingerprint in the QR code")?;
402
403 let param: BTreeMap<&str, &str> = fragment
404 .split('&')
405 .filter_map(|s| {
406 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
407 Some((key, value))
408 } else {
409 None
410 }
411 })
412 .collect();
413
414 let addr = if let Some(addr) = param.get("a") {
415 Some(normalize_address(addr)?)
416 } else {
417 None
418 };
419
420 let name = if let Some(encoded_name) = param.get("n") {
421 let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
423 Ok(name) => name.to_string(),
424 Err(err) => bail!("Invalid name: {}", err),
425 }
426 } else {
427 "".to_string()
428 };
429
430 let invitenumber = param
431 .get("i")
432 .filter(|&s| validate_id(s))
433 .map(|s| s.to_string());
434 let authcode = param
435 .get("s")
436 .filter(|&s| validate_id(s))
437 .map(|s| s.to_string());
438 let grpid = param
439 .get("x")
440 .filter(|&s| validate_id(s))
441 .map(|s| s.to_string());
442
443 let grpname = if grpid.is_some() {
444 if let Some(encoded_name) = param.get("g") {
445 let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
447 Ok(name) => Some(name.to_string()),
448 Err(err) => bail!("Invalid group name: {}", err),
449 }
450 } else {
451 None
452 }
453 } else {
454 None
455 };
456
457 if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
458 let addr = ContactAddress::new(addr)?;
459 let (contact_id, _) = Contact::add_or_lookup_ex(
460 context,
461 &name,
462 &addr,
463 &fingerprint.hex(),
464 Origin::UnhandledSecurejoinQrScan,
465 )
466 .await
467 .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
468
469 if let (Some(grpid), Some(grpname)) = (grpid, grpname) {
470 if context
471 .is_self_addr(&addr)
472 .await
473 .with_context(|| format!("can't check if address {addr:?} is our address"))?
474 {
475 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
476 Ok(Qr::WithdrawVerifyGroup {
477 grpname,
478 grpid,
479 contact_id,
480 fingerprint,
481 invitenumber,
482 authcode,
483 })
484 } else {
485 Ok(Qr::ReviveVerifyGroup {
486 grpname,
487 grpid,
488 contact_id,
489 fingerprint,
490 invitenumber,
491 authcode,
492 })
493 }
494 } else {
495 Ok(Qr::AskVerifyGroup {
496 grpname,
497 grpid,
498 contact_id,
499 fingerprint,
500 invitenumber,
501 authcode,
502 })
503 }
504 } else if context.is_self_addr(&addr).await? {
505 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
506 Ok(Qr::WithdrawVerifyContact {
507 contact_id,
508 fingerprint,
509 invitenumber,
510 authcode,
511 })
512 } else {
513 Ok(Qr::ReviveVerifyContact {
514 contact_id,
515 fingerprint,
516 invitenumber,
517 authcode,
518 })
519 }
520 } else {
521 Ok(Qr::AskVerifyContact {
522 contact_id,
523 fingerprint,
524 invitenumber,
525 authcode,
526 })
527 }
528 } else if let Some(addr) = addr {
529 let fingerprint = fingerprint.hex();
530 let (contact_id, _) =
531 Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan)
532 .await?;
533 let contact = Contact::get_by_id(context, contact_id).await?;
534
535 if contact.public_key(context).await?.is_some() {
536 Ok(Qr::FprOk { contact_id })
537 } else {
538 Ok(Qr::FprMismatch {
539 contact_id: Some(contact_id),
540 })
541 }
542 } else {
543 Ok(Qr::FprWithoutAddr {
544 fingerprint: fingerprint.to_string(),
545 })
546 }
547}
548
549async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
551 let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
552 let qr = qr.replacen('&', "#", 1);
553 decode_openpgp(context, &qr)
554 .await
555 .with_context(|| format!("failed to decode {prefix} QR code"))
556}
557
558fn decode_account(qr: &str) -> Result<Qr> {
560 let payload = qr
561 .get(DCACCOUNT_SCHEME.len()..)
562 .context("Invalid DCACCOUNT payload")?;
563 let url = url::Url::parse(payload).context("Invalid account URL")?;
564 if url.scheme() == "http" || url.scheme() == "https" {
565 Ok(Qr::Account {
566 domain: url
567 .host_str()
568 .context("can't extract account setup domain")?
569 .to_string(),
570 })
571 } else {
572 bail!("Bad scheme for account URL: {:?}.", url.scheme());
573 }
574}
575
576fn decode_webrtc_instance(_context: &Context, qr: &str) -> Result<Qr> {
578 let payload = qr
579 .get(DCWEBRTC_SCHEME.len()..)
580 .context("Invalid DCWEBRTC payload")?;
581
582 let (_type, url) = Message::parse_webrtc_instance(payload);
583 let url = url::Url::parse(&url).context("Invalid WebRTC instance")?;
584
585 if url.scheme() == "http" || url.scheme() == "https" {
586 Ok(Qr::WebrtcInstance {
587 domain: url
588 .host_str()
589 .context("can't extract WebRTC instance domain")?
590 .to_string(),
591 instance_pattern: payload.to_string(),
592 })
593 } else {
594 bail!("Bad URL scheme for WebRTC instance: {:?}", url.scheme());
595 }
596}
597
598fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
600 let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
601
602 let mut host: Option<String> = None;
603 let mut port: u16 = DEFAULT_SOCKS_PORT;
604 let mut user: Option<String> = None;
605 let mut pass: Option<String> = None;
606 for (key, value) in url.query_pairs() {
607 if key == "server" {
608 host = Some(value.to_string());
609 } else if key == "port" {
610 port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
611 } else if key == "user" {
612 user = Some(value.to_string());
613 } else if key == "pass" {
614 pass = Some(value.to_string());
615 }
616 }
617
618 let Some(host) = host else {
619 bail!("Bad t.me/socks url: {:?}", url);
620 };
621
622 let mut url = "socks5://".to_string();
623 if let Some(pass) = pass {
624 url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
625 url += ":";
626 url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
627 url += "@";
628 };
629 url += &host;
630 url += ":";
631 url += &port.to_string();
632
633 Ok(Qr::Proxy { url, host, port })
634}
635
636fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
638 let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
639 let addr = server_config.addr();
640 let host = addr.host().to_string();
641 let port = addr.port();
642 Ok(Qr::Proxy {
643 url: qr.to_string(),
644 host,
645 port,
646 })
647}
648
649fn decode_backup2(qr: &str) -> Result<Qr> {
651 let version_and_payload = qr
652 .strip_prefix(DCBACKUP_SCHEME_PREFIX)
653 .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
654 let (version, payload) = version_and_payload
655 .split_once(':')
656 .context("DCBACKUP scheme separator missing")?;
657 let version: i32 = version.parse().context("Not a valid number")?;
658 if version > DCBACKUP_VERSION {
659 return Ok(Qr::BackupTooNew {});
660 }
661
662 let (auth_token, node_addr) = payload
663 .split_once('&')
664 .context("Backup QR code has no separator")?;
665 let auth_token = auth_token.to_string();
666 let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
667 .context("Invalid node addr in backup QR code")?;
668
669 Ok(Qr::Backup2 {
670 node_addr,
671 auth_token,
672 })
673}
674
675#[derive(Debug, Deserialize)]
676struct CreateAccountSuccessResponse {
677 email: String,
679
680 password: String,
682}
683#[derive(Debug, Deserialize)]
684struct CreateAccountErrorResponse {
685 reason: String,
687}
688
689pub(crate) async fn set_account_from_qr(context: &Context, qr: &str) -> Result<()> {
693 let url_str = qr
694 .get(DCACCOUNT_SCHEME.len()..)
695 .context("Invalid DCACCOUNT scheme")?;
696
697 if !url_str.starts_with(HTTPS_SCHEME) {
698 bail!("DCACCOUNT QR codes must use HTTPS scheme");
699 }
700
701 let (response_text, response_success) = post_empty(context, url_str).await?;
702 if response_success {
703 let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
704 .with_context(|| {
705 format!("Cannot create account, response is malformed:\n{response_text:?}")
706 })?;
707 context
708 .set_config_internal(Config::Addr, Some(&email))
709 .await?;
710 context
711 .set_config_internal(Config::MailPw, Some(&password))
712 .await?;
713
714 Ok(())
715 } else {
716 match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
717 Ok(error) => Err(anyhow!(error.reason)),
718 Err(parse_error) => {
719 context.emit_event(EventType::Error(format!(
720 "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
721 )));
722 bail!(
723 "Cannot create account, unexpected server response:\n{:?}",
724 response_text
725 )
726 }
727 }
728 }
729}
730
731pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
733 match check_qr(context, qr).await? {
734 Qr::Account { .. } => set_account_from_qr(context, qr).await?,
735 Qr::WebrtcInstance {
736 domain: _,
737 instance_pattern,
738 } => {
739 context
740 .set_config_internal(Config::WebrtcInstance, Some(&instance_pattern))
741 .await?;
742 }
743 Qr::Proxy { url, .. } => {
744 let old_proxy_url_value = context
745 .get_config(Config::ProxyUrl)
746 .await?
747 .unwrap_or_default();
748
749 let url = ProxyConfig::from_url(&url)?.to_url();
751
752 let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
753 .chain(
754 old_proxy_url_value
755 .split('\n')
756 .filter(|s| !s.is_empty() && *s != url),
757 )
758 .collect();
759 context
760 .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
761 .await?;
762 context.set_config_bool(Config::ProxyEnabled, true).await?;
763 }
764 Qr::WithdrawVerifyContact {
765 invitenumber,
766 authcode,
767 ..
768 } => {
769 token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
770 token::delete(context, token::Namespace::Auth, &authcode).await?;
771 context
772 .sync_qr_code_token_deletion(invitenumber, authcode)
773 .await?;
774 }
775 Qr::WithdrawVerifyGroup {
776 invitenumber,
777 authcode,
778 ..
779 } => {
780 token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
781 token::delete(context, token::Namespace::Auth, &authcode).await?;
782 context
783 .sync_qr_code_token_deletion(invitenumber, authcode)
784 .await?;
785 }
786 Qr::ReviveVerifyContact {
787 invitenumber,
788 authcode,
789 ..
790 } => {
791 token::save(context, token::Namespace::InviteNumber, None, &invitenumber).await?;
792 token::save(context, token::Namespace::Auth, None, &authcode).await?;
793 context.sync_qr_code_tokens(None).await?;
794 context.scheduler.interrupt_inbox().await;
795 }
796 Qr::ReviveVerifyGroup {
797 invitenumber,
798 authcode,
799 grpid,
800 ..
801 } => {
802 token::save(
803 context,
804 token::Namespace::InviteNumber,
805 Some(&grpid),
806 &invitenumber,
807 )
808 .await?;
809 token::save(context, token::Namespace::Auth, Some(&grpid), &authcode).await?;
810 context.sync_qr_code_tokens(Some(&grpid)).await?;
811 context.scheduler.interrupt_inbox().await;
812 }
813 Qr::Login { address, options } => {
814 configure_from_login_qr(context, &address, options).await?
815 }
816 _ => bail!("QR code does not contain config"),
817 }
818
819 Ok(())
820}
821
822async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
826 let payload = qr
827 .get(MAILTO_SCHEME.len()..)
828 .context("Invalid mailto: scheme")?;
829
830 let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
831
832 let param: BTreeMap<&str, &str> = query
833 .split('&')
834 .filter_map(|s| {
835 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
836 Some((key, value))
837 } else {
838 None
839 }
840 })
841 .collect();
842
843 let subject = if let Some(subject) = param.get("subject") {
844 subject.to_string()
845 } else {
846 "".to_string()
847 };
848 let draft = if let Some(body) = param.get("body") {
849 if subject.is_empty() {
850 body.to_string()
851 } else {
852 subject + "\n" + body
853 }
854 } else {
855 subject
856 };
857 let draft = draft.replace('+', "%20"); let draft = match percent_decode_str(&draft).decode_utf8() {
859 Ok(decoded_draft) => decoded_draft.to_string(),
860 Err(_err) => draft,
861 };
862
863 let addr = normalize_address(addr)?;
864 let name = "";
865 Qr::from_address(
866 context,
867 name,
868 &addr,
869 if draft.is_empty() { None } else { Some(draft) },
870 )
871 .await
872}
873
874async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
878 let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
879
880 let (addr, _rest) = payload
881 .split_once(':')
882 .context("Invalid SMTP scheme payload")?;
883 let addr = normalize_address(addr)?;
884 let name = "";
885 Qr::from_address(context, name, &addr, None).await
886}
887
888async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
894 let addr = if let Some(to_index) = qr.find("TO:") {
897 let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
898 if let Some(semi_index) = addr.find(';') {
899 addr.get(..semi_index).unwrap_or_default().trim()
900 } else {
901 addr
902 }
903 } else {
904 bail!("Invalid MATMSG found");
905 };
906
907 let addr = normalize_address(addr)?;
908 let name = "";
909 Qr::from_address(context, name, &addr, None).await
910}
911
912static VCARD_NAME_RE: LazyLock<regex::Regex> =
913 LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
914static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
915 LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
916
917async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
921 let name = VCARD_NAME_RE
922 .captures(qr)
923 .and_then(|caps| {
924 let last_name = caps.get(1)?.as_str().trim();
925 let first_name = caps.get(2)?.as_str().trim();
926
927 Some(format!("{first_name} {last_name}"))
928 })
929 .unwrap_or_default();
930
931 let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
932 normalize_address(cap.as_str().trim())?
933 } else {
934 bail!("Bad e-mail address");
935 };
936
937 Qr::from_address(context, &name, &addr, None).await
938}
939
940impl Qr {
941 pub async fn from_address(
945 context: &Context,
946 name: &str,
947 addr: &str,
948 draft: Option<String>,
949 ) -> Result<Self> {
950 let addr = ContactAddress::new(addr)?;
951 let (contact_id, _) =
952 Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
953 Ok(Qr::Addr { contact_id, draft })
954 }
955}
956
957fn normalize_address(addr: &str) -> Result<String> {
959 let new_addr = percent_decode_str(addr).decode_utf8()?;
961 let new_addr = addr_normalize(&new_addr);
962
963 ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
964
965 Ok(new_addr.to_string())
966}
967
968#[cfg(test)]
969mod qr_tests;