diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 3f6bf0294..12d8347f0 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -101,7 +101,7 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String { } /// Parses `VcardContact`s from a given `&str`. -pub fn parse_vcard(vcard: &str) -> Result> { +pub fn parse_vcard(vcard: &str) -> Vec { fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { let start_of_s = s.get(..prefix.len())?; @@ -209,7 +209,7 @@ pub fn parse_vcard(vcard: &str) -> Result> { }); } - Ok(contacts) + contacts } /// Valid contact address. @@ -438,8 +438,7 @@ EMAIL;PREF=1:bobzzz@freenet.de UID:cac4fef4-6351-4854-bbe4-9b6df857eaed END:VCARD ", - ) - .unwrap(); + ); assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string()); assert_eq!(contacts[0].display_name, "Alice Mueller".to_string()); @@ -469,8 +468,7 @@ KEY;TYPE=PGP;ENCODING=b:[base64-data] REV:20240418T184242Z END:VCARD", - ) - .unwrap(); + ); assert_eq!(contacts[0].addr, "alice@example.com".to_string()); assert_eq!(contacts[0].display_name, "Alice Wonderland".to_string()); @@ -502,7 +500,7 @@ END:VCARD", for len in 0..=contacts.len() { let contacts = &contacts[0..len]; let vcard = make_vcard(contacts); - let parsed = parse_vcard(&vcard).unwrap(); + let parsed = parse_vcard(&vcard); assert_eq!(parsed.len(), contacts.len()); for i in 0..parsed.len() { assert_eq!(parsed[i].addr, contacts[i].addr); @@ -580,8 +578,7 @@ FN:Alice EMAIL;HOME:alice@example.org END:VCARD ", - ) - .unwrap(); + ); assert_eq!(contacts[0].addr, "bob@example.org".to_string()); assert_eq!(contacts[0].display_name, "Bob".to_string()); @@ -605,8 +602,7 @@ END:VCARD EMAIL;TYPE=work:alice@example.org\n\ REV:20240418T184242\n\ END:VCARD", - ) - .unwrap(); + ); assert_eq!(contacts.len(), 1); assert_eq!(contacts[0].addr, "alice@example.org".to_string()); assert_eq!(contacts[0].display_name, "Alice Wonderland".to_string()); diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 59732c18c..584e6939f 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -5480,6 +5480,11 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot); */ #define DC_MSG_WEBXDC 80 +/** + * Message containing shared contacts represented as a vCard (virtual contact file) + * with email addresses and possibly other fields. + */ +#define DC_MSG_VCARD 90 /** * @} @@ -7338,6 +7343,9 @@ void dc_event_unref(dc_event_t* event); /// Used as info message. #define DC_STR_SECUREJOIN_WAIT_TIMEOUT 191 +/// "Contact" +#define DC_STR_CONTACT 200 + /** * @} */ diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index d022a6235..8f020be7e 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1431,7 +1431,7 @@ impl CommandApi { async fn parse_vcard(&self, path: String) -> Result> { let vcard = tokio::fs::read(Path::new(&path)).await?; let vcard = str::from_utf8(&vcard)?; - Ok(deltachat_contact_tools::parse_vcard(vcard)? + Ok(deltachat_contact_tools::parse_vcard(vcard) .into_iter() .map(|c| c.into()) .collect()) diff --git a/deltachat-jsonrpc/src/api/types/contact.rs b/deltachat-jsonrpc/src/api/types/contact.rs index c3f25c038..8fc085699 100644 --- a/deltachat-jsonrpc/src/api/types/contact.rs +++ b/deltachat-jsonrpc/src/api/types/contact.rs @@ -94,6 +94,7 @@ impl ContactObject { pub struct VcardContact { /// Email address. addr: String, + /// The contact's name, or the email address if no name was given. display_name: String, /// Public PGP key in Base64. key: Option, diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index 05c8801ee..dd51f3fc7 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -274,6 +274,11 @@ pub enum MessageViewtype { /// Message is an webxdc instance. Webxdc, + + /// Message containing shared contacts represented as a vCard (virtual contact file) + /// with email addresses and possibly other fields. + /// Use `parse_vcard()` to retrieve them. + Vcard, } impl From for MessageViewtype { @@ -290,6 +295,7 @@ impl From for MessageViewtype { Viewtype::File => MessageViewtype::File, Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation, Viewtype::Webxdc => MessageViewtype::Webxdc, + Viewtype::Vcard => MessageViewtype::Vcard, } } } @@ -308,6 +314,7 @@ impl From for Viewtype { MessageViewtype::File => Viewtype::File, MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation, MessageViewtype::Webxdc => Viewtype::Webxdc, + MessageViewtype::Vcard => Viewtype::Vcard, } } } diff --git a/src/message.rs b/src/message.rs index 880c75935..1163b8d62 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1416,8 +1416,8 @@ pub(crate) fn guess_msgtype_from_suffix(path: &Path) -> Option<(Viewtype, &str)> "tif" => (Viewtype::File, "image/tiff"), "ttf" => (Viewtype::File, "font/ttf"), "txt" => (Viewtype::File, "text/plain"), - "vcard" => (Viewtype::File, "text/vcard"), - "vcf" => (Viewtype::File, "text/vcard"), + "vcard" => (Viewtype::Vcard, "text/vcard"), + "vcf" => (Viewtype::Vcard, "text/vcard"), "wav" => (Viewtype::File, "audio/wav"), "weba" => (Viewtype::File, "audio/webm"), "webm" => (Viewtype::Video, "video/webm"), @@ -1938,7 +1938,8 @@ pub enum Viewtype { Text = 10, /// Image message. - /// If the image is an animated GIF, the type DC_MSG_GIF should be used. + /// If the image is a GIF and has the appropriate extension, the viewtype is auto-changed to + /// `Gif` when sending the message. /// File, width and height are set via dc_msg_set_file(), dc_msg_set_dimension /// and retrieved via dc_msg_set_file(), dc_msg_set_dimension(). Image = 20, @@ -1982,6 +1983,11 @@ pub enum Viewtype { /// Message is an webxdc instance. Webxdc = 80, + + /// Message containing shared contacts represented as a vCard (virtual contact file) + /// with email addresses and possibly other fields. + /// Use `parse_vcard()` to retrieve them. + Vcard = 90, } impl Viewtype { @@ -1999,6 +2005,7 @@ impl Viewtype { Viewtype::File => true, Viewtype::VideochatInvitation => false, Viewtype::Webxdc => true, + Viewtype::Vcard => true, } } } @@ -2512,6 +2519,7 @@ mod tests { Viewtype::from_i32(70).unwrap() ); assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap()); + assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/mimeparser.rs b/src/mimeparser.rs index af4f86b0d..93f212ea8 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -600,6 +600,7 @@ impl MimeMessage { | Viewtype::Audio | Viewtype::Voice | Viewtype::Video + | Viewtype::Vcard | Viewtype::File | Viewtype::Webxdc => true, Viewtype::Unknown | Viewtype::Text | Viewtype::VideochatInvitation => false, @@ -1933,16 +1934,11 @@ fn get_mime_type( let mimetype = mail.ctype.mimetype.parse::()?; let viewtype = match mimetype.type_() { - mime::TEXT => { - if !is_attachment_disposition(mail) { - match mimetype.subtype() { - mime::PLAIN | mime::HTML => Viewtype::Text, - _ => Viewtype::File, - } - } else { - Viewtype::File - } - } + mime::TEXT => match mimetype.subtype() { + mime::VCARD if is_valid_deltachat_vcard(mail) => Viewtype::Vcard, + mime::PLAIN | mime::HTML if !is_attachment_disposition(mail) => Viewtype::Text, + _ => Viewtype::File, + }, mime::IMAGE => match mimetype.subtype() { mime::GIF => Viewtype::Gif, mime::SVG => Viewtype::File, @@ -1990,6 +1986,17 @@ fn is_attachment_disposition(mail: &mailparse::ParsedMail<'_>) -> bool { .any(|(key, _value)| key.starts_with("filename")) } +fn is_valid_deltachat_vcard(mail: &mailparse::ParsedMail) -> bool { + let Ok(body) = &mail.get_body() else { + return false; + }; + let contacts = deltachat_contact_tools::parse_vcard(body); + if let [c] = &contacts[..] { + return deltachat_contact_tools::may_be_valid_addr(&c.addr); + } + false +} + /// Tries to get attachment filename. /// /// If filename is explicitly specified in Content-Disposition, it is diff --git a/src/receive_imf/tests.rs b/src/receive_imf/tests.rs index 216a7ed94..e3d7a03db 100644 --- a/src/receive_imf/tests.rs +++ b/src/receive_imf/tests.rs @@ -4552,3 +4552,58 @@ async fn test_list_from() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_receive_vcard() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + for vcard_contains_address in [true, false] { + let mut msg = Message::new(Viewtype::Vcard); + msg.set_file_from_bytes( + &alice, + "claire.vcf", + format!( + "BEGIN:VCARD\n\ + VERSION:4.0\n\ + FN:Claire\n\ + {}\ + END:VCARD", + if vcard_contains_address { + "EMAIL;TYPE=work:claire@example.org\n" + } else { + "" + } + ) + .as_bytes(), + None, + ) + .await + .unwrap(); + + let alice_bob_chat = alice.create_chat(&bob).await; + let sent = alice.send_msg(alice_bob_chat.id, &mut msg).await; + let rcvd = bob.recv_msg(&sent).await; + + if vcard_contains_address { + assert_eq!(rcvd.viewtype, Viewtype::Vcard); + } else { + // VCards without an email address are not "deltachat contacts", + // so they are shown as files + assert_eq!(rcvd.viewtype, Viewtype::File); + } + + let vcard = tokio::fs::read(rcvd.get_file(&bob).unwrap()).await?; + let vcard = std::str::from_utf8(&vcard)?; + let parsed = deltachat_contact_tools::parse_vcard(vcard); + assert_eq!(parsed.len(), 1); + if vcard_contains_address { + assert_eq!(&parsed[0].addr, "claire@example.org"); + } else { + assert_eq!(&parsed[0].addr, ""); + } + } + + Ok(()) +} diff --git a/src/stock_str.rs b/src/stock_str.rs index 984a658f8..2d7da1043 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -443,6 +443,9 @@ pub enum StockMessage { fallback = "Could not yet establish guaranteed end-to-end encryption, but you may already send a message." ))] SecurejoinWaitTimeout = 191, + + #[strum(props(fallback = "Contact"))] + Contact = 200, } impl StockMessage { @@ -1098,6 +1101,11 @@ pub(crate) async fn videochat_invite_msg_body(context: &Context, url: &str) -> S .replace1(url) } +/// Stock string: `Contact`. +pub(crate) async fn contact(context: &Context) -> String { + translated(context, StockMessage::Contact).await +} + /// Stock string: `Error:\n\nā€œ%1$sā€`. pub(crate) async fn configuration_failed(context: &Context, details: &str) -> String { translated(context, StockMessage::ConfigurationFailed) diff --git a/src/summary.rs b/src/summary.rs index cf087ee4a..9fe63530b 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use std::fmt; +use std::str; use crate::chat::Chat; use crate::constants::Chattype; @@ -228,6 +229,12 @@ impl Message { ); append_text = true; } + Viewtype::Vcard => { + emoji = Some("šŸ‘¤"); + type_name = Some(stock_str::contact(context).await); + type_file = None; + append_text = true; + } Viewtype::Text | Viewtype::Unknown => { emoji = None; if self.param.get_cmd() == SystemMessage::LocationOnly { @@ -340,10 +347,6 @@ mod tests { msg.set_file("foo.mp3", None); assert_summary_texts(&msg, ctx, "šŸŽµ foo.mp3").await; // file name is added for audio - let mut msg = Message::new(Viewtype::Audio); - msg.set_file("foo.mp3", None); - assert_summary_texts(&msg, ctx, "šŸŽµ foo.mp3").await; // file name is added for audio, empty text is not added - let mut msg = Message::new(Viewtype::Audio); msg.set_text(some_text.clone()); msg.set_file("foo.mp3", None); @@ -363,6 +366,27 @@ mod tests { msg.set_file("foo.bar", None); assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations + let mut msg = Message::new(Viewtype::Vcard); + msg.set_file("foo.vcf", None); + assert_summary_texts(&msg, ctx, "šŸ‘¤ Contact").await; + msg.set_text(some_text.clone()); + assert_summary_texts(&msg, ctx, "šŸ‘¤ bla bla").await; + + let mut msg = Message::new(Viewtype::Vcard); + msg.set_file_from_bytes( + ctx, + "alice.vcf", + b"BEGIN:VCARD\n\ + VERSION:4.0\n\ + FN:Alice Wonderland\n\ + EMAIL;TYPE=work:alice@example.org\n\ + END:VCARD", + None, + ) + .await + .unwrap(); + assert_summary_texts(&msg, ctx, "šŸ‘¤ Contact").await; + // Forwarded let mut msg = Message::new(Viewtype::Text); msg.set_text(some_text.clone());