deltachat/
html.rs

1//! # Get message as HTML.
2//!
3//! Use `Message.has_html()` to check if the UI shall render a
4//! corresponding button and `MsgId.get_html()` to get the full message.
5//!
6//! Even when the original mime-message is not HTML,
7//! `MsgId.get_html()` will return HTML -
8//! this allows nice quoting, handling linebreaks properly etc.
9
10use std::mem;
11
12use anyhow::{Context as _, Result};
13use base64::Engine as _;
14use mailparse::ParsedContentType;
15use mime::Mime;
16
17use crate::context::Context;
18use crate::headerdef::{HeaderDef, HeaderDefMap};
19use crate::log::warn;
20use crate::message::{self, Message, MsgId};
21use crate::mimeparser::parse_message_id;
22use crate::param::Param::SendHtml;
23use crate::plaintext::PlainText;
24
25impl Message {
26    /// Check if the message can be retrieved as HTML.
27    /// Typically, this is the case, when the mime structure of a Message is modified,
28    /// meaning that some text is cut or the original message
29    /// is in HTML and `simplify()` may hide some maybe important information.
30    /// The corresponding ffi-function is `dc_msg_has_html()`.
31    /// To get the HTML-code of the message, use `MsgId.get_html()`.
32    pub fn has_html(&self) -> bool {
33        self.mime_modified
34    }
35
36    /// Set HTML-part part of a message that is about to be sent.
37    /// The HTML-part is written to the database before sending and
38    /// used as the `text/html` part in the MIME-structure.
39    ///
40    /// Received HTML parts are handled differently,
41    /// they are saved together with the whole MIME-structure
42    /// in `mime_headers` and the HTML-part is extracted using `MsgId::get_html()`.
43    /// (To underline this asynchronicity, we are using the wording "SendHtml")
44    pub fn set_html(&mut self, html: Option<String>) {
45        if let Some(html) = html {
46            self.param.set(SendHtml, html);
47            self.mime_modified = true;
48        } else {
49            self.param.remove(SendHtml);
50            self.mime_modified = false;
51        }
52    }
53}
54
55/// Type defining a rough mime-type.
56/// This is mainly useful on iterating
57/// to decide whether a mime-part has subtypes.
58enum MimeMultipartType {
59    Multiple,
60    Single,
61    Message,
62}
63
64/// Function takes a content type from a ParsedMail structure
65/// and checks and returns the rough mime-type.
66fn get_mime_multipart_type(ctype: &ParsedContentType) -> MimeMultipartType {
67    let mimetype = ctype.mimetype.to_lowercase();
68    if mimetype.starts_with("multipart") && ctype.params.contains_key("boundary") {
69        MimeMultipartType::Multiple
70    } else if mimetype == "message/rfc822" {
71        MimeMultipartType::Message
72    } else {
73        MimeMultipartType::Single
74    }
75}
76
77/// HtmlMsgParser converts a mime-message to HTML.
78#[derive(Debug)]
79struct HtmlMsgParser {
80    pub html: String,
81    pub plain: Option<PlainText>,
82    pub(crate) msg_html: String,
83}
84
85impl HtmlMsgParser {
86    /// Function takes a raw mime-message string,
87    /// searches for the main-text part
88    /// and returns that as parser.html
89    pub fn from_bytes<'a>(
90        context: &Context,
91        rawmime: &'a [u8],
92    ) -> Result<(Self, mailparse::ParsedMail<'a>)> {
93        let mut parser = HtmlMsgParser {
94            html: "".to_string(),
95            plain: None,
96            msg_html: "".to_string(),
97        };
98
99        let parsedmail = mailparse::parse_mail(rawmime).context("Failed to parse mail")?;
100
101        parser.collect_texts_recursive(context, &parsedmail)?;
102
103        if parser.html.is_empty() {
104            if let Some(plain) = &parser.plain {
105                parser.html = plain.to_html();
106            }
107        } else {
108            parser.cid_to_data_recursive(context, &parsedmail)?;
109        }
110        parser.html += &mem::take(&mut parser.msg_html);
111        Ok((parser, parsedmail))
112    }
113
114    /// Function iterates over all mime-parts
115    /// and searches for text/plain and text/html parts and saves the
116    /// first one found.
117    /// in the corresponding structure fields.
118    ///
119    /// Usually, there is at most one plain-text and one HTML-text part,
120    /// multiple plain-text parts might be used for mailinglist-footers,
121    /// therefore we use the first one.
122    fn collect_texts_recursive<'a>(
123        &'a mut self,
124        context: &'a Context,
125        mail: &'a mailparse::ParsedMail<'a>,
126    ) -> Result<()> {
127        match get_mime_multipart_type(&mail.ctype) {
128            MimeMultipartType::Multiple => {
129                for cur_data in &mail.subparts {
130                    self.collect_texts_recursive(context, cur_data)?
131                }
132                Ok(())
133            }
134            MimeMultipartType::Message => {
135                let raw = mail.get_body_raw()?;
136                if raw.is_empty() {
137                    return Ok(());
138                }
139                let (parser, mail) = HtmlMsgParser::from_bytes(context, &raw)?;
140                if !parser.html.is_empty() {
141                    let mut text = "\r\n\r\n".to_string();
142                    for h in mail.headers {
143                        let key = h.get_key();
144                        if matches!(
145                            key.to_lowercase().as_str(),
146                            "date"
147                                | "from"
148                                | "sender"
149                                | "reply-to"
150                                | "to"
151                                | "cc"
152                                | "bcc"
153                                | "subject"
154                        ) {
155                            text += &format!("{key}: {}\r\n", h.get_value());
156                        }
157                    }
158                    text += "\r\n";
159                    self.msg_html += &PlainText {
160                        text,
161                        flowed: false,
162                        delsp: false,
163                    }
164                    .to_html();
165                    self.msg_html += &parser.html;
166                }
167                Ok(())
168            }
169            MimeMultipartType::Single => {
170                let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
171                if mimetype == mime::TEXT_HTML {
172                    if self.html.is_empty()
173                        && let Ok(decoded_data) = mail.get_body()
174                    {
175                        self.html = decoded_data;
176                    }
177                } else if mimetype == mime::TEXT_PLAIN
178                    && self.plain.is_none()
179                    && let Ok(decoded_data) = mail.get_body()
180                {
181                    self.plain = Some(PlainText {
182                        text: decoded_data,
183                        flowed: if let Some(format) = mail.ctype.params.get("format") {
184                            format.as_str().eq_ignore_ascii_case("flowed")
185                        } else {
186                            false
187                        },
188                        delsp: if let Some(delsp) = mail.ctype.params.get("delsp") {
189                            delsp.as_str().eq_ignore_ascii_case("yes")
190                        } else {
191                            false
192                        },
193                    });
194                }
195                Ok(())
196            }
197        }
198    }
199
200    /// Replace cid:-protocol by the data:-protocol where appropriate.
201    /// This allows the final html-file to be self-contained.
202    fn cid_to_data_recursive<'a>(
203        &'a mut self,
204        context: &'a Context,
205        mail: &'a mailparse::ParsedMail<'a>,
206    ) -> Result<()> {
207        match get_mime_multipart_type(&mail.ctype) {
208            MimeMultipartType::Multiple => {
209                for cur_data in &mail.subparts {
210                    self.cid_to_data_recursive(context, cur_data)?;
211                }
212                Ok(())
213            }
214            MimeMultipartType::Message => Ok(()),
215            MimeMultipartType::Single => {
216                let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
217                if mimetype.type_() == mime::IMAGE
218                    && let Some(cid) = mail.headers.get_header_value(HeaderDef::ContentId)
219                    && let Ok(cid) = parse_message_id(&cid)
220                    && let Ok(replacement) = mimepart_to_data_url(mail)
221                {
222                    let re_string = format!(
223                        "(<img[^>]*src[^>]*=[^>]*)(cid:{})([^>]*>)",
224                        regex::escape(&cid)
225                    );
226                    match regex::Regex::new(&re_string) {
227                        Ok(re) => {
228                            self.html = re
229                                .replace_all(
230                                    &self.html,
231                                    format!("${{1}}{replacement}${{3}}").as_str(),
232                                )
233                                .as_ref()
234                                .to_string()
235                        }
236                        Err(e) => warn!(
237                            context,
238                            "Cannot create regex for cid: {} throws {}", re_string, e
239                        ),
240                    }
241                }
242                Ok(())
243            }
244        }
245    }
246}
247
248/// Convert a mime part to a data: url as defined in [RFC 2397](https://tools.ietf.org/html/rfc2397).
249fn mimepart_to_data_url(mail: &mailparse::ParsedMail<'_>) -> Result<String> {
250    let data = mail.get_body_raw()?;
251    let data = base64::engine::general_purpose::STANDARD.encode(data);
252    Ok(format!("data:{};base64,{}", mail.ctype.mimetype, data))
253}
254
255impl MsgId {
256    /// Get HTML by database message id.
257    /// Returns `Some` at least if `Message.has_html()` is true.
258    /// NB: we do not save raw mime unconditionally in the database to save space.
259    /// The corresponding ffi-function is `dc_get_msg_html()`.
260    pub async fn get_html(self, context: &Context) -> Result<Option<String>> {
261        // If there are many concurrent db readers, going to the queue earlier makes sense.
262        let (param, rawmime) = tokio::join!(
263            self.get_param(context),
264            message::get_mime_headers(context, self)
265        );
266        if let Some(html) = param?.get(SendHtml) {
267            return Ok(Some(html.to_string()));
268        }
269
270        let rawmime = rawmime?;
271        if !rawmime.is_empty() {
272            match HtmlMsgParser::from_bytes(context, &rawmime) {
273                Err(err) => {
274                    warn!(context, "get_html: parser error: {:#}", err);
275                    Ok(None)
276                }
277                Ok((parser, _)) => Ok(Some(parser.html)),
278            }
279        } else {
280            warn!(context, "get_html: no mime for {}", self);
281            Ok(None)
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::chat::{self, Chat, forward_msgs, save_msgs};
290    use crate::constants;
291    use crate::contact::ContactId;
292    use crate::message::{MessengerMessage, Viewtype};
293    use crate::receive_imf::receive_imf;
294    use crate::test_utils::{TestContext, TestContextManager};
295
296    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
297    async fn test_htmlparse_plain_unspecified() {
298        let t = TestContext::new().await;
299        let raw = include_bytes!("../test-data/message/text_plain_unspecified.eml");
300        let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
301        assert_eq!(
302            parser.html,
303            r#"<!DOCTYPE html>
304<html><head>
305<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
306<meta name="color-scheme" content="light dark" />
307</head><body>
308This message does not have Content-Type nor Subject.<br/>
309</body></html>
310"#
311        );
312    }
313
314    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
315    async fn test_htmlparse_plain_iso88591() {
316        let t = TestContext::new().await;
317        let raw = include_bytes!("../test-data/message/text_plain_iso88591.eml");
318        let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
319        assert_eq!(
320            parser.html,
321            r#"<!DOCTYPE html>
322<html><head>
323<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
324<meta name="color-scheme" content="light dark" />
325</head><body>
326message with a non-UTF-8 encoding: äöüßÄÖÜ<br/>
327</body></html>
328"#
329        );
330    }
331
332    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
333    async fn test_htmlparse_plain_flowed() {
334        let t = TestContext::new().await;
335        let raw = include_bytes!("../test-data/message/text_plain_flowed.eml");
336        let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
337        assert!(parser.plain.unwrap().flowed);
338        assert_eq!(
339            parser.html,
340            r#"<!DOCTYPE html>
341<html><head>
342<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
343<meta name="color-scheme" content="light dark" />
344</head><body>
345This line ends with a space and will be merged with the next one due to format=flowed.<br/>
346<br/>
347This line does not end with a space<br/>
348and will be wrapped as usual.<br/>
349</body></html>
350"#
351        );
352    }
353
354    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
355    async fn test_htmlparse_alt_plain() {
356        let t = TestContext::new().await;
357        let raw = include_bytes!("../test-data/message/text_alt_plain.eml");
358        let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
359        assert_eq!(
360            parser.html,
361            r#"<!DOCTYPE html>
362<html><head>
363<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
364<meta name="color-scheme" content="light dark" />
365</head><body>
366mime-modified should not be set set as there is no html and no special stuff;<br/>
367although not being a delta-message.<br/>
368test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x27; :)<br/>
369</body></html>
370"#
371        );
372    }
373
374    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
375    async fn test_htmlparse_html() {
376        let t = TestContext::new().await;
377        let raw = include_bytes!("../test-data/message/text_html.eml");
378        let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
379
380        // on windows, `\r\n` linends are returned from mimeparser,
381        // however, rust multiline-strings use just `\n`;
382        // therefore, we just remove `\r` before comparison.
383        assert_eq!(
384            parser.html.replace('\r', ""),
385            r##"
386<html>
387  <p>mime-modified <b>set</b>; simplify is always regarded as lossy.</p>
388</html>"##
389        );
390    }
391
392    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
393    async fn test_htmlparse_alt_html() {
394        let t = TestContext::new().await;
395        let raw = include_bytes!("../test-data/message/text_alt_html.eml");
396        let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
397        assert_eq!(
398            parser.html.replace('\r', ""), // see comment in test_htmlparse_html()
399            r##"<html>
400  <p>mime-modified <b>set</b>; simplify is always regarded as lossy.</p>
401</html>
402"##
403        );
404    }
405
406    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
407    async fn test_htmlparse_alt_plain_html() {
408        let t = TestContext::new().await;
409        let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
410        let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
411        assert_eq!(
412            parser.html.replace('\r', ""), // see comment in test_htmlparse_html()
413            r##"<html>
414  <p>
415    this is <b>html</b>
416  </p>
417</html>
418"##
419        );
420    }
421
422    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
423    async fn test_htmlparse_apple_cid_jpg() {
424        // load raw mime html-data with related image-part (cid:)
425        // and make sure, Content-Id has angle-brackets that are removed correctly.
426        let t = TestContext::new().await;
427        let raw = include_bytes!("../test-data/message/apple_cid_jpg.eml");
428        let test = String::from_utf8_lossy(raw);
429        assert!(test.contains("Content-Id: <8AE052EF-BC90-486F-BB78-58D3590308EC@fritz.box>"));
430        assert!(test.contains("cid:8AE052EF-BC90-486F-BB78-58D3590308EC@fritz.box"));
431        assert!(test.find("data:").is_none());
432
433        // parsing converts cid: to data:
434        let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
435        assert!(parser.html.contains("<html>"));
436        assert!(!parser.html.contains("Content-Id:"));
437        assert!(parser.html.contains("data:image/jpeg;base64,/9j/4AAQ"));
438        assert!(!parser.html.contains("cid:"));
439    }
440
441    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
442    async fn test_get_html_invalid_msgid() {
443        let t = TestContext::new().await;
444        let msg_id = MsgId::new(100);
445        assert!(msg_id.get_html(&t).await.is_err())
446    }
447
448    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
449    async fn test_html_forwarding() -> Result<()> {
450        // alice receives a non-delta html-message
451        let mut tcm = TestContextManager::new();
452        let alice = &tcm.alice().await;
453        let chat = alice
454            .create_chat_with_contact("", "sender@testrun.org")
455            .await;
456        let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
457        receive_imf(alice, raw, false).await.unwrap();
458        let msg = alice.get_last_msg_in(chat.get_id()).await;
459        assert_ne!(msg.get_from_id(), ContactId::SELF);
460        assert_eq!(msg.is_dc_message, MessengerMessage::No);
461        assert!(!msg.is_forwarded());
462        assert!(msg.get_text().contains("this is plain"));
463        assert!(msg.has_html());
464        let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
465        assert!(html.contains("this is <b>html</b>"));
466
467        // alice: create chat with bob and forward received html-message there
468        let chat_alice = alice.create_chat_with_contact("", "bob@example.net").await;
469        forward_msgs(alice, &[msg.get_id()], chat_alice.get_id())
470            .await
471            .unwrap();
472        async fn check_sender(ctx: &TestContext, chat: &Chat) {
473            let msg = ctx.get_last_msg_in(chat.get_id()).await;
474            assert_eq!(msg.get_from_id(), ContactId::SELF);
475            assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
476            assert!(msg.is_forwarded());
477            assert!(msg.get_text().contains("this is plain"));
478            assert!(msg.has_html());
479            let html = msg.get_id().get_html(ctx).await.unwrap().unwrap();
480            assert!(html.contains("this is <b>html</b>"));
481        }
482        check_sender(alice, &chat_alice).await;
483
484        // bob: check that bob also got the html-part of the forwarded message
485        let bob = &tcm.bob().await;
486        let chat_bob = bob.create_chat_with_contact("", "alice@example.org").await;
487        async fn check_receiver(ctx: &TestContext, chat: &Chat, sender: &TestContext) {
488            let msg = ctx.recv_msg(&sender.pop_sent_msg().await).await;
489            assert_eq!(chat.id, msg.chat_id);
490            assert_ne!(msg.get_from_id(), ContactId::SELF);
491            assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
492            assert!(msg.is_forwarded());
493            assert!(msg.get_text().contains("this is plain"));
494            assert!(msg.has_html());
495            let html = msg.get_id().get_html(ctx).await.unwrap().unwrap();
496            assert!(html.contains("this is <b>html</b>"));
497        }
498        check_receiver(bob, &chat_bob, alice).await;
499
500        // Let's say that the alice and bob profiles are on the same device,
501        // so alice can forward the message to herself via bob profile!
502        chat::forward_msgs_2ctx(alice, &[msg.get_id()], bob, chat_bob.get_id()).await?;
503        check_sender(bob, &chat_bob).await;
504        check_receiver(alice, &chat_alice, bob).await;
505
506        // Check cross-profile forwarding of long outgoing messages.
507        let line = "this text with 42 chars is just repeated.\n";
508        let long_txt = line.repeat(constants::DC_DESIRED_TEXT_LEN / line.len() + 2);
509        let mut msg = Message::new_text(long_txt);
510        alice.send_msg(chat_alice.id, &mut msg).await;
511        let msg = alice.get_last_msg_in(chat_alice.id).await;
512        assert!(msg.has_html());
513        let html = msg.id.get_html(alice).await?.unwrap();
514        chat::forward_msgs_2ctx(alice, &[msg.get_id()], bob, chat_bob.get_id()).await?;
515        let msg = bob.get_last_msg_in(chat_bob.id).await;
516        assert!(msg.has_html());
517        assert_eq!(msg.id.get_html(bob).await?.unwrap(), html);
518        Ok(())
519    }
520
521    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
522    async fn test_html_save_msg() -> Result<()> {
523        // Alice receives a non-delta html-message
524        let alice = TestContext::new_alice().await;
525        let chat = alice
526            .create_chat_with_contact("", "sender@testrun.org")
527            .await;
528        let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
529        receive_imf(&alice, raw, false).await?;
530        let msg = alice.get_last_msg_in(chat.get_id()).await;
531
532        // Alice saves the message
533        let self_chat = alice.get_self_chat().await;
534        save_msgs(&alice, &[msg.id]).await?;
535        let saved_msg = alice.get_last_msg_in(self_chat.get_id()).await;
536        assert_ne!(saved_msg.id, msg.id);
537        assert_eq!(
538            saved_msg.get_original_msg_id(&alice).await?.unwrap(),
539            msg.id
540        );
541        assert!(!saved_msg.is_forwarded()); // UI should not flag "saved messages" as "forwarded"
542        assert_ne!(saved_msg.get_from_id(), ContactId::SELF);
543        assert_eq!(saved_msg.get_from_id(), msg.get_from_id());
544        assert_eq!(saved_msg.is_dc_message, MessengerMessage::No);
545        assert!(saved_msg.get_text().contains("this is plain"));
546        assert!(saved_msg.has_html());
547        let html = saved_msg.get_id().get_html(&alice).await?.unwrap();
548        assert!(html.contains("this is <b>html</b>"));
549
550        Ok(())
551    }
552
553    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
554    async fn test_html_forwarding_encrypted() {
555        let mut tcm = TestContextManager::new();
556        // Alice receives a non-delta html-message
557        let alice = &tcm.alice().await;
558        let chat = alice
559            .create_chat_with_contact("", "sender@testrun.org")
560            .await;
561        let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
562        receive_imf(alice, raw, false).await.unwrap();
563        let msg = alice.get_last_msg_in(chat.get_id()).await;
564
565        // forward the message to saved-messages,
566        // this will encrypt the message as new_alice() has set up keys
567        let chat = alice.get_self_chat().await;
568        forward_msgs(alice, &[msg.get_id()], chat.get_id())
569            .await
570            .unwrap();
571        let msg = alice.pop_sent_msg().await;
572
573        // receive the message on another device
574        let alice = &tcm.alice().await;
575        let msg = alice.recv_msg(&msg).await;
576        assert_eq!(msg.chat_id, alice.get_self_chat().await.id);
577        assert_eq!(msg.get_from_id(), ContactId::SELF);
578        assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
579        assert!(msg.get_showpadlock());
580        assert!(msg.is_forwarded());
581        assert!(msg.get_text().contains("this is plain"));
582        assert!(msg.has_html());
583        let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
584        assert!(html.contains("this is <b>html</b>"));
585    }
586
587    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
588    async fn test_set_html() {
589        let mut tcm = TestContextManager::new();
590        let alice = &tcm.alice().await;
591        let bob = &tcm.bob().await;
592
593        // alice sends a message with html-part to bob
594        let chat_id = alice.create_chat(bob).await.id;
595        let mut msg = Message::new_text("plain text".to_string());
596        msg.set_html(Some("<b>html</b> text".to_string()));
597        assert!(msg.mime_modified);
598        chat::send_msg(alice, chat_id, &mut msg).await.unwrap();
599
600        // check the message is written correctly to alice's db
601        let msg = alice.get_last_msg_in(chat_id).await;
602        assert_eq!(msg.get_text(), "plain text");
603        assert!(!msg.is_forwarded());
604        assert!(msg.mime_modified);
605        let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
606        assert!(html.contains("<b>html</b> text"));
607
608        // let bob receive the message
609        let chat_id = bob.create_chat(alice).await.id;
610        let msg = bob.recv_msg(&alice.pop_sent_msg().await).await;
611        assert_eq!(msg.chat_id, chat_id);
612        assert_eq!(msg.get_text(), "plain text");
613        assert!(!msg.is_forwarded());
614        assert!(msg.mime_modified);
615        let html = msg.get_id().get_html(bob).await.unwrap().unwrap();
616        assert!(html.contains("<b>html</b> text"));
617    }
618
619    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
620    async fn test_cp1252_html() -> Result<()> {
621        let t = TestContext::new_alice().await;
622        receive_imf(
623            &t,
624            include_bytes!("../test-data/message/cp1252-html.eml"),
625            false,
626        )
627        .await?;
628        let msg = t.get_last_msg().await;
629        assert_eq!(msg.viewtype, Viewtype::Text);
630        assert!(msg.text.contains("foo bar ä ö ü ß"));
631        assert!(msg.has_html());
632        let html = msg.get_id().get_html(&t).await?.unwrap();
633        println!("{html}");
634        assert!(html.contains("foo bar ä ö ü ß"));
635        Ok(())
636    }
637}