Thunderbird for Android 离线 Demo Backend 完全指南:数据组织、消息线程与内容编辑
【免费下载链接】thunderbird-androidThunderbird for Android – Open Source Email App for Android (fka K-9 Mail)项目地址: https://gitcode.com/gh_mirrors/th/thunderbird-android
导读
backend/demo是 Thunderbird for Android(即 K-9 Mail 的继任者)仓库中的一个自包含、完全离线的邮件后端实现,它的存在让开发者可以在不连接任何真实邮件服务器的情况下,运行应用、演示邮件 UI、手工测试各种邮件交互流程。本文以 backend/demo/README.md 为骨架,结合仓库中 DemoBackend、DemoStore、DemoDataLoader 等源码与真实的 EML 样例数据,完整讲解 Demo Backend 的能力边界、数据结构、关键类实现、集成方式以及如何新增消息、嵌套文件夹和构造会话(Thread)——读完后你将具备独立维护和扩展这套离线演示数据的能力。
模块定位:为什么需要一个离线 Demo Backend
Thunderbird for Android 是一个大型多模块 Android 项目,真实的邮件收发依赖 IMAP、POP3、SMTP 等网络协议后端(见 backend/imap、backend/pop3、backend/smtp)。但在开发邮件 UI、做截图、跑手工验收、写自动化测试时,每次都连真实服务器既不现实也不稳定。
backend/demo正是为此而生的Kotlin/JVM 库:它实现了统一的 Backend API,但所有数据都来自随库打包的本地资源(src/main/resources/mailbox),不发任何网络请求。README 中明确描述了它的用途:
a self-contained, offline backend implementation used by the app to showcase and manually test email UI and flows without connecting to a real mail server.
这意味着:它是一份“数据 + 行为”都高度可控的假服务器,适用于 UI 演示、开发调试、冒烟测试等场景。
Demo Backend 的能力与边界
已实现的能力
按照 README 与 DemoBackend.kt 的声明,Demo Backend 提供以下行为:
| 能力 | 说明 |
|---|---|
| 文件夹列表 | 返回预定义的文件夹列表,并通过refreshFolderList()暴露给上层 |
| 消息列表同步 | 支持基本同步,将资源中的消息写入本地存储 |
| 会话(Thread) | 基于标准Message-Id、In-Reply-To、References头,配合应用 UI 形成会话视图 |
| 移动 / 复制 / 上传 | “假装”成功:只返回新生成的 serverId,不真正落盘到任何远端 |
| 发送消息 | 将消息交给应用的存储层(BackendStorage),不经过网络 |
从 DemoBackend.kt 的 capability 标志可以看到具体取值:
override val supportsFlags: Boolean = true override val supportsExpunge: Boolean = false override val supportsMove: Boolean = true override val supportsCopy: Boolean = true override val supportsUpload: Boolean = true override val supportsTrashFolder: Boolean = true override val supportsSearchByDate: Boolean = false override val supportsFolderSubscriptions: Boolean = false override val isPushCapable: Boolean = false这些标志与“未实现即抛异常”的方法相互印证,构成了能力边界:支持打标、移动、复制、上传、废件箱;不支持 Expunge、按日期搜索、订阅文件夹和 Push。
有意的限制(Limitations by design)
README 明确列出以下限制,这些不是缺陷而是刻意设计:
- 无真实网络:
search、fetchPart、downloadMessage、downloadMessageStructure、downloadCompleteMessage、findByMessageId、expunge、createPusher等方法在 DemoBackend.kt 中一律throw UnsupportedOperationException("not implemented"); - 不支持 Push:
isPushCapable = false,createPusher直接抛异常; - 数据受限于清单:只有
contents.json中列出、且存在对应.eml文件的消息才可见; - 跨文件夹会话不支持:一个会话的所有消息必须放在同一个文件夹中。
数据结构:contents.json 与 EML 文件的映射
Demo 数据的根目录是backend/demo/src/main/resources/mailbox/,由两部分组成:
- 文件夹树定义:contents.json
- 消息文件:
<folderServerId>/<messageServerId>.eml
contents.json 的字段语义
以仓库中真实的 contents.json 为例:
{ "inbox": { "name": "Inbox", "type": "INBOX", "messageServerIds": [ "01-intro", "02-many-recipients", "03-thread-1", "04-thread-2" ] }, "drafts": { "name": "Drafts", "type": "DRAFTS", "messageServerIds": [] }, "sent": { "name": "Sent", "type": "SENT", "messageServerIds": [] }, "spam": { "name": "Spam", "type": "SPAM", "messageServerIds": [] }, "trash": { "name": "Trash", "type": "TRASH", "messageServerIds": [] }, "archive":{ "name": "Archive","type": "ARCHIVE","messageServerIds": [] } }每个条目是一个以folderServerId为 key 的对象,字段包括:
| 字段 | 含义 | 取值参考 |
|---|---|---|
name | 文件夹显示名 | 任意字符串,如"Inbox"、"Nested Level 1" |
type | 文件夹类型 | INBOX、DRAFTS、SENT、SPAM、TRASH、ARCHIVE、REGULAR等(对应 FolderType) |
messageServerIds | 该文件夹下的消息 id 列表 | 每个 id 对应一个<id>.eml文件 |
subFolders | 嵌套子文件夹(可选) | 递归的同类结构 |
从源码看,这个 JSON 的结构由 DemoFolder.kt 中的@Serializable data class精确定义:
@Serializable internal data class DemoFolder( val name: String, val type: FolderType, val messageServerIds: List<String>, val subFolders: DemoFolders? = null, )而DemoFolders只是Map<String, DemoFolder>的类型别名(见 DemoFolders.kt)。
EML 消息文件的路径规则
消息文件必须严格遵循以下路径约定:
src/main/resources/mailbox/<folderServerId>/<messageServerId>.eml例如 inbox/01-intro.eml 对应folderServerId=inbox、messageServerId=01-intro。
对应的解析逻辑在 DemoDataLoader.kt:
fun loadMessage(folderServerId: String, messageServerId: String): Message { return getResourceAsStream("/mailbox/$folderServerId/$messageServerId.eml").use { inputStream -> MimeMessage.parseMimeMessage(inputStream, false).apply { uid = messageServerId } } }值得注意的是:消息的uid被强制设置为messageServerId,也就是说消息的唯一标识由文件名决定,而不是由 EML 内容决定。
一个真实的 EML 样例(欢迎邮件 01-intro.eml):
MIME-Version: 1.0 From: "Thunderbird" <thunderbird@example.com> Date: Thu, 23 Sep 2021 23:42:00 +0200 Message-ID: <hello-1-2-3@example.com> Subject: Welcome to Thunderbird for Android To: User <user@example.com> Content-Type: text/plain; charset=UTF-8 Congratulations, you have managed to set up Thunderbird for Android's demo account.仓库中还内置了多个用于演示特殊场景的样例消息,例如:
02-many-recipients:多收件人;05-inline-image-data-uri/06-inline-image-attachment:内嵌图片的两种形式;07-localpart-exceeds-length-limit:本地部分超长地址的边界场景;turing/文件夹下的 1966–1996 年图灵奖系列消息。
特殊文件夹的保证
Demo 后端会确保 Inbox、Drafts、Sent、Spam、Trash、Archive 这些特殊文件夹始终存在。在 DemoStore.kt 中还有一个实用方法:
fun getInboxFolderId(): String { return demoFolders.filterValues { it.type == FolderType.INBOX }.keys.first() }发送消息时,CommandSendMessage正是靠它找到 Inbox 的 serverId,把“已发送”的消息写进 Inbox(见下文)。
关键类:三个核心构件
README 点名了三个关键类,它们在backend/demo/src/main/kotlin/app/k9mail/backend/demo/下:
| 类 | 职责 | 关键实现 |
|---|---|---|
DemoBackend | 实现Backend接口,把各操作分发给简单命令对象 | capability 标志 + 未实现方法抛异常 |
DemoStore | 以资源为数据源的内存态唯一事实来源(source of truth) | 懒加载 + 递归拍平嵌套文件夹 |
DemoDataLoader | 读取contents.json,把.eml解析成Message对象 | kotlinx.serialization +MimeMessage.parseMimeMessage |
DemoBackend:接口实现与命令分发
DemoBackend.kt 是模块的入口,构造时只需注入BackendStorage:
class DemoBackend( private val backendStorage: BackendStorage, ) : Backend { private val demoStore by lazy { DemoStore() } private val commandSync by lazy { CommandSync(backendStorage, demoStore) } private val commandRefreshFolderList by lazy { CommandRefreshFolderList(backendStorage, demoStore) } private val commandSendMessage by lazy { CommandSendMessage(backendStorage, demoStore) } ... }所有“未实现”的方法统一抛UnsupportedOperationException("not implemented");而移动/复制/上传这类操作则“只做足够模拟成功的事”:
override fun moveMessages( sourceFolderServerId: String, targetFolderServerId: String, messageServerIds: List<String>, ): Map<String, String> { // We do just enough to simulate a successful operation on the server. return messageServerIds.associateWith { createNewServerId() } }createNewServerId()由 DemoHelper.kt 提供,本质就是UUID.randomUUID().toString()——每一次“服务端成功”都会返回一个全新 id,从而让上层 UI 相信操作真实发生了。
DemoStore:内存态唯一事实来源与嵌套拍平
DemoStore.kt 内部持有DemoFolders,通过lazy在首次访问时由DemoDataLoader加载,再立即做一次递归拍平。
拍平逻辑正是 README 中“嵌套文件夹会以Parent/Child形式显示”的源码证据:
private fun flattenDemoFolders( demoFolders: DemoFolders, parentName: String = "", parentServerId: String = "", ): DemoFolders { val flatFolders = mutableMapOf<String, DemoFolder>() for ((folderServerId, demoFolder) in demoFolders) { val fullName = if (parentName.isEmpty()) { demoFolder.name } else { "$parentName/${demoFolder.name}" } val fullServerId = if (parentServerId.isEmpty()) { folderServerId } else { "$parentServerId/$folderServerId" } flatFolders[fullServerId] = demoFolder.copy(name = fullName) val subFolders = demoFolder.subFolders if (subFolders != null) { flatFolders.putAll( from = flattenDemoFolders( demoFolders = demoFolder.subFolders, parentName = fullName, parentServerId = fullServerId, ), ) } } return flatFolders }代码注释揭示了这样做的原因:后端本身不支持嵌套文件夹,因此必须把层级拍平成扁平的serverId与name。以仓库真实数据为例,nested文件夹树(Nested → Nested Level 1 → Nested Level 2)会被拍平成:
| serverId | 显示名 |
|---|---|
nested | Nested |
nested/nested_level_1 | Nested/Nested Level 1 |
nested/nested_level_1/nested_level_2 | Nested/Nested Level 1/Nested Level 2 |
同步流程:CommandSync 的幂等设计
CommandSync.kt 实现了 README 所说的“基本同步”,其流程为:
- 通知监听器
syncStarted(folderServerId); - 从
DemoStore查找文件夹,找不到则syncFailed并返回; - 读取本地存储中该文件夹已有的消息 id 列表,若不为空则直接
syncFinished返回(幂等:避免重复灌入); - 逐个读取资源中的消息,
saveMessage(message, MessageDownloadState.FULL)写入本地存储,并回调syncNewMessage; - 设置
setMoreMessages(MoreMessages.FALSE)(表示没有更多历史消息)后syncFinished。
这也解释了为什么新增 EML 后需要重新运行任务更新contents.json——同步过程只遍历清单中列出的messageServerIds。
发送消息:CommandSendMessage 的“假发送真落库”
CommandSendMessage.kt 演示了“发送成功”的模拟方式:
fun sendMessage(message: Message) { val inboxServerId = demoStore.getInboxFolderId() val backendFolder = backendStorage.getFolder(inboxServerId) val newMessage = message.copy(uid = createNewServerId()) backendFolder.saveMessage(newMessage, MessageDownloadState.FULL) }即:把要发送的消息序列化 → 重新解析成新的MimeMessage→ 换上新的uid→保存到 Inbox 文件夹。从 UI 视角看,发送成功了;从数据视角看,它只是被“归档”进了收件箱,全程零网络。
文件夹列表刷新:CommandRefreshFolderList 的差异同步
CommandRefreshFolderList.kt 采用“差异对比”策略:把本地存储已有的文件夹 id 与 DemoStore 的 id 集合做差集,新出现的创建、消失的删除,最后返回默认路径分隔符FOLDER_DEFAULT_PATH_DELIMITER。
在应用中使用 Demo Backend
README 指出:本模块是Kotlin/JVM 库,应用模块可以依赖backend:demo,并在创建账号时选择 Demo 后端以用于测试/开发;具体的注册与选择逻辑由各应用模块自行决定(可参考仓库中 app-k9mail、app-thunderbird 等应用模块的实现)。
使用前提是满足依赖配置:模块通过 Gradle 声明对backend:demo的依赖,例如implementation(project(":backend:demo"))。之后构造DemoBackend(backendStorage)实例,将其作为Backend注入到账号创建流程即可。Demo 数据随库打包在 classpath 资源中,因此无需任何网络或服务器配置,开箱即用。
编辑 Demo 内容:新增消息与文件夹
添加一条新消息
按 README 步骤操作:
- 将 EML 文件放到:
src/main/resources/mailbox/<folderServerId>/<yourMessageId>.eml; - 运行 Gradle 任务重新生成清单:
./gradlew :backend:demo:updateDemoMailbox该任务定义在 backend/demo/build.gradle.kts 中(tasks.register<UpdateDemoMailbox>("updateDemoMailbox")),它会扫描mailbox目录下所有文件夹与 EML 文件,重新生成src/main/resources/mailbox/contents.json。
添加一个新文件夹(可嵌套)
- 在
src/main/resources/mailbox/<yourFolderServerId>/下创建目录,并把.eml文件放进去; - 运行
./gradlew :backend:demo:updateDemoMailbox更新contents.json。
若需要嵌套层级,只需在contents.json的文件夹条目中加入subFolders字段(或依赖工具自动生成),后端会在运行时自动拍平并以Parent/Child名称呈现。
构造会话(Threaded Messages)
会话功能是 UI 层基于标准邮件头实现的,Demo Backend 只负责“如实暴露消息”,分组由应用 UI 完成。要在一个文件夹内构造会话,需要遵循以下规则(完整规则见 README 的Threaded messages一节):
- 每条消息必须有唯一的
Message-Id头; - 回复消息用
In-Reply-To指向父消息的Message-Id; - 维护
References头,包含从根消息到当前消息的整条祖先链(根在前、逐级回复在后); - 同一会话的所有消息必须放在同一个文件夹;
- 标题中的
Re:前缀不影响会话分组,是否添加均可。
README 给出的回复消息头部示例:
Message-Id: <reply-2@example.test> In-Reply-To: <root-1@example.test> References: <root-1@example.test>仓库中 03-thread-1.eml 就是一个会话根消息的实例:
MIME-Version: 1.0 From: Alice <alice@example.com> Date: Fri, 10 Feb 2023 10:00:00 +0100 Message-ID: <thread-1@example.com> Subject: Thread To: Bob <bob@example.com> Content-Type: text/plain; charset=UTF-8 This is the first message in this thread.它对应的04-thread-2.eml则作为回复消息,通过In-Reply-To: <thread-1@example.com>与之构成会话。
会话相关的两条重要限制(README 明确声明):
- 跨文件夹会话不支持:一个会话的消息必须保持在同一个文件夹中;
- 会话推断只看 MIME 头:后端不会根据文件名或
messageServerIds推断线程关系,只有Message-Id/In-Reply-To/References三个头参与会话分组。
常见问题速查
| 问题 | 原因与处理 |
|---|---|
| 新增 EML 后 App 里看不到消息 | contents.json未更新,运行./gradlew :backend:demo:updateDemoMailbox重新生成 |
文件夹名称显示为Parent/Child | 嵌套文件夹被后端拍平,属预期行为 |
| 点击消息详情报 “not implemented” | search、fetchPart等方法未实现,属设计限制 |
| 收件箱里多出了刚“发送”的邮件 | CommandSendMessage把发送的消息写入 Inbox 以模拟成功,属预期行为 |
| 会话没有正确分组 | 检查 EML 的Message-Id/In-Reply-To/References头是否正确、消息是否在同一文件夹 |
小结
backend/demo用极小的代码量(9 个 Kotlin 文件 + 一份 JSON 清单 + 一组 EML 资源)完整覆盖了邮件后端在 UI 层所需的大部分行为面:文件夹列表、同步、移动/复制/上传、发送、会话分组,并把未覆盖的能力以“抛异常”方式显式隔离。对于需要向客户演示、做 UI 冒烟测试或快速验证新交互的开发者来说,它是 Thunderbird for Android 仓库里成本最低、最可控的“离线邮件服务器”。维护者只需遵循“EML 文件 + contents.json 清单 + 标准邮件头”这三条规则,即可自由扩展演示内容。
【免费下载链接】thunderbird-androidThunderbird for Android – Open Source Email App for Android (fka K-9 Mail)项目地址: https://gitcode.com/gh_mirrors/th/thunderbird-android
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考