Skip to content

Commit 5ed2351

Browse files
authored
Merge pull request #9947 from wmontwe/chore/9938/add-gradle-task-to-update-demo-backend
chore(demo): add gradle task to update demo backend
2 parents cf33915 + 55eb7b9 commit 5ed2351

32 files changed

+288
-90
lines changed

backend/demo/README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Demo Backend
2+
3+
This module provides a self‑contained, offline backend implementation used by the app to showcase and manually test
4+
email UI and flows without connecting to a real mail server. It implements the Backend API and loads demo data from
5+
resources bundled with the library.
6+
7+
## What it does
8+
9+
- Exposes a Backend that
10+
- returns a predefined folder list
11+
- supports basic sync of message lists
12+
- supports threaded conversations (based on standard Message-Id, In-Reply-To, and References headers)
13+
- pretends to move/copy/upload messages successfully
14+
- sends messages by handing them to the app storage layer (no network)
15+
- Loads folders and messages from `src/main/resources/mailbox`.
16+
17+
## How data is organized
18+
19+
- Folder tree definition: `src/main/resources/mailbox/contents.json`
20+
- Describes folders by `serverId`, display name, type (INBOX, SENT, …), and a list of `messageServerIds` per folder.
21+
- Supports nested folders through the `subFolders` field. The backend flattens nested folders internally so they show up as "Parent/Child" names.
22+
- Special folders (Inbox, Drafts, Sent, Spam, Trash, Archive) are ensured to always exist.
23+
- Messages: EML files in `src/main/resources/mailbox/<folderServerId>/<messageServerId>.eml`
24+
- Example: `src/main/resources/mailbox/inbox/intro.eml` corresponds to `folderServerId=inbox` and `messageServerId=intro`.
25+
26+
Key classes
27+
28+
- DemoBackend: Backend implementation wired to simple commands.
29+
- DemoStore: In‑memory source of truth backed by resources.
30+
- DemoDataLoader: Reads contents.json and parses .eml files into Message objects.
31+
32+
Limitations (by design)
33+
34+
- No real network access; search, part fetching, and some operations are not implemented and will throw.
35+
- Push is not supported.
36+
- Only data found in contents.json and the matching .eml files is available.
37+
38+
## Using the demo backend in apps
39+
40+
This module is a Kotlin/JVM library. Applications can depend on `backend:demo` and select the demo backend when creating
41+
accounts for testing/development. The exact wiring is app‑specific (see the app modules in this repository for how
42+
they register/select backends).
43+
44+
## Editing demo content
45+
46+
1) Add a new message
47+
- Place your EML file at: `src/main/resources/mailbox/<folderServerId>/<yourMessageId>.eml`
48+
- Run `./gradlew :backend:demo:updateDemoMailbox` to regenerate `src/main/resources/mailbox/contents.json`.
49+
50+
2) Add a new folder (optionally nested)
51+
- Create the corresponding directory under `src/main/resources/mailbox/<yourFolderServerId>/` and place the `.eml` files inside it.
52+
- Then run `./gradlew :backend:demo:updateDemoMailbox` to update `contents.json`.
53+
54+
### Threaded messages
55+
56+
Threading is supported by the app when messages include standard headers. The demo backend simply exposes the messages; the UI groups them into threads.
57+
58+
To create a conversation thread in a folder:
59+
60+
- Give each message a unique `Message-Id` header.
61+
- For replies, set `In-Reply-To` to the `Message-Id` of the parent message.
62+
- Maintain a `References` header that contains the chain of ancestor `Message-Id`s (root first, then each reply). Many clients do this automatically; for demo EMLs, edit the headers manually.
63+
- Place all messages of a thread in the same folder.
64+
- Subject prefixes like `Re:` are optional and not used for threading.
65+
66+
Example headers in a reply message:
67+
68+
```
69+
Message-Id: <reply-2@example.test>
70+
In-Reply-To: <root-1@example.test>
71+
References: <root-1@example.test>
72+
```
73+
74+
Notes and limitations for threads:
75+
76+
- Cross-folder threading is not supported by the demo backend; keep a thread’s messages in one folder.
77+
- The backend does not infer threads from filenames or `messageServerIds`; only the MIME headers control threading.
78+

