Compare commits

..
Author SHA1 Message Date
B. Petersen 69816965ed feat: add chat_export() api 2026-10-28 12:52:10 +01:00
20 changed files with 1258 additions and 85 deletions
-47
View File
@@ -1,51 +1,5 @@
# Changelog
## [2.66.0] - 2026-10-28
### API-Changes
- [**breaking**] remove `webxdc_realtime_enabled` config.
### Fixes
- mark messages seen and queue read receipts in one transaction.
- fix the order of arguments to `merge_openpgp_certificates()` call.
- ignore `html/*` MIME parts.
### Performance
- avoid some full table scans on every IMAP loop iteration and sent message.
### Documentation
- document arguments to `secret_key_to_public_key()`.
### Refactor
- pass current timestamp as an argument to `signature_subpackets()`.
- factor out `Contact.relay_addresses()`.
- scheduler: do not wait for IMAP and SMTP loop to be started.
- move expiration and IMAP loop queries into constants.
- make `update_msg_state()` synchronous and take a transaction.
- make `MsgId::start_ephemeral_timer()` synchronous.
- split a sync `insert_imap_markseen()` out of `markseen_on_imap_table()`.
- read messages inside the `markseen_msgs()` transaction.
- smtp: handle closed interrupt channel explicitly.
- [**breaking**] remove `configured_addr` config.
### Tests
- add `test_merge_openpgp_certificates_tiebreak()`.
- add `test_import_public_key_tiebreak()`.
### Miscellaneous Tasks
- update yerpc 0.7.0 to 0.7.1 ([#8868](https://github.com/chatmail/core/pull/8868)).
- update clipboard-win from 5.4.0 to 5.4.3.
- upgrade tokio from 1.53.1 to 1.53.3.
- add deny.toml exception for RUSTSEC-2026-0387.
- update preloaded DNS cache.
## [2.65.0] - 2026-10-22
### Features / Changes
@@ -9097,4 +9051,3 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.63.0]: https://github.com/chatmail/core/compare/v2.62.0..v2.63.0
[2.64.0]: https://github.com/chatmail/core/compare/v2.63.0..v2.64.0
[2.65.0]: https://github.com/chatmail/core/compare/v2.64.0..v2.65.0
[2.66.0]: https://github.com/chatmail/core/compare/v2.65.0..v2.66.0
Generated
+26 -26
View File
@@ -334,13 +334,13 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.92"
version = "0.1.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 2.0.118",
]
[[package]]
@@ -1334,7 +1334,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.67.0-dev"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.67.0-dev"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1461,14 +1461,14 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc-bindings"
version = "2.67.0-dev"
version = "2.66.0-dev"
dependencies = [
"deltachat-jsonrpc",
]
[[package]]
name = "deltachat-repl"
version = "2.67.0-dev"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1484,7 +1484,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.67.0-dev"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1514,7 +1514,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.67.0-dev"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -4683,25 +4683,25 @@ dependencies = [
]
[[package]]
name = "proc-macro-error-attr3"
version = "3.1.1"
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error3"
version = "3.1.1"
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr3",
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 2.0.118",
]
[[package]]
@@ -6619,9 +6619,9 @@ checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "typescript-type-def"
version = "0.5.14"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36c186188fb72fdcb201ef6e7dc9e5bde757d59b98bc5ccaa377eb8fbd555cf3"
checksum = "233ee5e596f41dbaf8c3e48a60b128eadf89395ee9e45fcedc3281c077c5a014"
dependencies = [
"serde_json",
"typescript-type-def-derive",
@@ -6629,13 +6629,13 @@ dependencies = [
[[package]]
name = "typescript-type-def-derive"
version = "0.5.14"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a0410908b502c50432b4581eeb58688ecb36efceabc2e02d34b69be22708120"
checksum = "912e6d6fed61bac90cd957093b11b5330d756ad4e7b8f309f71ae04b546a8513"
dependencies = [
"darling",
"ident_case",
"proc-macro-error3",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -7530,9 +7530,9 @@ dependencies = [
[[package]]
name = "yerpc"
version = "0.7.1"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7436d4f59711e0a796697630a77c33981c4b5406bdd52e874a08b27ad938ee"
checksum = "6924db1f1011f1d22566c45c7090aa66c3107c3c463ca32beaa8b2327127040a"
dependencies = [
"anyhow",
"async-channel 1.9.0",
@@ -7550,9 +7550,9 @@ dependencies = [
[[package]]
name = "yerpc_derive"
version = "0.7.1"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "feab3bc81a5743c3661099ef6727b182a27c32686b78831e143786b636b32fee"
checksum = "42c374248c189b15a7f0660db3984e9dcc790ec15f4b44e932e92be49de829f9"
dependencies = [
"convert_case",
"darling",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.67.0-dev"
version = "2.66.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
+168
View File
@@ -0,0 +1,168 @@
/* common */
body {
font-family: sans-serif;
background: #e6dcd4;
overflow-wrap: anywhere;
}
.chat {
max-width: 800px;
margin: 0 auto;
}
.avatar {
flex: none;
width: 36px; height: 36px;
border-radius: 50%;
object-fit: cover;
color: #fff;
font-weight: bold;
line-height: 36px;
text-align: center;
}
.text, .description {
line-height: 1.3em;
}
/* header */
.header {
background: #fff;
border-radius: 16px;
margin-top: 2em;
padding: 1em;
}
.header .avatar {
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.header h1 {
display: inline;
vertical-align: middle;
margin: 0;
font-size: 1.5em;
}
.header .description {
margin-top: 0.8em;
}
/* messages */
.msg {
margin: 0.8em 0;
}
.in, .out {
display: flex;
align-items: flex-end;
gap: 8px;
}
.out {
flex-direction: row-reverse;
}
.info, .day {
display: table;
margin: 1em auto;
padding: 7px 14px;
border-radius: 20px;
background: #89837f;
color: #fff;
text-align: center;
text-decoration: none;
}
.day {
font-weight: bold;
margin-top: 2em;
}
.bubble {
max-width: 66%;
padding: 10px 12px;
border-radius: 16px;
background: #fff;
}
.in .bubble {
border-bottom-left-radius: 1px;
}
.out .bubble {
border-bottom-right-radius: 1px;
background: #efffde;
}
.bubble img {
max-width: 100%;
margin-bottom: 0.4em;
}
.file {
display: block;
margin-bottom: 0.4em;
padding: 0.5em;
background: lightgrey;
}
.sender {
font-weight: bold;
margin-bottom: 0.4em;
}
.quote {
display: block;
margin-bottom: 0.4em;
padding-left: 8px;
border-left: 3px solid #888;
text-decoration: none;
}
.sender, .quote, time {
font-size: small;
}
.quote, time {
color: #888;
}
.quote .sender {
margin-bottom: 0.2em;
}
time {
display: block;
text-align: right;
}
.reactions {
margin: 4px 0 -18px;
}
.reactions span {
display: inline-block;
padding: 1px 8px;
border: 1px solid #ccc;
border-radius: 12px;
background: #fff;
font-size: small;
white-space: nowrap;
}
.reactions .self {
background: #efffde;
}
.nav {
margin: 1em 0;
text-align: center;
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.67.0-dev"
version = "2.66.0-dev"
description = "Deltachat FFI"
edition = "2024"
license = "MPL-2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc-bindings"
version = "2.67.0-dev"
version = "2.66.0-dev"
description = "Autogenerate DeltaChat JSON-RPC API bindings at build time"
edition = "2024"
license = "MPL-2.0"
@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.67.0-dev"
"version": "2.66.0-dev"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.67.0-dev"
version = "2.66.0-dev"
description = "DeltaChat JSON-RPC API"
edition = "2024"
license = "MPL-2.0"
+32
View File
@@ -1938,6 +1938,38 @@ impl CommandApi {
Ok(chat_id.map(|id| id.to_u32()))
}
/// Exports a chat as a ZIP-file.
///
/// The ZIP-file contains `index.html` with the messages as well as images and files.
/// `index.html` is well-formed and can be processed with a simple XML parser as well.
/// As browsers cannot handle arbitrary large pages,
/// large chats are continued in `index-2.html`, `index-3.html` and so on.
///
/// The UI is expected to let the user choose `dest`,
/// an existing file is overwritten.
///
/// `include_images` (images, sticker, gif)
/// and `include_files` (video, apps, files, vcards etc.) define what should be part of the export;
/// avatars are always added.
async fn export_chat(
&self,
account_id: u32,
chat_id: u32,
dest: String,
include_images: bool,
include_files: bool,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
chat::export_chat(
&ctx,
ChatId::new(chat_id),
dest.as_ref(),
include_images,
include_files,
)
.await
}
/// Returns all message IDs of the given types in a chat.
/// Typically used to show a gallery.
///
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.67.0-dev"
version = "2.66.0-dev"
license = "MPL-2.0"
edition = "2024"
repository = "https://github.com/chatmail/core"
+18
View File
@@ -249,6 +249,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"====================Import/Export commands==\n\
has-backup\n\
export-backup\n\
export-chat <chat-id> <zip-file> [all|textandimages|text]\n\
import-backup <backup-file>\n\
send-backup\n\
receive-backup <qr>\n\
@@ -408,6 +409,23 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
.await?;
println!("Exported to {}.", dir.to_string_lossy());
}
"export-chat" => {
ensure!(!arg1.is_empty(), "Argument <chat-id> missing.");
ensure!(!arg2.is_empty(), "Argument <zip-file> missing.");
let chat_id = ChatId::new(arg1.parse()?);
let (dest, what) = arg2.split_once(' ').unwrap_or((arg2, ""));
let include_images = what.is_empty() || what == "all" || what == "textandimages";
let include_files = what.is_empty() || what == "all";
chat::export_chat(
&context,
chat_id,
dest.as_ref(),
include_images,
include_files,
)
.await?;
println!("Exported to {dest}.");
}
"import-backup" => {
ensure!(!arg1.is_empty(), "Argument <backup-file> missing.");
imex(
+2 -1
View File
@@ -144,9 +144,10 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 9] = [
const IMEX_COMMANDS: [&str; 10] = [
"has-backup",
"export-backup",
"export-chat",
"import-backup",
"send-backup",
"receive-backup",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.67.0-dev"
version = "2.66.0-dev"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.67.0-dev"
version = "2.66.0-dev"
description = "DeltaChat JSON-RPC server"
edition = "2024"
license = "MPL-2.0"
@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.67.0-dev"
"version": "2.66.0-dev"
}
+5
View File
@@ -18,6 +18,11 @@ ignore = [
# this should be fixed by upgrading to iroh 1.0 once it is released.
"RUSTSEC-2025-0134",
# Unmaintained proc-macro-error2
# Transitive dependency of typescript-type-def 0.5.13.
# <https://rustsec.org/advisories/RUSTSEC-2026-0173>
"RUSTSEC-2026-0173",
# rustls-webpki v0.102.8
# We cannot upgrade to >=0.103.10 because
# it is a transitive dependency of iroh 0.35.0
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.67.0-dev"
version = "2.66.0-dev"
license = "MPL-2.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
+1 -1
View File
@@ -1 +1 @@
2026-10-28
2026-10-22
+3
View File
@@ -50,6 +50,9 @@ use crate::tools::{
};
use crate::webxdc::StatusUpdateSerial;
mod export;
pub use export::export_chat;
pub(crate) const PARAM_BROADCAST_SECRET: Param = Param::Arg3;
/// An chat item, such as a message or a marker.
+993
View File
@@ -0,0 +1,993 @@
//! # Export a single chat as a ZIP-file.
//!
//! Scope of the export is to have a long-living archive of non-ephemeral chat messages.
//! Target group is the end user, being able to access their data detached from a chatmail app.
//!
//! The ZIP-file contains:
//! - `index.html` with the messages.
//! The file is well-formed and can be processed with a simple XML parser as well.
//! As browsers cannot handle arbitrary large pages,
//! large chats are continued in `index-2.html`, `index-3.html` and so on.
//! - `style.css`
//! - `files/`, all attachments and avatars referenced by `index.html`
//!
//! Non-goal is to export non-chat data (eg. contacts) or dynamic data (eg. apps or delivery state);
//! for that, use a backup.
//! Non-goal is to make the file more complex than needed,
//! we rely on the browser wherever possible to keep things slick and fast,
//! e.g. images can be enlarged by a right click or the zoom gesture
//! and there is already a search and a video player in the browser.
//!
//! Some performance notes:
//! - Performance attribute "content-visibility: auto" is not used
//! as it makes layout harder, eg. clip the reactions,
//! which is not worth the effort or may even result in the opposite.
//! Similar for lazy loading of images. keep it simple.
//! Instead, we offer pagination, so that html files do not get arbitrarily big.
//! This also lowers memory usage when exporting huge chats
//! - We do not try to re-compress potentially compressed files
use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::{Context as _, Result};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipDateTime, ZipDateTimeBuilder, ZipEntryBuilder};
use chrono::{DateTime, Datelike as _, Local, Timelike as _};
use quick_xml::escape::escape;
use regex::regex;
use tokio::fs::{self, File};
use tokio::io::AsyncWriteExt as _;
use tokio_util::compat::TokioAsyncReadCompatExt as _;
use crate::chat::{self, Chat, ChatId, ChatItem, MessageListOptions};
use crate::constants::Chattype;
use crate::contact::{Contact, ContactId};
use crate::context::Context;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::reaction::get_msg_reactions;
use crate::stock_str;
use crate::tools::{time, truncate};
/// Pages larger than `MAX_PAGE_BYTES` are continued on the next page.
///
/// Browsers need time and memory for rendering
/// e.g. chromium on a desktop needs 2 second and 350mb for 5mb of HTML (about 20k messages),
/// phones are often slower.
const MAX_PAGE_BYTES: usize = 1_000_000;
/// Exports a chat as a ZIP-file.
/// If destination exists, it is overwritten.
///
/// `include_images` (images, sticker, gif)
/// and `include_files` (video, apps, files, vcards etc.) define what should be part of the export;
/// avatars are always added.
pub async fn export_chat(
context: &Context,
chat_id: ChatId,
dest: &Path,
include_images: bool,
include_files: bool,
) -> Result<()> {
let res = export_chat_inner(
context,
chat_id,
dest,
include_images,
include_files,
MAX_PAGE_BYTES,
)
.await;
if res.is_err() {
fs::remove_file(dest).await.ok();
}
res
}
async fn export_chat_inner(
context: &Context,
chat_id: ChatId,
dest: &Path,
include_images: bool,
include_files: bool,
max_page_bytes: usize,
) -> Result<()> {
let file = File::create(dest)
.await
.with_context(|| format!("Cannot create {}", dest.display()))?;
let mut exporter = ChatExporter {
chat: Chat::load_from_db(context, chat_id).await?,
zip: ZipFileWriter::with_tokio(file),
export_date: zip_datetime(time()),
include_images,
include_files,
current_page: 1,
rendered_contact_avatars: HashMap::new(),
added_files: HashSet::new(),
message_pages: HashMap::new(),
};
let css = include_str!("../../assets/chat-export.css");
exporter.add_bytes("style.css", css.as_bytes()).await?;
let mut html = exporter.render_page_start(1);
html += &exporter.render_header(context).await?;
let items = chat::get_chat_msgs_ext(
context,
chat_id,
MessageListOptions {
add_daymarker: true,
},
)
.await?;
let mut day = String::new();
let mut day_added = false;
for item in items {
let msg_id = match item {
ChatItem::DayMarker { timestamp } => {
// render day headlines only if there is actually a message -
// and not if all messages of that day are skipped because being ephemeral
let date = local_time(timestamp).format("%Y-%m-%d");
day = format!("<div class=\"day\">{date}</div>\n\n");
day_added = false;
continue;
}
ChatItem::Message { msg_id } => msg_id,
};
let msg = Message::load_from_db(context, msg_id).await?;
if msg.get_ephemeral_timer() != EphemeralTimer::Disabled {
continue;
}
if html.len() >= max_page_bytes {
// page is full: finish it with a link to the next page ...
let previous = page_name(exporter.current_page);
let next = page_name(exporter.current_page.saturating_add(1));
html += &format!("<div class=\"nav\"><a href=\"{next}\">{next} →</a></div>\n");
html += &exporter.render_page_end();
exporter.add_bytes(&previous, html.as_bytes()).await?;
// ... and start the next page
exporter.current_page = exporter.current_page.saturating_add(1);
html = exporter.render_page_start(exporter.current_page);
html +=
&format!("<div class=\"nav\"><a href=\"{previous}\">← {previous}</a></div>\n\n");
day_added = false;
}
if !day_added {
html += &day;
day_added = true;
}
if msg.is_info() {
html += &exporter.render_info(context, &msg).await?;
} else {
html += &exporter.render_bubble(context, &msg).await?;
}
exporter.message_pages.insert(msg_id, exporter.current_page);
html += "\n";
}
// finalize last page, close zip and flush everything
html += &exporter.render_page_end();
exporter
.add_bytes(&page_name(exporter.current_page), html.as_bytes())
.await?;
let mut file = exporter.zip.close().await?.into_inner();
file.flush().await?;
Ok(())
}
struct ChatExporter {
chat: Chat,
zip: ZipFileWriter<File>,
export_date: ZipDateTime,
/// Number of the page that is currently written, starting with 1.
current_page: u32,
/// Add images, stickers and GIFs; avatars are always added.
include_images: bool,
/// Add other attachments such as documents, videos and audio.
include_files: bool,
rendered_contact_avatars: HashMap<ContactId, String>,
/// Names of the entries below `files/` that are already written.
added_files: HashSet<String>,
/// Pages of the messages that are written already.
/// Used for links between messages on different pages.
message_pages: HashMap<MsgId, u32>,
}
impl ChatExporter {
fn render_page_start(&self, page: u32) -> String {
let chat_name = text_to_html(self.chat.get_name());
let title = if page == 1 {
chat_name.to_string()
} else {
format!("{chat_name} ({page})")
};
// `\` at the end of a line skips the indentation of the next one
format!(
"<!DOCTYPE html>\n\
<html>\n\
<head>\n\
<meta charset=\"utf-8\" />\n\
<title>{title}</title>\n\
<link rel=\"stylesheet\" href=\"style.css\" />\n\
</head>\n\
<body>\n\
<div class=\"chat\">\n"
)
}
fn render_page_end(&self) -> String {
"</div>\n</body>\n</html>\n".to_string()
}
async fn render_header(&mut self, context: &Context) -> Result<String> {
let image = self.chat.get_profile_image(context).await?;
let color = self.chat.get_color(context).await?;
let chat_name = self.chat.get_name().to_string();
let avatar = self
.render_avatar(context, image.as_deref(), color, &chat_name)
.await?;
let headline = text_to_html(&chat_name);
let description = if self.chat.typ == Chattype::Single {
match chat::get_chat_contacts(context, self.chat.id)
.await?
.first()
{
Some(contact_id) => Contact::get_by_id(context, *contact_id)
.await?
.get_status()
.to_string(),
None => String::new(),
}
} else {
chat::get_chat_description(context, self.chat.id).await?
};
let description = if description.is_empty() {
String::new()
} else {
format!(
"<div class=\"description\">{}</div>",
text_to_linkified_html(&description)
)
};
Ok(format!(
"<div class=\"header\">\n{avatar}\n<h1>{headline}</h1>\n{description}\n</div>\n\n"
))
}
async fn render_avatar(
&mut self,
context: &Context,
image: Option<&Path>,
color: u32,
name: &str,
) -> Result<String> {
if let Some(image) = image
&& let Some(src) = self
.add_file(context, image, self.export_date, Compression::Stored)
.await?
{
return Ok(format!(r#"<img class="avatar" src="{src}" alt="" />"#)); // return image
}
let letter = name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_default();
Ok(format!(
r#"<div class="avatar" style="background: #{color:06x}">{}</div>"#, // return fallback with letter
text_to_html(&letter)
))
}
async fn render_contact_avatar(
&mut self,
context: &Context,
contact: &Contact,
) -> Result<String> {
if let Some(avatar) = self.rendered_contact_avatars.get(&contact.id) {
return Ok(avatar.clone());
}
let avatar = self
.render_avatar(
context,
contact.get_profile_image(context).await?.as_deref(),
contact.get_color(),
contact.get_display_name(),
)
.await?;
self.rendered_contact_avatars
.insert(contact.id, avatar.clone());
Ok(avatar)
}
async fn render_info(&self, context: &Context, msg: &Message) -> Result<String> {
let id = msg.get_id().to_u32();
let text = text_to_html(&msg.get_text());
let target = match msg.get_info_type() {
SystemMessage::MessagePinned | SystemMessage::MessageUnpinned => {
self.message_link(msg, msg.parent(context).await?.as_ref())
}
_ => None,
};
Ok(match target {
Some(link) => {
format!("<a class=\"msg info\" id=\"m{id}\" href=\"{link}\">{text}</a>\n")
}
None => format!("<div class=\"msg info\" id=\"m{id}\">{text}</div>\n"),
})
}
async fn render_bubble(&mut self, context: &Context, msg: &Message) -> Result<String> {
let id = msg.get_id().to_u32();
let from_id = msg.get_from_id();
let outgoing = from_id == ContactId::SELF;
let show_sender = self.chat.typ != Chattype::Single && !outgoing;
let contact = Contact::get_by_id(context, from_id).await?;
let direction = if outgoing { "out" } else { "in" };
let mut html = format!("<div class=\"msg {direction}\" id=\"m{id}\">\n");
if show_sender {
html += &self.render_contact_avatar(context, &contact).await?;
html += "\n";
}
html += "<div class=\"bubble\">\n";
if msg.is_forwarded() {
let sender = text_to_html(&stock_str::forwarded(context));
html += &format!("<div class=\"sender\" style=\"color: #888\">{sender}</div>\n");
} else if show_sender {
let sender = text_to_html(&msg.get_sender_name(&contact));
let color = contact.get_color();
html +=
&format!("<div class=\"sender\" style=\"color: #{color:06x}\">{sender}</div>\n");
}
if let Some(quoted_text) = msg.quoted_text() {
let quoted_msg = msg.quoted_message(context).await?;
let mut style = String::new();
let mut author = String::new();
if let Some(quoted_msg) = &quoted_msg {
if quoted_msg.is_forwarded() {
if !msg.is_forwarded() {
let sender = text_to_html(&stock_str::forwarded(context));
author = format!("<div class=\"sender\">{sender}</div>");
}
} else {
let contact = Contact::get_by_id(context, quoted_msg.get_from_id()).await?;
let sender = text_to_html(&quoted_msg.get_sender_name(&contact));
let color = contact.get_color();
style = format!(" style=\"border-left-color: #{color:06x}\"");
author = format!(
"<div class=\"sender\" style=\"color: #{color:06x}\">{sender}</div>"
);
}
}
let approx_chars = 180; // cut the quote hard, it is just a hint for the quoted message
let quoted_text = if let Some(quoted_msg) = &quoted_msg {
let add_forwarded = false; // "Forwarded" is shown above already
let add_emoji = true;
quoted_msg
.get_summary_text_ext(context, add_forwarded, add_emoji, approx_chars)
.await
} else {
truncate(&quoted_text, approx_chars).to_string()
};
let quote = text_to_html(&quoted_text);
html += &match self.message_link(msg, quoted_msg.as_ref()) {
Some(link) => {
format!("<a class=\"quote\" href=\"{link}\"{style}>{author}{quote}</a>\n")
}
None => format!("<div class=\"quote\"{style}>{author}{quote}</div>\n"),
};
}
if let Some(path) = msg.get_file(context) {
let compression = match msg.get_viewtype() {
Viewtype::Image
| Viewtype::Gif
| Viewtype::Sticker
| Viewtype::Video
| Viewtype::Webxdc
| Viewtype::Audio
| Viewtype::Voice => Compression::Stored, // speed up export by not trying to compress already compressed files
_ => Compression::Deflate,
};
let is_image = matches!(
msg.get_viewtype(),
Viewtype::Image | Viewtype::Gif | Viewtype::Sticker
);
let copy_to_archive = if is_image {
self.include_images
} else {
self.include_files
};
let src = if copy_to_archive {
self.add_file(
context,
&path,
zip_datetime(msg.get_timestamp()),
compression,
)
.await?
} else {
None
};
let filename = text_to_html(&msg.get_filename().unwrap_or_default());
html += &match src {
Some(src) if is_image => format!("<img src=\"{src}\" alt=\"\" />\n"),
Some(src) => format!("<a class=\"file\" href=\"{src}\">{filename}</a>\n"),
None => format!("<div class=\"file\">{filename}</div>\n"),
};
}
let text = msg.get_text();
if !text.is_empty() {
html += &format!(
"<div class=\"text\">{}</div>\n",
text_to_linkified_html(&text)
);
}
let time = local_time(msg.get_timestamp());
html += &format!(
"<time datetime=\"{}\">{}</time>\n",
time.to_rfc3339(),
time.format("%H:%M")
);
let reactions = get_msg_reactions(context, msg.get_id()).await?;
if !reactions.is_empty() {
let pills: Vec<String> = reactions
.frequencies
.iter()
.map(|f| {
let class = if f.is_from_self {
" class=\"self\""
} else {
""
};
format!(
"<span{class}>{} {}</span>",
text_to_html(f.reaction.as_str()),
f.count
)
})
.collect();
html += &format!("<div class=\"reactions\">{}</div>\n", pills.join(" "));
}
html += "</div>\n</div>\n";
Ok(html)
}
fn new_entry(&self, name: &str, compression: Compression) -> ZipEntryBuilder {
ZipEntryBuilder::new(name.to_string().into(), compression)
.last_modification_date(self.export_date)
.unix_permissions(0o644) // otherwise unzip may create unreadable files (mode 000)
}
async fn add_bytes(&mut self, name: &str, data: &[u8]) -> Result<()> {
let entry = self.new_entry(name, Compression::Deflate);
self.zip.write_entry_whole(entry, data).await?;
Ok(())
}
/// Adds a file to the archive and returns the path inside the archive.
/// Returns the existing path if the file was already added.
/// Returns `None` if the file cannot be read, e.g. if it was not downloaded.
async fn add_file(
&mut self,
context: &Context,
path: &Path,
modified: ZipDateTime,
compression: Compression,
) -> Result<Option<String>> {
let Some(name) = path.file_name() else {
return Ok(None);
};
let name = format!("files/{}", name.to_string_lossy());
if !self.added_files.contains(&name) {
let src = match File::open(path).await {
Ok(src) => src,
Err(err) => {
warn!(context, "Export skipped {}: {err:#}.", path.display());
return Ok(None);
}
};
let entry = self
.new_entry(&name, compression)
.last_modification_date(modified);
let mut entry = self.zip.write_entry_stream(entry).await?;
futures::io::copy(src.compat(), &mut entry).await?;
entry.close().await?;
self.added_files.insert(name.clone());
}
Ok(Some(name))
}
/// Returns the link to the target message.
/// - if the target message was not written `None` is returned (usually, newer messages reference older ones)
/// - if the target message is on the same page, a link as `#m1234` is returned
/// - if the target message is on a previous page, a links as `index.html#m1234`, `index-2.html#m5678` etc. are returned
fn message_link(&self, msg: &Message, target: Option<&Message>) -> Option<String> {
let target = target.filter(|target| target.get_chat_id() == msg.get_chat_id())?;
let page = *self.message_pages.get(&target.get_id())?;
let file = if page == self.current_page {
String::new()
} else {
page_name(page)
};
Some(format!("{file}#m{}", target.get_id().to_u32()))
}
}
/// Returns the name of the file of the page `page`: `index.html`, `index-2.html`, `index-3.html`.
fn page_name(page: u32) -> String {
if page == 1 {
"index.html".to_string()
} else {
format!("index-{page}.html")
}
}
fn zip_datetime(timestamp: i64) -> ZipDateTime {
let time = local_time(timestamp);
// ZIP can store local time of the years 1980 to 2107 only.
ZipDateTimeBuilder::new()
.year(time.year().clamp(1980, 2107))
.month(time.month())
.day(time.day())
.hour(time.hour())
.minute(time.minute())
.second(time.second())
.build()
}
fn local_time(timestamp: i64) -> DateTime<Local> {
DateTime::from_timestamp(timestamp, 0)
.unwrap_or_default()
.with_timezone(&Local)
}
/// Escapes text for use in XML.
/// Replaces newlines by `<br />` and removes control characters.
fn text_to_html(text: &str) -> String {
let text: String = text
.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
.collect();
escape(&text).replace('\n', "<br />")
}
/// Like text_to_html() plus turning detected URLs into links.
fn text_to_linkified_html(text: &str) -> String {
let url_re = regex!(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)");
let mut html = String::new();
let mut end = 0;
for m in url_re.find_iter(text) {
// Trailing punctuation is more likely to be part of the sentence than of the URL.
let url = m.as_str().trim_end_matches(['.', ',', ':', ';', '!', '?']);
let escaped = text_to_html(url);
html += &text_to_html(text.get(end..m.start()).unwrap_or_default());
html += &format!("<a href=\"{escaped}\">{escaped}</a>");
end = m.start().saturating_add(url.len());
}
html + &text_to_html(text.get(end..).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::num::NonZero;
use std::time::Duration;
use async_zip::base::read::mem::ZipFileReader;
use quick_xml::events::Event;
use super::*;
use crate::config::Config;
use crate::message::MessageState;
use crate::pinned_messages::set_pinned_state;
use crate::reaction::send_reaction;
use crate::test_utils::{
AVATAR_64x64_BYTES, TestContext, TestContextManager, TimeShiftFalsePositiveNote,
create_test_image,
};
use crate::tools::SystemTime;
#[test]
fn test_zip_datetime_with_unusual_timestamps() {
// the ZIP date builder panics for years that cannot be stored.
for timestamp in [i64::MIN, -1, 0, 4_500_000_000, i64::MAX] {
zip_datetime(timestamp);
}
}
fn assert_well_formed_xml(html: &str) -> Result<()> {
let mut reader = quick_xml::Reader::from_str(html);
while reader.read_event()? != Event::Eof {} // this bubbles up any parsing error and let tests fail
Ok(())
}
// exports and reads the archive back, returning html and all file names
async fn export_and_read_back(
t: &TestContext,
chat_id: ChatId,
include_images: bool,
include_files: bool,
) -> Result<(String, Vec<String>)> {
let dir = tempfile::tempdir()?;
let dest = dir.path().join("chat.zip");
export_chat(t, chat_id, &dest, include_images, include_files).await?;
let zip = ZipFileReader::new(fs::read(&dest).await?).await?;
let names: Vec<String> = zip
.file()
.entries()
.iter()
.map(|e| e.filename().as_str().map(|s| s.to_string()))
.collect::<Result<_, _>>()?;
for entry in zip.file().entries() {
assert_eq!(entry.unix_permissions(), Some(0o644));
}
let index = names.iter().position(|n| n == "index.html").unwrap();
let mut html = Vec::new();
zip.reader_with_entry(index)
.await?
.read_to_end_checked(&mut html)
.await?;
let html = String::from_utf8(html)?;
assert_well_formed_xml(&html)?;
Ok((html, names))
}
// exports and reads the archive back, returning a map of index names and content
async fn export_and_read_back_pages(
t: &TestContext,
chat_id: ChatId,
max_page_bytes: usize,
) -> Result<BTreeMap<String, String>> {
let dir = tempfile::tempdir()?;
let dest = dir.path().join("chat.zip");
export_chat_inner(t, chat_id, &dest, true, true, max_page_bytes).await?;
let zip = ZipFileReader::new(fs::read(&dest).await?).await?;
let mut pages = BTreeMap::new();
for (i, entry) in zip.file().entries().iter().enumerate() {
let name = entry.filename().as_str()?.to_string();
if name.starts_with("index") {
let mut html = Vec::new();
zip.reader_with_entry(i)
.await?
.read_to_end_checked(&mut html)
.await?;
let html = String::from_utf8(html)?;
assert_well_formed_xml(&html)?;
pages.insert(name, html);
}
}
Ok(pages)
}
#[tokio::test(flavor = "multi_thread")]
async fn test_export_chat_in_pages() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = alice.create_chat(bob).await.id;
// add a number of messages to be split over several pages
let mut msg_ids = Vec::new();
for i in 0..40 {
let sent = alice
.send_text(chat_id, &format!("message number {i}"))
.await;
msg_ids.push(sent.sender_msg_id);
}
// add another message that will be on the last page and quotes a message on the first page
let first_msg = Message::load_from_db(alice, msg_ids[0]).await?;
let mut msg = Message::new_text("reply to the first one".to_string());
msg.set_quote(alice, Some(&first_msg)).await?;
msg_ids.push(chat::send_msg(alice, chat_id, &mut msg).await?);
// force everything on one page: no navigation will be present
let pages = export_and_read_back_pages(alice, chat_id, 1_000_000).await?;
assert_eq!(pages.len(), 1);
assert!(!pages["index.html"].contains("class=\"nav\""));
// force smaller page sizes and a split
let max_page_bytes = 3000; // The checks below need at least 3 pages, so the limit has to be small enough
let pages = export_and_read_back_pages(alice, chat_id, max_page_bytes).await?;
let count = pages.len();
assert!(
count >= 3,
"only {count} pages, messages got smaller, lower max_page_bytes to fix the test"
);
let names: Vec<&String> = pages.keys().collect();
for n in 2..=count {
assert!(names.contains(&&format!("index-{n}.html")));
}
assert!(pages["index.html"].contains(r#"<a href="index-2.html">index-2.html →</a>"#));
assert!(!pages["index.html"].contains("←"));
assert!(pages["index-2.html"].contains(r#"<a href="index.html">← index.html</a>"#));
assert!(pages["index-2.html"].contains(r#"<a href="index-3.html">index-3.html →</a>"#));
let last = &pages[&format!("index-{count}.html")];
assert!(!last.contains("→"));
assert!(last.contains(&format!(
r#"<a href="{}">← {0}</a>"#,
page_name(count as u32 - 1)
)));
assert!(
pages["index-2.html"].contains(&format!("<title>{} (2)</title>", "bob@example.net"))
);
// test that no message is on multiple pages
for msg_id in &msg_ids {
let anchor = format!("id=\"m{}\"", msg_id.to_u32());
assert_eq!(pages.values().filter(|p| p.contains(&anchor)).count(), 1);
}
// a continuation page starts with a day marker, so the date is not lost
let page2 = &pages["index-2.html"];
assert!(page2.find("class=\"day\"").unwrap() < page2.find("id=\"m").unwrap());
// check that the quote on the last page links correctly to the first page
let link = format!("href=\"index.html#m{}\"", msg_ids[0].to_u32());
assert!(last.contains(&link));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_export_chat_content() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = alice.create_chat(bob).await.id;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(alice, "image.png", &create_test_image(10, 10)?, None)?;
chat::send_msg(alice, chat_id, &mut msg).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(alice, "doc.txt", b"content", None)?;
chat::send_msg(alice, chat_id, &mut msg).await?;
alice.send_text(chat_id, "Some text").await;
// tool to check the number of written files
let check = |names: &[String], files: usize| {
assert_eq!(
names.iter().filter(|n| n.starts_with("files/")).count(),
files
);
};
// everything
let (html, names) = export_and_read_back(alice, chat_id, true, true).await?;
check(&names, 2);
assert!(html.contains(r#"<img src="files/"#));
assert!(html.contains(r#"<a class="file" href="files/"#));
assert!(!html.contains(r#"<div class="file""#));
// text and images
let (html, names) = export_and_read_back(alice, chat_id, true, false).await?;
check(&names, 1);
assert!(html.contains(r#"<img src="files/"#));
assert!(!html.contains(r#"<a class="file" href="files/"#));
assert!(html.contains(r#"<div class="file">doc.txt</div>"#));
// text only: the attachments are still mentioned
let (html, names) = export_and_read_back(alice, chat_id, false, false).await?;
check(&names, 0);
assert!(!html.contains("files/"));
assert!(html.contains(r#"<div class="file">image_"#));
assert!(html.contains(r#"<div class="file">doc.txt</div>"#));
assert!(html.contains("Some text"));
// other files without images
let (html, names) = export_and_read_back(alice, chat_id, false, true).await?;
check(&names, 1);
assert!(html.contains(r#"<a class="file" href="files/"#));
assert!(html.contains(r#"<div class="file">image_"#));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_export_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = alice.create_chat(bob).await.id;
let bob_chat_id = bob.create_chat(alice).await.id;
// create a little chat between Alice and Bob, including reactions, quotes, images and a "pinned" info message
let sent = alice
.send_text(alice_chat_id, "<b>Hi</b> & \"bye\"\nsecond line")
.await;
let bob_msg = bob.recv_msg(&sent).await;
send_reaction(bob, bob_msg.id, "👍").await?;
let reaction = bob.pop_sent_msg().await;
alice.recv_msg_hidden(&reaction).await;
send_reaction(alice, sent.sender_msg_id, "❤").await?;
let sent = bob.send_text(bob_chat_id, "Hi Alice").await;
let hi_alice = alice.recv_msg(&sent).await;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(alice, "image.png", &create_test_image(10, 10)?, None)?;
chat::send_msg(alice, alice_chat_id, &mut msg).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(alice, "doc.txt", b"content", None)?;
chat::send_msg(alice, alice_chat_id, &mut msg).await?;
alice
.send_text(alice_chat_id, "See https://example.org/a?b=1&c=2. Done")
.await;
let mut msg = Message::new_text("reply".to_string());
msg.set_quote(alice, Some(&hi_alice)).await?;
chat::send_msg(alice, alice_chat_id, &mut msg).await?;
chat::add_info_msg(alice, alice_chat_id, "An <info>").await?;
set_pinned_state(alice, hi_alice.get_id(), true).await?;
let pin_info_id = alice.get_last_msg_id_in(alice_chat_id).await;
// check the export
let (html, names) = export_and_read_back(alice, alice_chat_id, true, true).await?;
assert!(!html.contains("<b>Hi</b>"));
assert!(html.contains("&lt;b&gt;Hi&lt;/b&gt; &amp; &quot;bye&quot;<br />second line"));
assert!(html.contains(
r#"See <a href="https://example.org/a?b=1&amp;c=2">https://example.org/a?b=1&amp;c=2</a>. Done"#
));
assert!(html.contains(r#"<div class="msg in" id="m"#));
assert!(html.contains(r#"<div class="msg out" id="m"#));
assert!(html.contains(r#"<div class="msg info" id="m"#));
assert!(html.contains("An &lt;info&gt;"));
assert!(html.contains(&format!(
r##"<a class="msg info" id="m{}" href="#m{}">"##,
pin_info_id.to_u32(),
hi_alice.get_id().to_u32()
)));
// The quote is a link to the quoted message, with its author on top.
assert!(html.contains(&format!(
r##"<a class="quote" href="#m{}" style="border-left-color: #"##,
hi_alice.get_id().to_u32()
)));
assert!(html.contains(r#"<div class="sender" style="color: #"#)); // for the quote
assert!(html.contains("</div>Hi Alice</a>"));
assert!(html.contains(r#"<div class="reactions">"#));
assert!(html.contains("<span>👍 1</span>"));
assert!(html.contains(r#"<span class="self">❤ 1</span>"#));
assert!(html.contains(r#"<a class="file" href="files/"#));
assert!(html.contains("doc.txt</a>"));
assert!(html.contains(r#"<img src="files/"#));
assert!(names.contains(&"style.css".to_string()));
assert_eq!(names.iter().filter(|n| n.starts_with("files/")).count(), 2);
for name in names.iter().filter(|n| n.starts_with("files/")) {
assert!(html.contains(&format!("\"{name}\"")));
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_export_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = chat::create_group(alice, "Group <1>").await?;
let bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
chat::add_contact_to_chat(alice, chat_id, bob_contact_id).await?;
let sent = alice.send_text(chat_id, "Hi from Alice").await;
let msg = bob.recv_msg(&sent).await;
assert_eq!(msg.get_state(), MessageState::InFresh);
let (html, names) = export_and_read_back(bob, msg.get_chat_id(), true, true).await?;
assert!(html.contains(r#"<div class="header">"#));
assert!(html.contains("<h1>Group &lt;1&gt;</h1>"));
assert!(!html.contains(r#"class="description""#));
assert!(html.contains(r#"<div class="sender" style="color: #"#));
assert!(html.contains(r#"<div class="avatar" style="background: #"#)); // avatars without images use color+letter
assert_eq!(names.len(), 2);
chat::set_chat_description(alice, chat_id, "About <us>").await?;
let (html, _) = export_and_read_back(alice, chat_id, true, true).await?;
assert!(html.contains(r#"<div class="description">About &lt;us&gt;</div>"#));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_export_chat_avatars() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let dir = tempfile::tempdir()?;
let avatar = dir.path().join("avatar.png");
fs::write(&avatar, AVATAR_64x64_BYTES).await?;
alice
.set_config(Config::Selfavatar, avatar.to_str())
.await?;
let chat_id = chat::create_group(alice, "Group").await?;
let bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
chat::add_contact_to_chat(alice, chat_id, bob_contact_id).await?;
let sent = alice.send_text(chat_id, "Hi from Alice").await;
let msg = bob.recv_msg(&sent).await;
// the avatar of Alice is shown on Bob's side, also if the export does not include message's images
for (include_images, include_files) in
[(false, false), (false, true), (true, false), (true, true)]
{
let (html, _) =
export_and_read_back(bob, msg.get_chat_id(), include_images, include_files).await?;
assert!(html.contains(r#"<img class="avatar" src="files/"#));
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_export_skips_ephemeral_messages() -> Result<()> {
let _n = TimeShiftFalsePositiveNote;
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = alice.create_chat(bob).await.id;
let kept = alice.send_text(chat_id, "kept message").await;
let duration = NonZero::new(3600).unwrap();
chat_id
.set_ephemeral_timer(alice, EphemeralTimer::Enabled { duration })
.await?;
// the ephemeral message is on a day of its own
SystemTime::shift(Duration::from_secs(2 * 24 * 60 * 60));
let ephemeral = alice.send_text(chat_id, "ephemeral message").await;
let (html, _) = export_and_read_back(alice, chat_id, true, true).await?;
assert!(html.contains("kept message"));
assert!(html.contains(&format!("id=\"m{}\"", kept.sender_msg_id.to_u32())));
assert!(!html.contains("ephemeral message"));
assert!(!html.contains(&format!("id=\"m{}\"", ephemeral.sender_msg_id.to_u32())));
// a day with ephemeral messages only gets no day marker
assert_eq!(html.matches("class=\"day\"").count(), 1);
Ok(())
}
}