backend/demo/build.gradle.kts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import groovy.json.JsonOutput
2+
13
plugins {
24
id(ThunderbirdPlugins.Library.jvm)
35
alias(libs.plugins.android.lint)
@@ -12,3 +14,111 @@ dependencies {
1214

1315
testImplementation(projects.mail.testing)
1416
}
17+
18+
tasks.register<UpdateDemoMailbox>("updateDemoMailbox") {
19+
group = "demo"
20+
description = "Update mailbox/contents.json from src/main/resources/mailbox contents."
21+
22+
inputDir.set(layout.projectDirectory.dir("src/main/resources/mailbox"))
23+
outputFile.set(layout.projectDirectory.file("src/main/resources/mailbox/contents.json"))
24+
}
25+
26+
@CacheableTask
27+
abstract class UpdateDemoMailbox : DefaultTask() {
28+
29+
@get:InputDirectory
30+
@get:PathSensitive(PathSensitivity.RELATIVE)
31+
abstract val inputDir: DirectoryProperty
32+
33+
@get:OutputFile
34+
abstract val outputFile: RegularFileProperty
35+
36+
@TaskAction
37+
fun generate() {
38+
val mailboxRoot = inputDir.get().asFile
39+
40+
val topLevelFolderMap = mailboxRoot.listFiles()
41+
?.filter { it.isDirectory }
42+
?.sortedBy { it.name }
43+
?.mapNotNull { folder ->
44+
buildFolderNode(folder).takeIf { it.first }?.let { folder.name to it.second }
45+
}
46+
?.toMap(LinkedHashMap()) ?: linkedMapOf()
47+
48+
// Ensure special folders exist (even if empty)
49+
SPECIAL_FOLDERS.forEach { id ->
50+
topLevelFolderMap.putIfAbsent(
51+
id,
52+
linkedMapOf(
53+
"name" to displayName(id),
54+
"type" to folderType(id),
55+
"messageServerIds" to emptyList<String>(),
56+
),
57+
)
58+
}
59+
60+
// Reorder: special folders first, then others alphabetically
61+
val orderedTopLevelMap = linkedMapOf<String, Map<String, Any?>>()
62+
SPECIAL_FOLDERS.forEach { id -> topLevelFolderMap[id]?.let { orderedTopLevelMap[id] = it } }
63+
topLevelFolderMap.keys.filter { it !in SPECIAL_FOLDERS }.sorted().forEach { id ->
64+
orderedTopLevelMap[id] = topLevelFolderMap[id]!!
65+
}
66+
67+
val contentsFile = outputFile.get().asFile
68+
contentsFile.parentFile?.mkdirs()
69+
70+
val json = JsonOutput.prettyPrint(JsonOutput.toJson(orderedTopLevelMap))
71+
contentsFile.writeText("$json\n")
72+
println("Wrote \"${contentsFile.toPath()}\" with ${orderedTopLevelMap.size} top-level folder(s)")
73+
}
74+
75+
private fun buildFolderNode(folder: File): Pair<Boolean, Map<String, Any?>> {
76+
val files = folder.listFiles() ?: emptyArray()
77+
val messageIds = files.filter { it.isFile && it.name.endsWith(".eml") }
78+
.map { it.name.removeSuffix(".eml") }
79+
.sorted()
80+
81+
val subFolderNodes = files.filter { it.isDirectory }
82+
.sortedBy { it.name }
83+
.mapNotNull { subFolder ->
84+
buildFolderNode(subFolder).takeIf { it.first }?.let { subFolder.name to it.second }
85+
}
86+
.toMap(LinkedHashMap())
87+
88+
val shouldInclude = messageIds.isNotEmpty() || subFolderNodes.isNotEmpty() || isSpecialFolder(folder.name)
89+
90+
val node = linkedMapOf<String, Any?>(
91+
"name" to displayName(folder.name),
92+
"type" to folderType(folder.name),
93+
"messageServerIds" to messageIds,
94+
)
95+
if (subFolderNodes.isNotEmpty()) {
96+
node["subFolders"] = subFolderNodes
97+
}
98+
return shouldInclude to node
99+
}
100+
101+
private fun isSpecialFolder(name: String) = SPECIAL_FOLDERS.contains(name.lowercase())
102+
103+
private fun folderType(name: String) = when (name.lowercase()) {
104+
"inbox" -> "INBOX"
105+
"drafts" -> "DRAFTS"
106+
"sent" -> "SENT"
107+
"spam" -> "SPAM"
108+
"trash" -> "TRASH"
109+
"archive" -> "ARCHIVE"
110+
else -> "REGULAR"
111+
}
112+
113+
private fun displayName(name: String): String {
114+
return name.replace('-', ' ')
115+
.replace('_', ' ')
116+
.split(' ')
117+
.filter { it.isNotBlank() }
118+
.joinToString(" ") { it.lowercase().replaceFirstChar { c -> c.titlecase() } }
119+
}
120+
121+
companion object {
122+
private val SPECIAL_FOLDERS = listOf("inbox", "drafts", "sent", "spam", "trash", "archive")
123+
}
124+
}

backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoDataLoader.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ internal class DemoDataLoader {
1111

1212
@OptIn(ExperimentalSerializationApi::class)
1313
fun loadFolders(): DemoFolders {
14-
return getResourceAsStream("/contents.json").use { inputStream ->
14+
return getResourceAsStream("/mailbox/contents.json").use { inputStream ->
1515
Json.decodeFromStream<DemoFolders>(inputStream)
1616
}
1717
}
1818

1919
fun loadMessage(folderServerId: String, messageServerId: String): Message {
20-
return getResourceAsStream("/$folderServerId/$messageServerId.eml").use { inputStream ->
20+
return getResourceAsStream("/mailbox/$folderServerId/$messageServerId.eml").use { inputStream ->
2121
MimeMessage.parseMimeMessage(inputStream, false).apply {
2222
uid = messageServerId
2323
}

backend/demo/src/main/resources/contents.json

Lines changed: 0 additions & 88 deletions
This file was deleted.

0 commit comments

Comments
 (0)