Fix/code review findings #1

Merged
JMR-dev merged 23 commits from fix/code-review-findings into main 2026-06-30 03:02:15 +00:00
101 changed files with 7108 additions and 375 deletions
+164
View File
@@ -0,0 +1,164 @@
# SPDX-License-Identifier: GPL-3.0-or-later
name: CI
on:
pull_request:
branches: [main]
# A new push to a PR cancels any in-flight run for that PR.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# SDK packages this project builds against (compileSdk 37 / build-tools 37.0.0).
# Quote the package ids when passed to sdkmanager — the ';' is a shell separator.
ANDROID_PLATFORM: "platforms;android-37.0"
ANDROID_BUILD_TOOLS: "build-tools;37.0.0"
jobs:
debug-build:
name: Debug build
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Assemble debug APK
run: ./gradlew assembleDebug --stacktrace
- name: Upload debug APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
unit-tests:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Run unit tests
run: ./gradlew testDebugUnitTest --stacktrace
- name: Upload unit test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unit-test-report
path: app/build/reports/tests/testDebugUnitTest/
if-no-files-found: warn
instrumented-tests:
name: Instrumented / UI tests (emulator)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# minSdk is 33; the instrumented suite (SQLCipher, Keystore, Room, Compose UI,
# mail providers) needs no API-37-specific behavior, so it runs on a stable,
# widely-available emulator image rather than the bleeding-edge android-37.0.
api-level: [35]
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# The hardware-accelerated emulator needs KVM, which is gated behind a udev rule.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Cache AVD snapshot
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ matrix.api-level }}-google_apis-x86_64
# On a cache miss, cold-boot the emulator once so its snapshot can be cached,
# making subsequent runs start from a warm snapshot.
- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew connectedDebugAndroidTest --stacktrace
- name: Upload instrumented test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: instrumented-test-report-api${{ matrix.api-level }}
path: app/build/reports/androidTests/connected/
if-no-files-found: warn
+77
View File
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: GPL-3.0-or-later
name: Release
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to create (e.g. v0.1.0)"
required: true
type: string
prerelease:
description: "Mark this GitHub release as a pre-release"
required: false
type: boolean
default: true
# Needed to create the tag, the GitHub release, and upload its assets.
permissions:
contents: write
env:
ANDROID_PLATFORM: "platforms;android-37.0"
ANDROID_BUILD_TOOLS: "build-tools;37.0.0"
jobs:
release:
name: Build APK and publish release
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# No release keystore is configured in CI, so the build falls back to the debug
# signing key (installable for testing, not for store publication).
- name: Assemble release APK
run: ./gradlew assembleRelease --stacktrace
- name: Stage release artifacts (APK + source archives)
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
mkdir -p dist
apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
cp "$apk" "dist/LibreMail-${tag}.apk"
# Source archives contain only git-tracked files at the released commit.
git archive --format=zip --prefix="LibreMail-${tag}/" -o "dist/LibreMail-${tag}-src.zip" HEAD
git archive --format=tar --prefix="LibreMail-${tag}/" HEAD | gzip > "dist/LibreMail-${tag}-src.tar.gz"
ls -l dist
- name: Create GitHub release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ inputs.tag }}
name: ${{ inputs.tag }}
target_commitish: ${{ github.sha }}
prerelease: ${{ inputs.prerelease }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/LibreMail-${{ inputs.tag }}.apk
dist/LibreMail-${{ inputs.tag }}-src.zip
dist/LibreMail-${{ inputs.tag }}-src.tar.gz
+35 -4
View File
@@ -5,13 +5,25 @@ A free and open-source email client for Android, built with Kotlin, Jetpack
Compose and Material 3 (Material You). LibreMail aims for a friendly default
experience with power-user features tucked under an **Advanced Settings** group.
> Status: **early scaffold.** The app builds and runs with a themed, navigable
> shell and the full architecture skeleton in place. Account sign-in, IMAP/SMTP
> sync and sending are implemented in subsequent increments.
> Status: **in development.** Material You shell; **account setup** (Gmail OAuth via
> AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore-
> encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local
> Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and
> rendered in a hardened WebView (JavaScript off, remote images blocked by default),
> with mark-read, star, and delete; and **composing** — a compose screen with device-
> contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued
> and retried, with a viewable outbox folder), plus reply and **drafts** saved for
> later; **on-device new-mail
> notifications** (no push service) with persisted settings; **instant push** via a
> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a
> system viewer, and attach files when composing; **multiple accounts** — a unified inbox
> with per-account filtering; and
> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft**
> accounts (OAuth 2.0 sign-in, IMAP receive + Microsoft Graph send, SMTP/XOAUTH2 fallback).
## Features (target MVP)
- Send and receive email with **Gmail** (OAuth 2.0) and **any IMAP/SMTP** provider.
- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider.
- Material You dynamic theming, light/dark, edge-to-edge.
- Clean compose screen with phone/account contacts integration.
- Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail.
@@ -77,6 +89,25 @@ assessment for the restricted scope.
5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set
`GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`.
## Outlook / Microsoft account setup (OAuth client)
Outlook uses the Microsoft identity platform with OAuth 2.0 + PKCE (no client secret). Send
goes through Microsoft **Graph** (`sendMail`, their preferred API) with SMTP/XOAUTH2 as a
fallback; receive is **IMAP**. Graph and Exchange Online are separate resources, so one
consent grants every scope and per-resource access tokens are minted from the one refresh
token. A working client ID ships with the build; to use your own Azure app registration:
1. [Azure portal](https://portal.azure.com/) → **App registrations → New registration.**
Supported account types: *Accounts in any organizational directory and personal Microsoft
accounts*.
2. **Authentication → Add a platform → Mobile and desktop applications**; add the redirect
URI `org.libremail.outlook://oauth2redirect` and enable **Allow public client flows**.
3. **API permissions** (delegated): **Microsoft Graph → `Mail.Send`** (primary send), plus
**Office 365 Exchange Online → `IMAP.AccessAsUser.All` and `SMTP.Send`** (receive + SMTP
fallback). `openid`/`email`/`offline_access` come from OIDC.
4. Copy the **Application (client) ID** into `secrets.properties` as
`OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default).
## Architecture
Offline-first, unidirectional, layered:
+63 -3
View File
@@ -18,6 +18,26 @@ val secrets = Properties().apply {
}
val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "")
// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client
// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid
// until a real client id is set in secrets.properties.
val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) {
"com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com")
} else {
"org.libremail.oauth"
}
// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via
// secrets.properties; defaults to the app's registered client id.
val outlookOAuthClientId: String = secrets.getProperty(
"OUTLOOK_OAUTH_CLIENT_ID",
"04e4aa5e-ed1f-47f9-b567-b99a0b29b3df",
)
// Optional release signing, configured via git-ignored secrets.properties. When absent, release
// builds fall back to the debug key (installable for testing, but not publishable).
val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE")
android {
namespace = "org.libremail"
compileSdk = 37
@@ -32,17 +52,38 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"")
// AppAuth redirect scheme (consumed when OAuth is wired up in a later increment).
manifestPlaceholders["appAuthRedirectScheme"] = "org.libremail.app"
buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"")
buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"")
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"")
// AppAuth captures the OAuth redirect via this custom scheme.
manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme
}
signingConfigs {
if (releaseStoreFile != null) {
create("release") {
storeFile = file(releaseStoreFile)
storePassword = secrets.getProperty("RELEASE_STORE_PASSWORD")
keyAlias = secrets.getProperty("RELEASE_KEY_ALIAS")
keyPassword = secrets.getProperty("RELEASE_KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Use a dedicated release keystore when configured in secrets.properties; otherwise fall
// back to the debug key so the build is still installable for local testing.
signingConfig = if (releaseStoreFile != null) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
@@ -65,6 +106,11 @@ android {
}
}
// Export Room schemas so migrations can be validated by instrumented MigrationTestHelper tests.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -74,6 +120,10 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.coroutines.android)
// Email transport (IMAP/SMTP) + OAuth
implementation(libs.angus.mail)
implementation(libs.appauth)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
@@ -85,17 +135,27 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
compileOnly(libs.error.prone.annotations)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.sqlcipher.android)
testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
testImplementation(libs.mockk)
testImplementation(libs.greenmail)
// The real org.json for unit tests (android.jar ships a stubbed, no-op version).
testImplementation("org.json:json:20231013")
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+31 -13
View File
@@ -1,14 +1,32 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# LibreMail ProGuard/R8 rules.
#
# Release builds are currently not minified (see app/build.gradle.kts). When
# minification is enabled for release, add keep rules for the email/auth stack:
#
# Jakarta / Angus Mail (reflection-based provider registration):
# -keep class jakarta.mail.** { *; }
# -keep class org.eclipse.angus.mail.** { *; }
# -keep class com.sun.mail.** { *; }
# -dontwarn jakarta.activation.**
#
# AppAuth:
# -keep class net.openid.appauth.** { *; }
# R8/ProGuard keep rules for LibreMail's release build.
# --- Jakarta / Angus Mail (IMAP/SMTP) ---
# Protocol providers (IMAP/SMTP stores and transports) are discovered via reflection and
# META-INF service files, so keep the mail and activation classes and their members intact.
-keep class jakarta.mail.** { *; }
-keep class jakarta.activation.** { *; }
-keep class org.eclipse.angus.mail.** { *; }
-keep class org.eclipse.angus.activation.** { *; }
-dontwarn jakarta.mail.**
-dontwarn jakarta.activation.**
-dontwarn org.eclipse.angus.**
-dontwarn com.sun.activation.**
# --- AppAuth ---
# AppAuth (de)serializes its models (AuthState, token responses) reflectively.
-keep class net.openid.appauth.** { *; }
-dontwarn net.openid.appauth.**
# --- Strip debug/verbose logging from release builds ---
# Drops Log.d/Log.v calls (and the evaluation of their arguments) so nothing like an
# account address is ever written to logcat in a release build.
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
}
# --- SQLCipher (opt-in encrypted cache) ---
# JNI-bound classes referenced by the native library; keep them intact.
-keep class net.zetetic.database.** { *; }
-dontwarn net.zetetic.database.**
@@ -0,0 +1,404 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "05d29b502db9e50c01ddcdf986be1660",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '05d29b502db9e50c01ddcdf986be1660')"
]
}
}
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.MessageEntity
/**
* Round-trips the cache database through [DatabaseEncryption] (plaintext → encrypted → plaintext),
* asserting the data and Room's schema version survive and the on-disk file is no longer readable
* as plaintext once encrypted. Requires a device/emulator — it loads SQLCipher's native library.
*/
@RunWith(AndroidJUnit4::class)
class DatabaseEncryptionTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val dbName = "enc_roundtrip_test.db"
private val dbFile: File get() = context.getDatabasePath(dbName)
// 64 hex chars == a 32-byte SQLCipher passphrase.
private val passphrase = "0123456789abcdef".repeat(4)
@Before
@After
fun clean() {
context.deleteDatabase(dbName)
dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() }
}
@Test
fun encryptsDecryptsAndPreservesData() = runBlocking<Unit> {
// Start with a plaintext Room database holding one row.
openPlaintext().apply {
messageDao().insertNew(listOf(message("acct:1")))
close()
}
assertFalse("freshly created DB is plaintext", DatabaseEncryption.isEncrypted(dbFile))
// Encrypt in place, then open through SQLCipher and confirm the row + schema survived.
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile))
openEncrypted().apply {
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
close()
}
// Decrypt back to plaintext and confirm the row is still there.
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile))
openPlaintext().apply {
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
close()
}
}
private fun openPlaintext(): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build()
private fun openEncrypted(): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName)
.openHelperFactory(SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false))
.build()
private fun message(id: String) = MessageEntity(
id = id,
accountId = "acct",
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "",
body = "",
timestampMillis = 1_000L,
isRead = false,
isStarred = false,
)
}
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.MessageEntity
/**
* Schema-behavior tests on the real (v7) Room database. (Migrations from versions before
* exportSchema was enabled can't be replayed with MigrationTestHelper, since their schema JSONs
* were never exported; exportSchema is now on so future migrations can be tested.)
*/
@RunWith(AndroidJUnit4::class)
class LibreMailDatabaseTest {
private lateinit var db: LibreMailDatabase
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
}
@After
fun tearDown() = db.close()
private fun message(id: String, body: String = "") = MessageEntity(
id = id,
accountId = "acct",
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "",
body = body,
timestampMillis = 1_000L,
isRead = false,
isStarred = false,
)
@Test
fun deletingMessageCascadesToItsAttachments() = runBlocking {
val messageDao = db.messageDao()
val attachmentDao = db.attachmentDao()
messageDao.insertNew(listOf(message("acct:1")))
attachmentDao.insert(listOf(AttachmentEntity("acct:1", 0, "report.pdf", "application/pdf", 10)))
assertEquals(1, attachmentDao.observeForMessage("acct:1").first().size)
messageDao.deleteById("acct:1")
assertTrue(
"attachment rows must cascade-delete with their message",
attachmentDao.observeForMessage("acct:1").first().isEmpty(),
)
}
@Test
fun searchRowsAreNotInboxAndAreCleared() = runBlocking {
val messageDao = db.messageDao()
messageDao.insertNew(listOf(message("acct:1").copy(inInbox = true)))
messageDao.insertNew(listOf(message("acct:2").copy(inInbox = false)))
assertEquals(listOf("acct:1"), messageDao.getInboxIdsForAccount("acct"))
messageDao.deleteSearchRows()
val remaining = messageDao.observeAll().first().map { it.id }
assertEquals(listOf("acct:1"), remaining)
}
}
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KeystoreCryptoTest {
private val crypto = KeystoreCrypto()
@Test
fun encrypt_then_decrypt_returns_original() {
val secret = "imap:app-password 🔒 {\"json\":true}"
val encrypted = crypto.encrypt(secret)
assertNotEquals(secret, encrypted)
assertEquals(secret, crypto.decrypt(encrypted))
}
@Test
fun encrypt_uses_a_fresh_iv_each_time() {
// Same plaintext must not produce identical ciphertext (random GCM IV).
assertNotEquals(crypto.encrypt("same"), crypto.encrypt("same"))
}
}
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import androidx.test.ext.junit.runners.AndroidJUnit4
import jakarta.mail.Session
import java.util.Properties
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
/**
* Guards the main "Jakarta/Angus Mail on Android" risk: that provider registration
* (META-INF/javamail.*) survives packaging, so IMAP/SMTP providers resolve at runtime.
*/
@RunWith(AndroidJUnit4::class)
class ImapProviderAndroidTest {
@Test
fun imap_and_imaps_store_providers_resolve() {
val session = Session.getInstance(Properties())
assertNotNull(session.getStore("imaps"))
assertNotNull(session.getStore("imap"))
}
@Test
fun smtp_transport_provider_resolves() {
val session = Session.getInstance(Properties())
assertNotNull(session.getTransport("smtp"))
}
}
@@ -1,28 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import androidx.compose.material3.Text
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.ui.theme.LibreMailTheme
@RunWith(AndroidJUnit4::class)
class AppSmokeTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun theme_rendersContent() {
composeTestRule.setContent {
LibreMailTheme {
Text("LibreMail")
}
}
composeTestRule.onNodeWithText("LibreMail").assertExists()
}
}
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import java.io.File
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import org.libremail.domain.model.Account
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
/**
* In-memory [AccountRepository] for Compose UI tests: serves a fixed account list, records the
* account/password handed to [addImapAccount], and returns a configurable connection-test result.
*/
class FakeAccountRepository(
accounts: List<Account> = emptyList(),
private var result: Result<List<String>> = Result.success(listOf("INBOX")),
) : AccountRepository {
private val accountsFlow = MutableStateFlow(accounts)
var addedAccount: Account? = null
private set
var addedPassword: String? = null
private set
override fun observeAccounts(): Flow<List<Account>> = accountsFlow
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> = result
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> {
addedAccount = account
addedPassword = password
return result
}
override suspend fun addOutlookAccount(
email: String,
accessToken: String,
authStateJson: String,
): Result<List<String>> = result
override suspend fun deleteAccount(id: String) {
accountsFlow.value = accountsFlow.value.filterNot { it.id == id }
}
}
/**
* In-memory [MailRepository] for Compose UI tests: records sent messages / saved drafts and returns
* a configurable [sendResult]. Read paths emit empty so screens render their empty states.
*/
class FakeMailRepository(
var sendResult: Result<Unit> = Result.success(Unit),
) : MailRepository {
val sentMessages = mutableListOf<OutgoingMessage>()
val savedDrafts = mutableListOf<Draft>()
val deletedDraftIds = mutableListOf<String>()
override fun observeMessages(): Flow<List<Message>> = flowOf(emptyList())
override suspend fun getMessage(id: String): Message? = null
override suspend fun openMessage(id: String): Result<Message> =
Result.failure(UnsupportedOperationException("not used in UI tests"))
override fun observeAttachments(messageId: String): Flow<List<Attachment>> = flowOf(emptyList())
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
Result.failure(UnsupportedOperationException("not used in UI tests"))
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = Result.success(Unit)
override suspend fun deleteMessage(id: String): Result<Unit> = Result.success(Unit)
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> {
sentMessages += outgoing
return sendResult
}
override fun observeDrafts(): Flow<List<Draft>> = flowOf(emptyList())
override suspend fun getDraft(id: String): Draft? = null
override suspend fun saveDraft(draft: Draft) {
savedDrafts += draft
}
override suspend fun deleteDraft(id: String) {
deletedDraftIds += id
}
override fun observeOutbox(): Flow<List<OutboxMessage>> = flowOf(emptyList())
override suspend fun cancelOutboxMessage(id: String) {}
override suspend fun retryOutbox() {}
override suspend fun searchServer(query: String) {}
override suspend fun clearSearchResults() {}
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/** UI test for the shared bottom navigation bar: it renders both tabs and reports the tapped tab. */
@RunWith(AndroidJUnit4::class)
class LibreMailBottomBarTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@Test
fun rendersBothTabs_andEmitsSelectionOnTap() {
var selected: TopDest? = null
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = { selected = it })
}
}
composeTestRule.onNodeWithText(string(R.string.nav_mailbox)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.nav_settings)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.nav_settings)).performClick()
assertEquals(TopDest.SETTINGS, selected)
}
}
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for manual IMAP/SMTP account setup. Drives the real [ManualSetupScreen] and a
* real [ManualSetupViewModel] backed by an in-memory [FakeAccountRepository].
*/
@RunWith(AndroidJUnit4::class)
class ManualSetupScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
// Build the view model once and capture it, so recomposition doesn't recreate it.
private fun setContent(
repository: FakeAccountRepository = FakeAccountRepository(),
onAccountAdded: () -> Unit = {},
) {
val viewModel = ManualSetupViewModel(repository)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ManualSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
}
}
}
private fun fillValidForm() {
composeTestRule.onNodeWithText(string(R.string.manual_email)).performTextInput("user@example.com")
composeTestRule.onNodeWithText(string(R.string.manual_password)).performTextInput("app-password")
composeTestRule.onNodeWithText(string(R.string.manual_imap_server)).performTextInput("imap.example.com")
composeTestRule.onNodeWithText(string(R.string.manual_smtp_server)).performTextInput("smtp.example.com")
}
@Test
fun testAndAddButton_isDisabledUntilRequiredFieldsAreFilled() {
setContent()
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsNotEnabled()
fillValidForm()
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsEnabled()
}
@Test
fun advancedToggle_revealsPortField() {
setContent()
composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule.onAllNodesWithText(string(R.string.manual_imap_port)).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertExists()
}
@Test
fun submit_onSuccess_invokesOnAccountAddedWithEnteredCredentials() {
val repository = FakeAccountRepository(result = Result.success(listOf("INBOX")))
var added = false
setContent(repository) { added = true }
fillValidForm()
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { added }
assertTrue(added)
assertEquals("user@example.com", repository.addedAccount?.email)
assertEquals("app-password", repository.addedPassword)
}
@Test
fun submit_onFailure_showsErrorAndStaysOnScreen() {
val repository = FakeAccountRepository(result = Result.failure(RuntimeException("Login failed")))
var added = false
setContent(repository) { added = true }
fillValidForm()
composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule.onAllNodesWithText("Login failed").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("Login failed").assertExists()
assertFalse(added)
}
}
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import android.Manifest
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.ServerConfig
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.FakeMailRepository
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for composing and sending a message. Drives the real [ComposeScreen] and a
* real [ComposeViewModel] backed by in-memory fakes plus the real (empty) device contacts provider.
*/
@RunWith(AndroidJUnit4::class)
class ComposeScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val account = Account(
id = "imap:me@example.com",
email = "me@example.com",
displayName = "Me",
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
)
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@Before
fun grantContactsPermission() {
// ComposeScreen requests READ_CONTACTS on first composition; pre-grant it (before the test
// calls setContent) so no system permission dialog appears to block the headless run.
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.uiAutomation.grantRuntimePermission(
instrumentation.targetContext.packageName,
Manifest.permission.READ_CONTACTS,
)
}
// Build the view model once and capture it, so recomposition doesn't recreate it.
private fun setContent(
mailRepository: FakeMailRepository = FakeMailRepository(),
onBack: () -> Unit = {},
) {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val viewModel = ComposeViewModel(
savedStateHandle = SavedStateHandle(),
mailRepository = mailRepository,
accountRepository = FakeAccountRepository(accounts = listOf(account)),
contactsRepository = ContactsRepository(context),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ComposeScreen(onBack = onBack, viewModel = viewModel)
}
}
}
@Test
fun sendButton_isDisabledUntilRecipientEntered() {
setContent()
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsNotEnabled()
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsEnabled()
}
@Test
fun send_withRecipient_sendsMessageAndClosesScreen() {
val mailRepository = FakeMailRepository()
var closed = false
setContent(mailRepository) { closed = true }
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_subject)).performTextInput("Hello")
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Body text")
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
val sent = mailRepository.sentMessages.single()
assertEquals("you@example.com", sent.to)
assertEquals("Hello", sent.subject)
assertEquals(account.id, sent.accountId)
composeTestRule.waitUntil(timeoutMillis = 5_000) { closed }
}
}
+54 -2
View File
@@ -1,12 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".LibreMailApplication"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -23,5 +29,51 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- WorkManager is initialized on-demand via Configuration.Provider, so remove the
default automatic initializer. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Holds a long-lived IMAP IDLE connection for instant push (opt-in via Advanced Settings). -->
<service
android:name=".push.IdleService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Shares downloaded attachments with viewer apps via a content:// URI. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
(mobile/desktop) redirect in the Azure app registration. -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="merge">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="org.libremail.outlook" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -2,7 +2,72 @@
package org.libremail
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.domain.repository.AccountRepository
import org.libremail.push.IdlePushManager
@HiltAndroidApp
class LibreMailApplication : Application()
class LibreMailApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var syncScheduler: SyncScheduler
@Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var accountRepository: AccountRepository
@Inject lateinit var idlePushManager: IdlePushManager
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
@Volatile
private var pushShouldBeActive = false
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
syncScheduler.schedulePeriodicSync()
// Run the IMAP IDLE push service only while it has something to do: the push setting is on
// AND at least one account exists. This starts it when the first account is added and stops
// it when the last is removed, reactively.
appScope.launch {
combine(
settingsRepository.settings.map { it.pushIdle },
accountRepository.observeAccounts().map { it.isNotEmpty() },
) { pushEnabled, hasAccounts -> pushEnabled && hasAccounts }
.distinctUntilChanged()
.collect { active ->
pushShouldBeActive = active
if (active) idlePushManager.start() else idlePushManager.stop()
}
}
}
/**
* Re-attempts starting the IDLE push service. A start from the background can be blocked
* (ForegroundServiceStartNotAllowedException) and is swallowed; because the active/inactive
* state hasn't changed, the collector above won't retry, so the foreground (MainActivity) calls
* this to recover. Safe to call repeatedly — starting an already-running service is a no-op.
*/
fun ensurePushStarted() {
if (pushShouldBeActive) idlePushManager.start()
}
}
@@ -1,23 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.libremail.data.settings.SettingsRepository
import org.libremail.ui.LibreMailApp
import org.libremail.ui.theme.LibreMailTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
override fun onStart() {
super.onStart()
// Foreground: recover IDLE push if a background start was previously blocked.
(application as? LibreMailApplication)?.ensurePushStarted()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LibreMailTheme {
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
LibreMailTheme(dynamicColor = dynamicColor) {
NotificationPermissionEffect()
LibreMailApp()
}
}
}
}
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
@Composable
private fun NotificationPermissionEffect() {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import org.json.JSONObject
import org.libremail.BuildConfig
/**
* Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted
* `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2.
*/
@Singleton
class GmailAuthManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token"),
)
/** False until a Google OAuth client id is provided in secrets.properties (see README). */
val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank()
fun createAuthIntent(): Intent {
val request = AuthorizationRequest.Builder(
serviceConfig,
BuildConfig.GMAIL_OAUTH_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI),
)
.setScope("openid email profile https://mail.google.com/")
.build()
return AuthorizationService(context).getAuthorizationRequestIntent(request)
}
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
val response = AuthorizationResponse.fromIntent(responseIntent)
val exception = AuthorizationException.fromIntent(responseIntent)
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
val service = AuthorizationService(context)
try {
val tokenResponse = suspendCancellableCoroutine { continuation ->
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed"))
}
}
}
val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
val email = emailFromIdToken(tokenResponse.idToken)
?: throw IllegalStateException("Could not read the account email from the token")
return OAuthResult(
email = email,
accessToken = tokenResponse.accessToken.orEmpty(),
authStateJson = authState.jsonSerializeString(),
)
} finally {
service.dispose()
}
}
/** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */
suspend fun freshAccessToken(authStateJson: String): FreshToken {
val authState = AuthState.jsonDeserialize(authStateJson)
val service = AuthorizationService(context)
try {
val accessToken = suspendCancellableCoroutine { continuation ->
authState.performActionWithFreshTokens(service) { token, _, error ->
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed"))
}
}
}
return FreshToken(
accessToken = accessToken,
authStateJson = authState.jsonSerializeString(),
accessTokenExpiry = authState.accessTokenExpirationTime,
)
} finally {
service.dispose()
}
}
private fun emailFromIdToken(idToken: String?): String? {
if (idToken.isNullOrBlank()) return null
return runCatching {
val payload = idToken.split(".").getOrNull(1) ?: return null
val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
JSONObject(json).optString("email").ifBlank { null }
}.getOrNull()
}
}
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
/** Result of a successful OAuth token exchange. */
data class OAuthResult(
val email: String,
val accessToken: String,
/** Serialized [net.openid.appauth.AuthState], stored encrypted for later token refresh. */
val authStateJson: String,
)
/** A freshly-refreshed access token plus the (possibly updated) AuthState to persist. */
data class FreshToken(
val accessToken: String,
val authStateJson: String,
/** Epoch-millis expiry of [accessToken], when the provider reported one (for caching). */
val accessTokenExpiry: Long? = null,
)
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.GrantTypeValues
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenRequest
import org.json.JSONObject
import org.libremail.BuildConfig
/**
* Outlook / Microsoft OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret.
*
* Send goes through Microsoft **Graph** (`sendMail`, their first-class/preferred API); IMAP
* receive — and SMTP send as a fallback — go through **Exchange Online**. Those are two distinct
* resources (`graph.microsoft.com` vs `outlook.office.com`), and Microsoft's token endpoint issues
* an access token for one resource per request, so a single consent grants every scope and we mint
* resource-specific access tokens from the one refresh token on demand. The "common" tenant accepts
* both personal Microsoft accounts and work/school (Microsoft 365).
*/
@Singleton
class OutlookAuthManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"),
Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/token"),
)
/** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */
val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank()
fun createAuthIntent(): Intent {
val request = AuthorizationRequest.Builder(
serviceConfig,
BuildConfig.OUTLOOK_OAUTH_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI),
)
// One consent covering both resources; per-resource access tokens are minted later.
.setScope("openid email $OFFLINE $GRAPH_SCOPE $OUTLOOK_SCOPE")
.build()
return AuthorizationService(context).getAuthorizationRequestIntent(request)
}
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
val response = AuthorizationResponse.fromIntent(responseIntent)
val exception = AuthorizationException.fromIntent(responseIntent)
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
val service = AuthorizationService(context)
try {
val tokenResponse = suspendCancellableCoroutine { continuation ->
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed"))
}
}
}
val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
val email = emailFromIdToken(tokenResponse.idToken)
?: throw IllegalStateException("Could not read the account email from the token")
// Mint an Exchange Online token so the caller can verify the account over IMAP.
val outlook = refreshForScope(authState, OUTLOOK_SCOPE)
return OAuthResult(
email = email,
accessToken = outlook.accessToken,
authStateJson = outlook.authStateJson,
)
} finally {
service.dispose()
}
}
/** A fresh Exchange Online (outlook.office.com) token for IMAP receive and SMTP-fallback send. */
suspend fun freshOutlookToken(authStateJson: String): FreshToken =
refreshForScope(AuthState.jsonDeserialize(authStateJson), OUTLOOK_SCOPE)
/** A fresh Microsoft Graph token for the primary `sendMail` send path. */
suspend fun freshGraphToken(authStateJson: String): FreshToken =
refreshForScope(AuthState.jsonDeserialize(authStateJson), GRAPH_SCOPE)
/** Redeems the stored refresh token for an access token scoped to a single resource. */
private suspend fun refreshForScope(authState: AuthState, scope: String): FreshToken {
val refreshToken = authState.refreshToken
?: throw IllegalStateException("No refresh token available; please sign in again")
val service = AuthorizationService(context)
try {
val request = TokenRequest.Builder(serviceConfig, BuildConfig.OUTLOOK_OAUTH_CLIENT_ID)
.setGrantType(GrantTypeValues.REFRESH_TOKEN)
.setRefreshToken(refreshToken)
.setScope("$OFFLINE $scope")
.build()
val tokenResponse = suspendCancellableCoroutine { continuation ->
service.performTokenRequest(request) { token, error ->
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed"))
}
}
}
authState.update(tokenResponse, null)
return FreshToken(
accessToken = tokenResponse.accessToken.orEmpty(),
authStateJson = authState.jsonSerializeString(),
accessTokenExpiry = tokenResponse.accessTokenExpirationTime,
)
} finally {
service.dispose()
}
}
/** Microsoft id tokens carry the address in `email`, falling back to `preferred_username`. */
private fun emailFromIdToken(idToken: String?): String? {
if (idToken.isNullOrBlank()) return null
return runCatching {
val payload = idToken.split(".").getOrNull(1) ?: return null
val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
val claims = JSONObject(json)
claims.optString("email").ifBlank { claims.optString("preferred_username") }.ifBlank { null }
}.getOrNull()
}
private companion object {
const val OFFLINE = "offline_access"
const val GRAPH_SCOPE = "https://graph.microsoft.com/Mail.Send"
const val OUTLOOK_SCOPE =
"https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send"
}
}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
import android.content.Context
import android.provider.ContactsContract.CommonDataKinds.Email
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** A device contact match for recipient autocomplete. */
data class ContactSuggestion(
val name: String,
val email: String,
)
/** Looks up device contacts (ContactsContract) for recipient autocomplete. */
@Singleton
class ContactsRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** Returns up to [LIMIT] contacts whose name or email matches [query]. Empty if no permission. */
suspend fun search(query: String): List<ContactSuggestion> = withContext(Dispatchers.IO) {
if (query.length < 2) return@withContext emptyList()
val projection = arrayOf(Email.ADDRESS, Email.DISPLAY_NAME_PRIMARY)
val selection = "${Email.ADDRESS} LIKE ? OR ${Email.DISPLAY_NAME_PRIMARY} LIKE ?"
val pattern = "%$query%"
val results = mutableListOf<ContactSuggestion>()
runCatching {
context.contentResolver.query(
Email.CONTENT_URI,
projection,
selection,
arrayOf(pattern, pattern),
"${Email.DISPLAY_NAME_PRIMARY} ASC",
)?.use { cursor ->
val addressIndex = cursor.getColumnIndexOrThrow(Email.ADDRESS)
val nameIndex = cursor.getColumnIndexOrThrow(Email.DISPLAY_NAME_PRIMARY)
val seen = HashSet<String>()
while (cursor.moveToNext() && results.size < LIMIT) {
val email = cursor.getString(addressIndex)?.trim().orEmpty()
if (email.isEmpty() || !seen.add(email.lowercase())) continue
val name = cursor.getString(nameIndex)?.takeIf { it.isNotBlank() } ?: email
results.add(ContactSuggestion(name, email))
}
}
}
results
}
private companion object {
const val LIMIT = 8
}
}
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.util.Log
import java.io.File
import net.zetetic.database.sqlcipher.SQLiteDatabase
/**
* Converts the Room database file between plaintext and SQLCipher-encrypted form, in place and
* idempotently. It is meant to run at startup — before Room opens the file — so there is never an
* open connection to race with. The on-disk form is detected from the file header (a plaintext
* SQLite file starts with the 16-byte magic "SQLite format 3"; an encrypted one does not),
* so the conversion self-heals: it re-runs after an interrupted attempt and no-ops when already in
* the desired form.
*/
object DatabaseEncryption {
/** True when the file exists and is NOT a plaintext SQLite database (i.e. it is encrypted). */
fun isEncrypted(dbFile: File): Boolean =
dbFile.exists() && dbFile.length() >= SQLITE_HEADER.size && !startsWithSqliteHeader(dbFile)
/** Ensures [dbFile] is SQLCipher-encrypted with [passphrase]; converts an existing plaintext DB. */
fun ensureEncrypted(dbFile: File, passphrase: String) {
if (!dbFile.exists() || dbFile.length() == 0L) return // fresh DB: the factory creates it encrypted
if (!startsWithSqliteHeader(dbFile)) return // already encrypted
migrate(dbFile, sourcePassphrase = "", targetPassphrase = passphrase)
}
/** Ensures [dbFile] is plaintext; decrypts an existing SQLCipher DB using [passphrase]. */
fun ensurePlaintext(dbFile: File, passphrase: String) {
if (!dbFile.exists() || dbFile.length() == 0L) return
if (startsWithSqliteHeader(dbFile)) return // already plaintext
migrate(dbFile, sourcePassphrase = passphrase, targetPassphrase = "")
}
/**
* Copies the database into a sibling temp file in the target form via `sqlcipher_export`, then
* atomically swaps it into place. An empty passphrase means "no encryption" on that side. Room's
* schema version (`PRAGMA user_version`) is carried across manually — `sqlcipher_export` copies
* tables but not that pragma, and a reset version would make Room attempt a bogus migration.
*/
private fun migrate(dbFile: File, sourcePassphrase: String, targetPassphrase: String) {
ensureLibraryLoaded()
val dir = dbFile.parentFile ?: error("database file has no parent directory")
val tmp = File(dir, dbFile.name + ".migrate").apply { delete() }
val userVersion: Int
val source = SQLiteDatabase.openOrCreateDatabase(
dbFile.absolutePath,
sourcePassphrase.toByteArray(Charsets.US_ASCII),
null, // no CursorFactory
null, // no DatabaseErrorHandler
)
try {
userVersion = source.version // PRAGMA user_version — Room's schema version
source.rawExecSQL("PRAGMA journal_mode = DELETE;") // fold any WAL back into the main file
val keyLiteral = targetPassphrase.replace("'", "''")
source.rawExecSQL("ATTACH DATABASE '${tmp.absolutePath}' AS target KEY '$keyLiteral';")
source.rawExecSQL("SELECT sqlcipher_export('target');")
source.rawExecSQL("DETACH DATABASE target;")
} finally {
source.close()
}
// `sqlcipher_export` copies tables but not PRAGMA user_version; carry Room's schema version
// onto the exported file (on its own main schema — a schema-qualified PRAGMA is rejected) so
// Room doesn't see version 0 and attempt a bogus migration.
check(tmp.length() > 0L) { "sqlcipher_export produced no output database" }
val target = SQLiteDatabase.openOrCreateDatabase(
tmp.absolutePath,
targetPassphrase.toByteArray(Charsets.US_ASCII),
null,
null,
)
try {
target.rawExecSQL("PRAGMA journal_mode = DELETE;") // no WAL sidecars to orphan on swap
target.version = userVersion
} finally {
target.close()
}
// Swap the converted file into place; drop any stale WAL/SHM sidecars from either file first.
listOf(dbFile.name, tmp.name).forEach { base ->
File(dir, "$base-wal").delete()
File(dir, "$base-shm").delete()
}
if (!tmp.renameTo(dbFile)) {
tmp.copyTo(dbFile, overwrite = true)
tmp.delete()
}
Log.d(TAG, "local cache database converted")
}
private fun startsWithSqliteHeader(dbFile: File): Boolean {
val head = ByteArray(SQLITE_HEADER.size)
val read = dbFile.inputStream().use { it.read(head) }
return read == SQLITE_HEADER.size && head.contentEquals(SQLITE_HEADER)
}
@Volatile private var libraryLoaded = false
private fun ensureLibraryLoaded() {
if (libraryLoaded) return
synchronized(this) {
if (!libraryLoaded) {
System.loadLibrary("sqlcipher")
libraryLoaded = true
}
}
}
private const val TAG = "LibreMailDbCrypto"
// The 16-byte magic that opens every plaintext SQLite file: "SQLite format 3" + a NUL terminator.
// Spelled out as bytes to keep the trailing NUL unambiguous.
private val SQLITE_HEADER = byteArrayOf(
0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66,
0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20, 0x33, 0x00,
)
}
@@ -4,16 +4,35 @@ package org.libremail.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.CredentialEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.OutboxEntity
@Database(
entities = [AccountEntity::class, MessageEntity::class],
version = 1,
exportSchema = false,
entities = [
AccountEntity::class,
MessageEntity::class,
CredentialEntity::class,
AttachmentEntity::class,
OutboxEntity::class,
DraftEntity::class,
],
version = 7,
exportSchema = true,
)
abstract class LibreMailDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun accountDao(): AccountDao
abstract fun credentialDao(): CredentialDao
abstract fun attachmentDao(): AttachmentDao
abstract fun outboxDao(): OutboxDao
abstract fun draftDao(): DraftDao
}
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.json.JSONArray
import org.json.JSONObject
import org.libremail.domain.model.Account
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.model.SmtpParams
import org.libremail.mail.AttachmentPart
import org.libremail.mail.FetchedMessage
internal fun AccountEntity.toDomain(): Account = Account(
id = id,
email = email,
displayName = displayName,
authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP),
imap = ServerConfig(imap.host, imap.port, imap.security.toMailSecurity()),
smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toMailSecurity()),
)
internal fun Account.toEntity(): AccountEntity = AccountEntity(
id = id,
email = email,
displayName = displayName,
authType = authType.name,
imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name),
smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name),
)
internal fun Account.toImapParams(
secret: String,
useXoauth2: Boolean,
strictStartTls: Boolean = true,
): ImapConnectionParams =
ImapConnectionParams(
host = imap.host,
port = imap.port,
security = imap.security,
username = email,
secret = secret,
useXoauth2 = useXoauth2,
strictStartTls = strictStartTls,
)
internal fun Account.toSmtpParams(
secret: String,
useXoauth2: Boolean,
strictStartTls: Boolean = true,
): SmtpParams =
SmtpParams(
host = smtp.host,
port = smtp.port,
security = smtp.security,
username = email,
secret = secret,
useXoauth2 = useXoauth2,
strictStartTls = strictStartTls,
)
internal fun MessageEntity.toDomain(): Message = Message(
id = id,
accountId = accountId,
sender = sender,
senderEmail = senderEmail,
subject = subject,
snippet = snippet,
body = body,
isHtml = isHtml,
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isStarred,
inInbox = inInbox,
)
internal fun FetchedMessage.toEntity(accountId: String, inInbox: Boolean = true): MessageEntity = MessageEntity(
id = "$accountId:$uid",
accountId = accountId,
sender = sender,
senderEmail = senderEmail,
subject = subject,
snippet = "",
body = "",
isHtml = false,
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isFlagged,
inInbox = inInbox,
bodyFetched = false,
)
internal fun AttachmentEntity.toDomain(): Attachment = Attachment(
messageId = messageId,
partIndex = partIndex,
filename = filename,
mimeType = mimeType,
sizeBytes = sizeBytes,
)
internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = AttachmentEntity(
messageId = messageId,
partIndex = partIndex,
filename = filename,
mimeType = mimeType,
sizeBytes = sizeBytes,
)
internal fun DraftEntity.toDomain(): Draft = Draft(
id = id,
accountId = accountId,
to = toAddresses,
cc = ccAddresses,
subject = subject,
body = body,
updatedAt = updatedAt,
attachments = attachments.toOutgoingAttachments(),
)
internal fun Draft.toEntity(): DraftEntity = DraftEntity(
id = id,
accountId = accountId,
toAddresses = to,
ccAddresses = cc,
subject = subject,
body = body,
updatedAt = updatedAt,
attachments = attachments.toJson(),
)
/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */
private fun List<OutgoingAttachment>.toJson(): String {
if (isEmpty()) return ""
val array = JSONArray()
forEach { array.put(JSONObject().put("uri", it.uri).put("name", it.name)) }
return array.toString()
}
private fun String.toOutgoingAttachments(): List<OutgoingAttachment> {
if (isBlank()) return emptyList()
return runCatching {
val array = JSONArray(this)
(0 until array.length()).map { i ->
val obj = array.getJSONObject(i)
OutgoingAttachment(obj.getString("uri"), obj.optString("name"))
}
}.getOrDefault(emptyList())
}
internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage(
id = id,
to = toAddresses,
subject = subject,
body = body,
createdAt = createdAt,
lastError = lastError,
)
private fun String.toMailSecurity(): MailSecurity =
runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS)
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/** v1 -> v2: add the encrypted-credentials table (preserves existing accounts/messages). */
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `credentials` (" +
"`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, " +
"PRIMARY KEY(`accountId`))",
)
}
}
/** v2 -> v3: add the [isHtml] flag to cached messages (preserves existing data). */
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE messages ADD COLUMN isHtml INTEGER NOT NULL DEFAULT 0")
}
}
/** v3 -> v4: add the attachments metadata table (preserves existing data). */
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `attachments` (" +
"`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, " +
"`mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, " +
"PRIMARY KEY(`messageId`, `partIndex`))",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `attachments` (`messageId`)",
)
}
}
/** v4 -> v5: add the outbox table for queued outgoing mail (preserves existing data). */
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `outbox` (" +
"`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, " +
"`ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, " +
"`createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
)
}
}
/** v5 -> v6: add the drafts table for saved unsent mail (preserves existing data). */
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `drafts` (" +
"`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, " +
"`ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, " +
"`updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
)
}
}
/**
* v6 -> v7 (preserves existing data). Rebuilds three tables to converge on Room's canonical
* schema and add new columns:
* - `messages`: drop the stray `DEFAULT 0` that MIGRATION_2_3 left on `isHtml` (so upgraded and
* fresh installs validate identically), and add `inInbox` (server-search hits are kept out of
* the inbox) and `bodyFetched` (distinguishes "not fetched yet" from "fetched, empty body").
* - `attachments`: add an ON DELETE CASCADE foreign key to `messages` so attachment rows can no
* longer be orphaned, dropping any pre-existing orphans in the process.
* - `drafts`: add the `attachments` column so a draft round-trips its attachments.
*/
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
// messages
db.execSQL(
"CREATE TABLE `messages_new` (" +
"`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, " +
"`senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, " +
"`body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, " +
"`isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `inInbox` INTEGER NOT NULL, " +
"`bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
)
db.execSQL(
"INSERT INTO `messages_new` " +
"SELECT id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, 1, (CASE WHEN body <> '' THEN 1 ELSE 0 END) " +
"FROM `messages`",
)
db.execSQL("DROP TABLE `messages`")
db.execSQL("ALTER TABLE `messages_new` RENAME TO `messages`")
db.execSQL("CREATE INDEX `index_messages_accountId` ON `messages` (`accountId`)")
db.execSQL("CREATE INDEX `index_messages_timestampMillis` ON `messages` (`timestampMillis`)")
// attachments (now with an ON DELETE CASCADE FK; orphans are dropped by the WHERE filter)
db.execSQL(
"CREATE TABLE `attachments_new` (" +
"`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, " +
"`mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, " +
"PRIMARY KEY(`messageId`, `partIndex`), " +
"FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE)",
)
db.execSQL(
"INSERT INTO `attachments_new` " +
"SELECT messageId, partIndex, filename, mimeType, sizeBytes FROM `attachments` " +
"WHERE messageId IN (SELECT id FROM `messages`)",
)
db.execSQL("DROP TABLE `attachments`")
db.execSQL("ALTER TABLE `attachments_new` RENAME TO `attachments`")
db.execSQL("CREATE INDEX `index_attachments_messageId` ON `attachments` (`messageId`)")
// drafts
db.execSQL(
"CREATE TABLE `drafts_new` (" +
"`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, " +
"`ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, " +
"`updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
)
db.execSQL(
"INSERT INTO `drafts_new` " +
"SELECT id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, '' " +
"FROM `drafts`",
)
db.execSQL("DROP TABLE `drafts`")
db.execSQL("ALTER TABLE `drafts_new` RENAME TO `drafts`")
}
}
@@ -13,6 +13,15 @@ interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY email")
fun observeAll(): Flow<List<AccountEntity>>
@Query("SELECT * FROM accounts ORDER BY email")
suspend fun getAll(): List<AccountEntity>
@Query("SELECT * FROM accounts WHERE id = :id LIMIT 1")
suspend fun getById(id: String): AccountEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(account: AccountEntity)
@Query("DELETE FROM accounts WHERE id = :id")
suspend fun deleteById(id: String)
}
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.AttachmentEntity
@Dao
interface AttachmentDao {
@Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
fun observeForMessage(messageId: String): Flow<List<AttachmentEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(attachments: List<AttachmentEntity>)
@Query("DELETE FROM attachments WHERE messageId = :messageId")
suspend fun deleteForMessage(messageId: String)
/** Replaces the cached attachment list for a message in one transaction. */
@Transaction
suspend fun replaceForMessage(messageId: String, attachments: List<AttachmentEntity>) {
deleteForMessage(messageId)
insert(attachments)
}
}
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import org.libremail.data.local.entity.CredentialEntity
@Dao
interface CredentialDao {
@Query("SELECT * FROM credentials WHERE accountId = :accountId LIMIT 1")
suspend fun getById(accountId: String): CredentialEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(credential: CredentialEntity)
@Query("DELETE FROM credentials WHERE accountId = :accountId")
suspend fun deleteById(accountId: String)
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.DraftEntity
@Dao
interface DraftDao {
@Query("SELECT * FROM drafts ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<DraftEntity>>
@Query("SELECT COUNT(*) FROM drafts")
fun observeCount(): Flow<Int>
@Query("SELECT * FROM drafts WHERE id = :id LIMIT 1")
suspend fun getById(id: String): DraftEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(draft: DraftEntity)
@Query("DELETE FROM drafts WHERE id = :id")
suspend fun delete(id: String)
}
@@ -16,6 +16,59 @@ interface MessageDao {
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
suspend fun getById(id: String): MessageEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(messages: List<MessageEntity>)
/** Ids of an account's inbox rows (excludes transient server-search hits). */
@Query("SELECT id FROM messages WHERE accountId = :accountId AND inInbox = 1")
suspend fun getInboxIdsForAccount(accountId: String): List<String>
/** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNew(messages: List<MessageEntity>)
/**
* Refreshes the display fields from the server without touching the cached body, the local
* read/star flags (which may hold an optimistic change the server hasn't reflected yet), or the
* inbox membership.
*/
@Query(
"UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " +
"timestampMillis = :timestampMillis WHERE id = :id",
)
suspend fun updateHeaderContent(
id: String,
sender: String,
senderEmail: String,
subject: String,
timestampMillis: Long,
)
/** Marks rows as belonging to the inbox (e.g. a former search-only row that the sync now returns). */
@Query("UPDATE messages SET inInbox = 1 WHERE id IN (:ids)")
suspend fun markInInbox(ids: List<String>)
@Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, bodyFetched = 1 WHERE id = :id")
suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String)
@Query("UPDATE messages SET isRead = :isRead WHERE id = :id")
suspend fun setRead(id: String, isRead: Boolean)
@Query("UPDATE messages SET isStarred = :isStarred WHERE id = :id")
suspend fun setStarred(id: String, isStarred: Boolean)
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM messages WHERE accountId = :accountId")
suspend fun deleteByAccount(accountId: String)
/** Clears only an account's inbox rows (leaves any in-flight search-only rows). */
@Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1")
suspend fun deleteInboxByAccount(accountId: String)
/** Drops inbox rows for an account that are no longer present on the server. */
@Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1 AND id NOT IN (:keepIds)")
suspend fun deleteInboxNotIn(accountId: String, keepIds: List<String>)
/** Removes transient server-search hits (called when search closes). */
@Query("DELETE FROM messages WHERE inInbox = 0")
suspend fun deleteSearchRows()
}
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.OutboxEntity
@Dao
interface OutboxDao {
@Insert
suspend fun insert(message: OutboxEntity)
@Query("SELECT * FROM outbox ORDER BY createdAt")
suspend fun getAll(): List<OutboxEntity>
@Query("SELECT * FROM outbox ORDER BY createdAt")
fun observeAll(): Flow<List<OutboxEntity>>
@Query("SELECT COUNT(*) FROM outbox")
fun observeCount(): Flow<Int>
@Query("UPDATE outbox SET lastError = :error WHERE id = :id")
suspend fun setError(id: String, error: String?)
@Query("DELETE FROM outbox WHERE id = :id")
suspend fun delete(id: String)
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -11,4 +12,14 @@ data class AccountEntity(
val displayName: String,
/** Persisted name of [org.libremail.domain.model.AuthType]. */
val authType: String,
@Embedded(prefix = "imap_") val imap: ServerConfigEmbedded,
@Embedded(prefix = "smtp_") val smtp: ServerConfigEmbedded,
)
/** Embedded host/port/security columns (prefixed per server in [AccountEntity]). */
data class ServerConfigEmbedded(
val host: String,
val port: Int,
/** Persisted name of [org.libremail.domain.model.MailSecurity]. */
val security: String,
)
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
/** Cached metadata for one attachment part of a message (the bytes are fetched on demand). */
@Entity(
tableName = "attachments",
primaryKeys = ["messageId", "partIndex"],
foreignKeys = [
ForeignKey(
entity = MessageEntity::class,
parentColumns = ["id"],
childColumns = ["messageId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("messageId")],
)
data class AttachmentEntity(
val messageId: String,
val partIndex: Int,
val filename: String,
val mimeType: String,
val sizeBytes: Long,
)
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Encrypted secret for an account: either an OAuth [net.openid.appauth.AuthState] JSON
* blob or an IMAP password. The value is AES-GCM ciphertext (see KeystoreCrypto), never
* plaintext.
*/
@Entity(tableName = "credentials")
data class CredentialEntity(
@PrimaryKey val accountId: String,
val encryptedSecret: String,
)
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/** A composed-but-unsent message saved for later editing. */
@Entity(tableName = "drafts")
data class DraftEntity(
@PrimaryKey val id: String,
val accountId: String?,
val toAddresses: String,
val ccAddresses: String,
val subject: String,
val body: String,
val updatedAt: Long,
/** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */
val attachments: String = "",
)
@@ -17,7 +17,12 @@ data class MessageEntity(
val subject: String,
val snippet: String,
val body: String,
val isHtml: Boolean = false,
val timestampMillis: Long,
val isRead: Boolean,
val isStarred: Boolean,
/** True for inbox-synced rows; false for transient server-search hits (purged on search close). */
val inInbox: Boolean = true,
/** True once the body has been fetched from the server (distinguishes "not fetched" from "empty body"). */
val bodyFetched: Boolean = false,
)
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/** A message queued for sending. The send worker drains this table and deletes rows on success. */
@Entity(tableName = "outbox")
data class OutboxEntity(
@PrimaryKey val id: String,
val accountId: String,
val toAddresses: String,
val ccAddresses: String,
val subject: String,
val body: String,
val createdAt: Long,
val lastError: String? = null,
)
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.repository
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
import org.libremail.data.local.toImapParams
import org.libremail.data.security.CredentialStore
import org.libremail.data.sync.SyncScheduler
import org.libremail.domain.model.Account
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.repository.AccountRepository
import org.libremail.mail.ImapClient
@Singleton
class AccountRepositoryImpl @Inject constructor(
private val accountDao: AccountDao,
private val messageDao: MessageDao,
private val credentialStore: CredentialStore,
private val imapClient: ImapClient,
private val syncScheduler: SyncScheduler,
) : AccountRepository {
override fun observeAccounts(): Flow<List<Account>> =
accountDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> =
runCatching { imapClient.listFolders(params) }
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = runCatching {
val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false))
accountDao.upsert(account.toEntity())
credentialStore.saveSecret(account.id, password)
syncScheduler.syncNow()
folders
}
override suspend fun addOutlookAccount(
email: String,
accessToken: String,
authStateJson: String,
): Result<List<String>> = runCatching {
val account = Account.outlook(email)
val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true))
accountDao.upsert(account.toEntity())
credentialStore.saveSecret(account.id, authStateJson)
syncScheduler.syncNow()
folders
}
override suspend fun deleteAccount(id: String) {
accountDao.deleteById(id)
credentialStore.delete(id)
// Remove the account's cached mail (attachment rows cascade via the foreign key).
messageDao.deleteByAccount(id)
}
}
@@ -1,58 +1,197 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.repository
import android.content.Context
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.mail.Flags
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.sample.SampleData
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.data.sync.SendScheduler
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.MailRepository
import org.libremail.mail.DownloadedAttachment
import org.libremail.mail.ImapClient
@Singleton
class MailRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val messageDao: MessageDao,
private val accountDao: AccountDao,
private val attachmentDao: AttachmentDao,
private val outboxDao: OutboxDao,
private val draftDao: DraftDao,
private val imapClient: ImapClient,
private val connectionFactory: MailConnectionFactory,
private val sendScheduler: SendScheduler,
) : MailRepository {
// Room is the single source of truth. Until sync lands the cache is empty, so
// we fall back to bundled sample data to keep the UI populated.
override fun observeMessages(): Flow<List<Message>> =
messageDao.observeAll().map { cached ->
if (cached.isEmpty()) SampleData.messages else cached.map { it.toDomain() }
}
messageDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override fun observeAccounts(): Flow<List<Account>> =
accountDao.observeAll().map { rows ->
rows.map { row ->
Account(
id = row.id,
email = row.email,
displayName = row.displayName,
authType = runCatching { AuthType.valueOf(row.authType) }
.getOrDefault(AuthType.PASSWORD_IMAP),
)
override suspend fun getMessage(id: String): Message? = messageDao.getById(id)?.toDomain()
override suspend fun openMessage(id: String): Result<Message> = runCatching {
val entity = messageDao.getById(id) ?: error("Message not found")
val account = accountDao.getById(entity.accountId)?.toDomain()
if (account != null) {
val params = connectionFactory.imapParamsFor(account)
if (!entity.bodyFetched) {
val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id))
messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body))
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
messageDao.setRead(id, true)
} else if (!entity.isRead) {
runCatching { imapClient.setFlag(params, uidOf(id), Flags.Flag.SEEN, true) }
messageDao.setRead(id, true)
}
}
messageDao.getById(id)?.toDomain() ?: error("Message not found")
}
override suspend fun getMessage(id: String): Message? =
messageDao.getById(id)?.toDomain() ?: SampleData.byId(id)
override fun observeAttachments(messageId: String): Flow<List<Attachment>> =
attachmentDao.observeForMessage(messageId).map { rows -> rows.map { it.toDomain() } }
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> = runCatching {
val entity = messageDao.getById(messageId) ?: error("Message not found")
val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found")
val params = connectionFactory.imapParamsFor(account)
saveToCache(imapClient.fetchAttachment(params, uidOf(messageId), partIndex))
}
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = runCatching {
messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure
accountFor(id)?.let { account ->
imapClient.setFlag(connectionFactory.imapParamsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred)
}
Unit
}
override suspend fun deleteMessage(id: String): Result<Unit> = runCatching {
val account = accountFor(id)
messageDao.deleteById(id) // optimistic; reappears on next sync if the server delete failed
account?.let { imapClient.deleteMessage(connectionFactory.imapParamsFor(it), uidOf(id)) }
Unit
}
/** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> = runCatching {
requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" }
val outboxId = UUID.randomUUID().toString()
copyAttachments(outboxId, outgoing.attachments)
outboxDao.insert(
OutboxEntity(
id = outboxId,
accountId = outgoing.accountId,
toAddresses = outgoing.to,
ccAddresses = outgoing.cc,
subject = outgoing.subject,
body = outgoing.body,
createdAt = System.currentTimeMillis(),
),
)
sendScheduler.sendNow()
}
/**
* Copies the picked attachment URIs into the outbox message's own directory for the worker.
* Each attachment goes in its own index-named subdirectory so the send worker can restore the
* original order (a flat listing's order is unspecified), keeping the file's real name intact.
*/
private fun copyAttachments(outboxId: String, attachments: List<OutgoingAttachment>) {
if (attachments.isEmpty()) return
attachments.forEachIndexed { index, attachment ->
val safeName = attachment.name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
val dir = File(context.cacheDir, "outbox/$outboxId/$index").apply { mkdirs() }
runCatching {
context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input ->
File(dir, safeName).outputStream().use { output -> input.copyTo(output) }
}
}
}
}
override fun observeDrafts(): Flow<List<Draft>> =
draftDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override suspend fun getDraft(id: String): Draft? = draftDao.getById(id)?.toDomain()
override suspend fun saveDraft(draft: Draft) = draftDao.upsert(draft.toEntity())
override suspend fun deleteDraft(id: String) = draftDao.delete(id)
override fun observeOutbox(): Flow<List<OutboxMessage>> =
outboxDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override suspend fun cancelOutboxMessage(id: String) {
outboxDao.delete(id)
File(context.cacheDir, "outbox/$id").deleteRecursively()
}
override suspend fun retryOutbox() = sendScheduler.sendNow()
override suspend fun searchServer(query: String) {
accountDao.getAll().forEach { entity ->
val account = entity.toDomain()
runCatching {
val results = imapClient.search(connectionFactory.imapParamsFor(account), query, SEARCH_LIMIT)
// Mark hits as non-inbox so they show only while searching (and never overwrite the
// inbox membership of a row that is genuinely in the inbox).
val entities = results.map { it.toEntity(account.id, inInbox = false) }
messageDao.insertNew(entities)
entities.forEach {
messageDao.updateHeaderContent(
id = it.id,
sender = it.sender,
senderEmail = it.senderEmail,
subject = it.subject,
timestampMillis = it.timestampMillis,
)
}
}
}
}
override suspend fun clearSearchResults() = messageDao.deleteSearchRows()
/** Writes downloaded bytes to a private cache file that the FileProvider can share. */
private fun saveToCache(attachment: DownloadedAttachment): File {
val dir = File(context.cacheDir, "attachments").apply { mkdirs() }
val safeName = attachment.filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
return File(dir, safeName).also { file ->
file.outputStream().use { it.write(attachment.bytes) }
}
}
private suspend fun accountFor(id: String): Account? {
val entity = messageDao.getById(id) ?: return null
return accountDao.getById(entity.accountId)?.toDomain()
}
}
private fun MessageEntity.toDomain(): Message = Message(
id = id,
accountId = accountId,
sender = sender,
senderEmail = senderEmail,
subject = subject,
snippet = snippet,
body = body,
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isStarred,
)
private const val SEARCH_LIMIT = 50
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
private fun uidOf(id: String): String = id.substringAfterLast(':')
private fun snippetOf(body: String): String =
body.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim().take(140)
@@ -1,62 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sample
import org.libremail.domain.model.Message
/**
* Placeholder content shown until real IMAP sync lands (next increment), so the
* mailbox and reader are populated when the local cache is still empty.
*/
object SampleData {
private const val MINUTE = 60_000L
private const val HOUR = 60 * MINUTE
private val now = System.currentTimeMillis()
val messages: List<Message> = listOf(
Message(
id = "sample-1",
accountId = "sample",
sender = "LibreMail",
senderEmail = "hello@libremail.org",
subject = "Welcome to LibreMail",
snippet = "Thanks for trying LibreMail — a free, open-source email client.",
body = "Thanks for trying LibreMail!\n\n" +
"This is placeholder content. Account sign-in, IMAP sync and sending " +
"arrive in upcoming increments. The screen you are looking at is wired " +
"to the same ViewModel → Repository → Room pipeline the real data will use.",
timestampMillis = now - 5 * MINUTE,
isRead = false,
isStarred = true,
),
Message(
id = "sample-2",
accountId = "sample",
sender = "Material You",
senderEmail = "design@android.example",
subject = "Your theme follows the wallpaper",
snippet = "On Android 12+ the colors you see are derived from your wallpaper.",
body = "LibreMail uses Material 3 dynamic color. On Android 12 and newer, the " +
"accent colors are generated from your wallpaper. On older versions it falls " +
"back to the LibreMail brand palette.",
timestampMillis = now - 3 * HOUR,
isRead = false,
isStarred = false,
),
Message(
id = "sample-3",
accountId = "sample",
sender = "Privacy",
senderEmail = "privacy@libremail.org",
subject = "Remote images are blocked by default",
snippet = "We block remote content to protect you from tracking pixels.",
body = "By default, remote images in HTML email are not loaded, which prevents " +
"senders from tracking when you open a message. You can change this under " +
"Settings → Advanced Settings.",
timestampMillis = now - 26 * HOUR,
isRead = true,
isStarred = false,
),
)
fun byId(id: String): Message? = messages.firstOrNull { it.id == id }
}
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import javax.inject.Inject
import javax.inject.Singleton
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.entity.CredentialEntity
/** Stores one encrypted secret per account (OAuth AuthState JSON or IMAP password). */
@Singleton
class CredentialStore @Inject constructor(
private val crypto: KeystoreCrypto,
private val credentialDao: CredentialDao,
) {
suspend fun saveSecret(accountId: String, secret: String) {
credentialDao.upsert(CredentialEntity(accountId, crypto.encrypt(secret)))
}
suspend fun loadSecret(accountId: String): String? =
credentialDao.getById(accountId)?.let { crypto.decrypt(it.encryptedSecret) }
suspend fun delete(accountId: String) = credentialDao.deleteById(accountId)
}
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private val Context.dbKeyDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_dbkey")
/**
* Supplies the SQLCipher passphrase for the opt-in encrypted cache. A random 256-bit key is
* generated once and persisted only as ciphertext — sealed by the non-exportable Android Keystore
* key via [KeystoreCrypto] — so the key that protects the cache is itself protected at rest. The
* passphrase is returned as a 64-character hex string and used directly as the SQLCipher passphrase.
*
* Stored in its own DataStore (not the Room database it protects) to avoid a chicken-and-egg cycle.
*/
@Singleton
class DatabaseKeyStore @Inject constructor(
@ApplicationContext private val context: Context,
private val crypto: KeystoreCrypto,
) {
private val generationLock = Mutex()
/** Returns the cache passphrase, generating and sealing it on first use. */
suspend fun passphrase(): String {
existing()?.let { return it }
return generationLock.withLock {
// Re-check inside the lock so a concurrent first-caller doesn't generate a second key
// (which would leave a DB encrypted under a key we then overwrite and can't reproduce).
existing() ?: generateAndStore()
}
}
private suspend fun existing(): String? =
context.dbKeyDataStore.data.first()[SEALED_KEY]?.let { crypto.decrypt(it) }
private suspend fun generateAndStore(): String {
val raw = ByteArray(KEY_BYTES).also { SecureRandom().nextBytes(it) }
val hex = raw.joinToString("") { "%02x".format(it) }
context.dbKeyDataStore.edit { it[SEALED_KEY] = crypto.encrypt(hex) }
return hex
}
private companion object {
val SEALED_KEY = stringPreferencesKey("sealed_db_key")
const val KEY_BYTES = 32
}
}
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* AES-256-GCM encryption backed by a non-exportable key in the Android Keystore. Secrets
* (OAuth tokens, IMAP passwords) are encrypted at rest so they never touch disk in plaintext.
*/
@Singleton
class KeystoreCrypto @Inject constructor() {
private val keyLock = Any()
// Synchronized so two concurrent first-run encrypts can't both generate a key under the same
// alias — the second would overwrite the first, leaving the first secret undecryptable.
private fun secretKey(): SecretKey = synchronized(keyLock) {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
generator.generateKey()
}
/** Returns Base64(iv || ciphertext). */
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
val iv = cipher.iv
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP)
}
fun decrypt(encoded: String): String {
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
val iv = bytes.copyOfRange(0, IV_LENGTH)
val ciphertext = bytes.copyOfRange(IV_LENGTH, bytes.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_BITS, iv))
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "libremail.master.key"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_LENGTH = 12
const val TAG_BITS = 128
}
}
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_settings")
/** User preferences. Only [dynamicColor] and [newMailNotifications] are wired to behaviour so far. */
data class AppSettings(
val dynamicColor: Boolean = true,
val newMailNotifications: Boolean = true,
val pushIdle: Boolean = true,
val allowStartTls: Boolean = false,
val loadRemoteImages: Boolean = false,
val encryptCache: Boolean = false,
)
@Singleton
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
AppSettings(
dynamicColor = prefs[DYNAMIC_COLOR] ?: true,
newMailNotifications = prefs[NEW_MAIL_NOTIFICATIONS] ?: true,
pushIdle = prefs[PUSH_IDLE] ?: true,
allowStartTls = prefs[ALLOW_STARTTLS] ?: false,
loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false,
encryptCache = prefs[ENCRYPT_CACHE] ?: false,
)
}
val dynamicColor: Flow<Boolean> = settings.map { it.dynamicColor }
suspend fun isNewMailNotificationsEnabled(): Boolean = settings.first().newMailNotifications
suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value)
suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value)
suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value)
suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value)
suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value)
suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value)
private suspend fun put(key: Preferences.Key<Boolean>, value: Boolean) {
context.settingsDataStore.edit { it[key] = value }
}
private companion object {
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications")
val PUSH_IDLE = booleanPreferencesKey("push_idle")
val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls")
val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images")
val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
}
}
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.libremail.auth.FreshToken
import org.libremail.auth.GmailAuthManager
import org.libremail.auth.OutlookAuthManager
import org.libremail.data.local.toImapParams
import org.libremail.data.local.toSmtpParams
import org.libremail.data.security.CredentialStore
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.SmtpParams
/** Resolves an account's stored credential (refreshing OAuth tokens when needed) into connection params. */
@Singleton
class MailConnectionFactory @Inject constructor(
private val credentialStore: CredentialStore,
private val gmailAuthManager: GmailAuthManager,
private val outlookAuthManager: OutlookAuthManager,
private val settingsRepository: SettingsRepository,
) {
private data class CachedToken(val token: String, val expiry: Long?)
/** Per-account lock so concurrent refreshes can't redeem the same (rotating) refresh token twice. */
private val refreshMutexes = ConcurrentHashMap<String, Mutex>()
/** In-memory access-token cache keyed by "accountId|scope", to avoid redeeming a still-valid token. */
private val tokenCache = ConcurrentHashMap<String, CachedToken>()
suspend fun imapParamsFor(account: Account): ImapConnectionParams =
account.toImapParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP, strictStartTls())
suspend fun smtpParamsFor(account: Account): SmtpParams =
account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP, strictStartTls())
/** A fresh Microsoft Graph access token for the primary Outlook (sendMail) send path. */
suspend fun graphTokenFor(account: Account): String =
cachedAccessToken(account.id, SCOPE_GRAPH, outlookAuthManager::freshGraphToken)
private suspend fun resolveSecret(account: Account): String = when (account.authType) {
AuthType.PASSWORD_IMAP ->
credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}")
AuthType.OAUTH_GMAIL ->
cachedAccessToken(account.id, SCOPE_GMAIL, gmailAuthManager::freshAccessToken)
AuthType.OAUTH_OUTLOOK ->
cachedAccessToken(account.id, SCOPE_OUTLOOK, outlookAuthManager::freshOutlookToken)
}
/**
* Returns a cached access token while it is still valid, otherwise refreshes under the account's
* lock (re-checking the cache first, so a concurrent caller redeems only once) and persists the
* updated AuthState.
*/
private suspend fun cachedAccessToken(
accountId: String,
scope: String,
refresh: suspend (String) -> FreshToken,
): String {
validCachedToken(accountId, scope)?.let { return it }
// computeIfAbsent (not getOrPut) so concurrent first-callers share one mutex per account.
return refreshMutexes.computeIfAbsent(accountId) { Mutex() }.withLock {
validCachedToken(accountId, scope)?.let { return@withLock it }
val stored = credentialStore.loadSecret(accountId) ?: error("No stored credentials for $accountId")
val fresh = refresh(stored)
if (fresh.authStateJson != stored) credentialStore.saveSecret(accountId, fresh.authStateJson)
tokenCache["$accountId|$scope"] = CachedToken(fresh.accessToken, fresh.accessTokenExpiry)
fresh.accessToken
}
}
private fun validCachedToken(accountId: String, scope: String): String? {
val cached = tokenCache["$accountId|$scope"] ?: return null
val expiry = cached.expiry ?: return null // unknown expiry — don't trust the cache
return cached.token.takeIf { expiry - EXPIRY_BUFFER_MS > System.currentTimeMillis() }
}
private suspend fun strictStartTls(): Boolean = !settingsRepository.settings.first().allowStartTls
private companion object {
const val SCOPE_GMAIL = "gmail"
const val SCOPE_OUTLOOK = "outlook"
const val SCOPE_GRAPH = "graph"
const val EXPIRY_BUFFER_MS = 60_000L
}
}
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Account
import org.libremail.mail.ImapClient
import org.libremail.notifications.MailNotifier
/** Fetches each account's recent INBOX headers into Room and notifies about newly-arrived mail. */
@Singleton
class MailSyncer @Inject constructor(
private val accountDao: AccountDao,
private val messageDao: MessageDao,
private val imapClient: ImapClient,
private val connectionFactory: MailConnectionFactory,
private val settingsRepository: SettingsRepository,
private val notifier: MailNotifier,
) {
// Serializes all syncing: syncAll/syncAccount are invoked concurrently by the periodic worker,
// pull-to-refresh, one-shot syncs, and one IDLE watcher per account. Without this, two runs can
// both compute the same message as "new" (double-notify) or let a stale deleteInboxNotIn snapshot
// delete a row another run just inserted.
private val syncMutex = Mutex()
/** Syncs every account. Succeeds if at least one account synced (or there are none). */
suspend fun syncAll(): Result<Int> = syncMutex.withLock {
val accounts = accountDao.getAll()
if (accounts.isEmpty()) return@withLock Result.success(0)
var total = 0
var firstError: Throwable? = null
var anySuccess = false
for (entity in accounts) {
syncAccountInternal(entity.toDomain()).fold(
onSuccess = { fetched ->
total += fetched
anySuccess = true
},
onFailure = { error -> if (firstError == null) firstError = error },
)
}
if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError)
}
/** Syncs one account — used by the per-account IDLE watcher so a single push doesn't re-sync all. */
suspend fun syncAccount(accountId: String): Result<Int> = syncMutex.withLock {
val entity = accountDao.getById(accountId) ?: return@withLock Result.success(0)
syncAccountInternal(entity.toDomain())
}
private suspend fun syncAccountInternal(account: Account): Result<Int> = runCatching {
val params = connectionFactory.imapParamsFor(account)
val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT) // cancellable network I/O
val entities = fetched.map { it.toEntity(account.id) }
// Persist and notify atomically with respect to cancellation: an IDLE renewal that cancels
// mid-sync must not drop a notification (the rows would then look "already seen" next time).
withContext(NonCancellable) {
val existingIds = messageDao.getInboxIdsForAccount(account.id).toHashSet()
// Don't notify on the very first sync of an account (would announce the whole inbox).
val newMessages = if (existingIds.isEmpty()) {
emptyList()
} else {
entities.filter { it.id !in existingIds && !it.isRead }
}
if (entities.isEmpty()) {
messageDao.deleteInboxByAccount(account.id)
} else {
val ids = entities.map { it.id }
messageDao.insertNew(entities)
// Mark every fetched message as inbox (upgrades any former search-only row) and refresh
// its display fields — without touching cached bodies or optimistic read/star flags.
messageDao.markInInbox(ids)
entities.forEach {
messageDao.updateHeaderContent(
id = it.id,
sender = it.sender,
senderEmail = it.senderEmail,
subject = it.subject,
timestampMillis = it.timestampMillis,
)
}
messageDao.deleteInboxNotIn(account.id, ids)
}
if (newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) {
notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis })
}
}
fetched.size
}
private companion object {
const val INBOX_LIMIT = 50
}
}
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/** Schedules the outbox send worker via WorkManager. */
@Singleton
class SendScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** Drain the outbox as soon as the network is available, retrying with backoff on failure. */
fun sendNow() {
val request = OneTimeWorkRequestBuilder<SendWorker>()
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
// REPLACE: start a fresh drain so newly-queued mail and manual retries run promptly,
// overriding any pending retry-backoff. At-least-once — a send cancelled mid-flight by a
// replacement may be re-sent, which is preferable to mail stuck waiting behind a backoff.
WorkManager.getInstance(context)
.enqueueUniqueWork(SEND_WORK, ExistingWorkPolicy.REPLACE, request)
}
private companion object {
const val SEND_WORK = "libremail_send_outbox"
}
}
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.toDomain
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.OutgoingMessage
import org.libremail.mail.GraphSendException
import org.libremail.mail.GraphSender
import org.libremail.mail.SmtpSender
/** Drains the outbox: sends each queued message over Graph/SMTP, deleting it on success. */
@HiltWorker
class SendWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val outboxDao: OutboxDao,
private val accountDao: AccountDao,
private val smtpSender: SmtpSender,
private val graphSender: GraphSender,
private val connectionFactory: MailConnectionFactory,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val pending = outboxDao.getAll()
if (pending.isEmpty()) return Result.success()
var anyFailed = false
for (entity in pending) {
val attachmentDir = File(applicationContext.cacheDir, "outbox/${entity.id}")
val account = accountDao.getById(entity.accountId)?.toDomain()
if (account == null) {
outboxDao.delete(entity.id) // account removed — drop the queued message
attachmentDir.deleteRecursively()
continue
}
runCatching {
val message = OutgoingMessage(
accountId = entity.accountId,
to = entity.toAddresses,
cc = entity.ccAddresses,
subject = entity.subject,
body = entity.body,
)
val files = orderedAttachments(attachmentDir)
if (account.authType == AuthType.OAUTH_OUTLOOK) {
sendOutlook(account, message, files)
} else {
smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files)
}
}.fold(
onSuccess = {
outboxDao.delete(entity.id)
attachmentDir.deleteRecursively()
},
onFailure = { e ->
if (e is GraphSendException && e.mayHaveSent) {
// Graph may already have delivered this; auto-retrying (or any other send)
// would duplicate it, so leave it queued with a clear status and let the
// user decide. Not counted as a failure, so WorkManager won't auto-retry.
outboxDao.setError(entity.id, "Send status unknown — check your Sent folder, then retry or cancel")
} else {
outboxDao.setError(entity.id, e.message)
anyFailed = true
}
},
)
}
// Retry (with WorkManager backoff) so failed sends are reattempted when conditions improve.
return if (anyFailed) Result.retry() else Result.success()
}
/**
* Outlook prefers Microsoft Graph. Fall back to SMTP only when Graph definitely did NOT send
* (a rejection, a pre-send/transport error, or a token failure); never fall back when the Graph
* request may already have been accepted, or the message would be sent twice.
*/
private suspend fun sendOutlook(account: Account, message: OutgoingMessage, files: List<File>) {
try {
val token = connectionFactory.graphTokenFor(account)
graphSender.send(token, message, files)
} catch (e: GraphSendException) {
if (e.mayHaveSent) throw e
smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Graph was never reached (e.g. token refresh failed) — SMTP cannot duplicate it.
smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files)
}
}
/** Attachments are staged one-per-indexed-subdirectory so their original order is preserved. */
private fun orderedAttachments(dir: File): List<File> =
dir.listFiles()
?.filter { it.isDirectory }
?.sortedBy { it.name.toIntOrNull() ?: Int.MAX_VALUE }
?.mapNotNull { it.listFiles()?.firstOrNull() }
.orEmpty()
}
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/** Schedules background mail sync via WorkManager. */
@Singleton
class SyncScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val workManager get() = WorkManager.getInstance(context)
private val networkConstraint = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
/** Periodic background sync (WorkManager's 15-minute floor). */
fun schedulePeriodicSync() {
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(networkConstraint)
.build()
workManager.enqueueUniquePeriodicWork(PERIODIC_WORK, ExistingPeriodicWorkPolicy.KEEP, request)
}
/** One-shot sync, e.g. right after an account is added. */
fun syncNow() {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(networkConstraint)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request)
}
private companion object {
const val PERIODIC_WORK = "libremail_periodic_sync"
const val ONESHOT_WORK = "libremail_oneshot_sync"
}
}
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val mailSyncer: MailSyncer,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result =
mailSyncer.syncAll().fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
)
}
@@ -9,9 +9,25 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import org.libremail.data.local.DatabaseEncryption
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.MIGRATION_1_2
import org.libremail.data.local.MIGRATION_2_3
import org.libremail.data.local.MIGRATION_3_4
import org.libremail.data.local.MIGRATION_4_5
import org.libremail.data.local.MIGRATION_5_6
import org.libremail.data.local.MIGRATION_6_7
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.SettingsRepository
@Module
@InstallIn(SingletonComponent::class)
@@ -19,12 +35,58 @@ object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db").build()
fun provideDatabase(
@ApplicationContext context: Context,
keyStore: DatabaseKeyStore,
settingsRepository: SettingsRepository,
): LibreMailDatabase {
val builder = Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
.addMigrations(
MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
)
// No destructive fallback: the migration chain is complete, and silently dropping the
// accounts/credentials/mail tables would lose stored secrets. A missing migration should
// fail loudly in testing instead.
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
// before the database is opened — so it never races an open connection; toggling the setting
// therefore takes effect on the next app start. The passphrase is sealed by the Keystore.
val dbFile = context.getDatabasePath(DB_NAME)
if (runBlocking { settingsRepository.settings.first().encryptCache }) {
val passphrase = runBlocking { keyStore.passphrase() }
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
builder.openHelperFactory(
SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false),
)
} else if (DatabaseEncryption.isEncrypted(dbFile)) {
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
DatabaseEncryption.ensurePlaintext(dbFile, runBlocking { keyStore.passphrase() })
}
return builder.build()
}
@Provides
fun provideMessageDao(database: LibreMailDatabase): MessageDao = database.messageDao()
@Provides
fun provideAccountDao(database: LibreMailDatabase): AccountDao = database.accountDao()
@Provides
fun provideCredentialDao(database: LibreMailDatabase): CredentialDao = database.credentialDao()
@Provides
fun provideAttachmentDao(database: LibreMailDatabase): AttachmentDao = database.attachmentDao()
@Provides
fun provideOutboxDao(database: LibreMailDatabase): OutboxDao = database.outboxDao()
@Provides
fun provideDraftDao(database: LibreMailDatabase): DraftDao = database.draftDao()
private const val DB_NAME = "libremail.db"
}
@@ -6,7 +6,9 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.libremail.data.repository.AccountRepositoryImpl
import org.libremail.data.repository.MailRepositoryImpl
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
@Module
@@ -16,4 +18,8 @@ abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindMailRepository(impl: MailRepositoryImpl): MailRepository
@Binds
@Singleton
abstract fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository
}
@@ -6,6 +6,9 @@ enum class AuthType {
/** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
OAUTH_GMAIL,
/** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
OAUTH_OUTLOOK,
/** Generic IMAP/SMTP with a password or app-password. */
PASSWORD_IMAP,
}
@@ -15,4 +18,18 @@ data class Account(
val email: String,
val displayName: String,
val authType: AuthType,
)
val imap: ServerConfig,
val smtp: ServerConfig,
) {
companion object {
/** An Outlook/Microsoft account using the unified office365 endpoints (personal + M365). */
fun outlook(email: String, displayName: String = email): Account = Account(
id = "outlook:$email",
email = email,
displayName = displayName.ifBlank { email },
authType = AuthType.OAUTH_OUTLOOK,
imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.office365.com", 587, MailSecurity.STARTTLS),
)
}
}
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
data class Attachment(
val messageId: String,
val partIndex: Int,
val filename: String,
val mimeType: String,
val sizeBytes: Long,
)
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
data class Draft(
val id: String,
val accountId: String?,
val to: String,
val cc: String,
val subject: String,
val body: String,
val updatedAt: Long,
val attachments: List<OutgoingAttachment> = emptyList(),
)
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/** Everything needed for a single IMAP connection attempt (transient; not persisted). */
data class ImapConnectionParams(
val host: String,
val port: Int,
val security: MailSecurity,
val username: String,
/** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */
val secret: String,
val useXoauth2: Boolean,
/** When true (default), a STARTTLS upgrade must succeed; when false it is best-effort. */
val strictStartTls: Boolean = true,
)
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/** Transport security for an IMAP/SMTP connection. */
enum class MailSecurity {
/** Implicit TLS (e.g. IMAPS 993 / SMTPS 465). */
SSL_TLS,
/** Upgrade a plaintext connection with STARTTLS (e.g. 143 / 587). */
STARTTLS,
/** No transport security — only for local test servers. */
NONE,
}
@@ -9,7 +9,10 @@ data class Message(
val subject: String,
val snippet: String,
val body: String,
val isHtml: Boolean,
val timestampMillis: Long,
val isRead: Boolean,
val isStarred: Boolean,
/** True for messages synced as part of the inbox; false for transient server-search hits. */
val inInbox: Boolean = true,
)
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/** A message waiting in the outbox. [lastError] is null while queued, set after a failed attempt. */
data class OutboxMessage(
val id: String,
val to: String,
val subject: String,
val body: String,
val createdAt: Long,
val lastError: String?,
)
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/** A message the user is sending. [to]/[cc] are comma-separated address lists. */
data class OutgoingMessage(
val accountId: String,
val to: String,
val cc: String = "",
val subject: String,
val body: String,
val attachments: List<OutgoingAttachment> = emptyList(),
)
/** A file the user attached, referenced by its content-URI string until it is sent. */
data class OutgoingAttachment(
val uri: String,
val name: String,
)
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/** Host/port/security for one mail server (IMAP or SMTP). */
data class ServerConfig(
val host: String,
val port: Int,
val security: MailSecurity,
)
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/** Everything needed for a single SMTP send attempt (transient; not persisted). */
data class SmtpParams(
val host: String,
val port: Int,
val security: MailSecurity,
val username: String,
/** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */
val secret: String,
val useXoauth2: Boolean,
/** When true (default), a STARTTLS upgrade must succeed; when false it is best-effort. */
val strictStartTls: Boolean = true,
)
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.repository
import kotlinx.coroutines.flow.Flow
import org.libremail.domain.model.Account
import org.libremail.domain.model.ImapConnectionParams
/** Account management: list, add (with a live connection test), and remove accounts. */
interface AccountRepository {
fun observeAccounts(): Flow<List<Account>>
/** Attempt an IMAP login and return the server's folder names. */
suspend fun testConnection(params: ImapConnectionParams): Result<List<String>>
/** Verify, then persist, a password/app-password IMAP account. Returns the folders found. */
suspend fun addImapAccount(account: Account, password: String): Result<List<String>>
/** Verify (via XOAUTH2), then persist, an Outlook account. Returns the folders found. */
suspend fun addOutlookAccount(email: String, accessToken: String, authStateJson: String): Result<List<String>>
suspend fun deleteAccount(id: String)
}
@@ -1,18 +1,52 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.repository
import java.io.File
import kotlinx.coroutines.flow.Flow
import org.libremail.domain.model.Account
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
/**
* Abstraction over the local cache (and, in later increments, network sync).
* Abstraction over the local message cache (and, in later increments, network sync).
* The UI always reads from here; the cache is the single source of truth.
*/
interface MailRepository {
fun observeMessages(): Flow<List<Message>>
fun observeAccounts(): Flow<List<Account>>
suspend fun getMessage(id: String): Message?
/** Loads a message for reading: fetches+caches the body if missing, and marks it read. */
suspend fun openMessage(id: String): Result<Message>
/** Cached attachment metadata for a message, populated when the message is opened. */
fun observeAttachments(messageId: String): Flow<List<Attachment>>
/** Downloads an attachment's bytes to a local cache file and returns it. */
suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File>
suspend fun setStarred(id: String, starred: Boolean): Result<Unit>
suspend fun deleteMessage(id: String): Result<Unit>
suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit>
/** Drafts: composed-but-unsent messages saved for later. */
fun observeDrafts(): Flow<List<Draft>>
suspend fun getDraft(id: String): Draft?
suspend fun saveDraft(draft: Draft)
suspend fun deleteDraft(id: String)
/** The outbox: messages queued for sending. */
fun observeOutbox(): Flow<List<OutboxMessage>>
suspend fun cancelOutboxMessage(id: String)
suspend fun retryOutbox()
/** Fetches server-side search matches into the cache so the message list can surface them. */
suspend fun searchServer(query: String)
/** Drops transient server-search hits from the cache (called when search is dismissed). */
suspend fun clearSearchResults()
}
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import jakarta.mail.internet.InternetAddress
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import org.libremail.domain.model.OutgoingMessage
/**
* Thrown when a Graph `sendMail` attempt fails. [mayHaveSent] is true only when the request was
* fully transmitted but the response could not be read — in that case the message may already be on
* its way, so callers must NOT retry or fall back to another transport (doing so would duplicate it).
*/
class GraphSendException(
message: String,
val mayHaveSent: Boolean,
cause: Throwable? = null,
) : Exception(message, cause)
/**
* Sends mail via Microsoft Graph `me/sendMail` — Microsoft's preferred send path for Outlook /
* Microsoft 365, used in place of SMTP. Authenticated with a Graph access token (Bearer).
*/
@Singleton
class GraphSender @Inject constructor() {
suspend fun send(
accessToken: String,
message: OutgoingMessage,
attachments: List<File> = emptyList(),
) = withContext(Dispatchers.IO) {
val payload = buildSendMailPayload(message, attachments)
val connection = (URL(SEND_MAIL_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
doOutput = true
setRequestProperty("Authorization", "Bearer $accessToken")
setRequestProperty("Content-Type", "application/json; charset=utf-8")
}
try {
// Failure here means the request never reached Graph — safe to fall back/retry.
try {
connection.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) }
} catch (e: IOException) {
throw GraphSendException("Graph sendMail could not be transmitted", mayHaveSent = false, cause = e)
}
// The request was fully sent; if we can't read the response, Graph may already have
// accepted and sent it — do not fall back to SMTP or the message would be duplicated.
val code = try {
connection.responseCode
} catch (e: IOException) {
throw GraphSendException("Graph sendMail sent but no response received", mayHaveSent = true, cause = e)
}
if (code !in 200..299) {
val body = (connection.errorStream ?: connection.inputStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
// An explicit non-2xx means Graph rejected (did not send) — safe to fall back.
throw GraphSendException("Graph sendMail failed (HTTP $code): ${body.take(500)}", mayHaveSent = false)
}
} finally {
connection.disconnect()
}
}
private companion object {
const val SEND_MAIL_URL = "https://graph.microsoft.com/v1.0/me/sendMail"
const val TIMEOUT_MS = 15_000
}
}
/** Builds the Graph `sendMail` JSON body (a pure function, so it is unit-testable without a network). */
internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List<File>): String {
val mail = JSONObject()
.put("subject", message.subject)
.put("body", JSONObject().put("contentType", "Text").put("content", message.body))
.put("toRecipients", recipientsJson(message.to))
if (message.cc.isNotBlank()) {
mail.put("ccRecipients", recipientsJson(message.cc))
}
if (attachments.isNotEmpty()) {
val items = JSONArray()
attachments.forEach { file ->
items.put(
JSONObject()
.put("@odata.type", "#microsoft.graph.fileAttachment")
.put("name", file.name)
.put("contentBytes", Base64.getEncoder().encodeToString(file.readBytes())),
)
}
mail.put("attachments", items)
}
return JSONObject().put("message", mail).put("saveToSentItems", true).toString()
}
/**
* Parses an address list into Graph `emailAddress` recipient objects. Uses RFC 822 parsing (the
* same as the SMTP path) so display-name recipients like `John Doe <john@example.com>` — and commas
* inside quoted display names — produce a valid bare `address` (plus an optional `name`).
*/
private fun recipientsJson(addresses: String): JSONArray {
val array = JSONArray()
val parsed = runCatching { InternetAddress.parse(addresses, false) }.getOrNull() ?: emptyArray()
parsed.forEach { addr ->
val email = addr.address?.trim().orEmpty()
if (email.isEmpty()) return@forEach
val emailAddress = JSONObject().put("address", email)
addr.personal?.takeIf { it.isNotBlank() }?.let { emailAddress.put("name", it) }
array.put(JSONObject().put("emailAddress", emailAddress))
}
return array
}
@@ -0,0 +1,369 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import android.util.Log
import jakarta.mail.FetchProfile
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Multipart
import jakarta.mail.Part
import jakarta.mail.Session
import jakarta.mail.Store
import jakarta.mail.UIDFolder
import jakarta.mail.event.MessageCountAdapter
import jakarta.mail.event.MessageCountEvent
import jakarta.mail.internet.ContentType
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeUtility
import jakarta.mail.search.BodyTerm
import jakarta.mail.search.FromStringTerm
import jakarta.mail.search.OrTerm
import jakarta.mail.search.SubjectTerm
import java.util.Properties
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.eclipse.angus.mail.imap.IMAPFolder
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
/** A message header fetched from the server (no body — that arrives with the reader). */
data class FetchedMessage(
val uid: String,
val sender: String,
val senderEmail: String,
val subject: String,
val timestampMillis: Long,
val isRead: Boolean,
val isFlagged: Boolean,
)
/** A message body extracted from the server, with metadata for any attachment parts. */
data class MessageContent(
val body: String,
val isHtml: Boolean,
val attachments: List<AttachmentPart> = emptyList(),
)
/** Metadata for one attachment part. [partIndex] is its position in attachment-tree order. */
data class AttachmentPart(
val partIndex: Int,
val filename: String,
val mimeType: String,
val sizeBytes: Long,
)
/** A downloaded attachment's bytes plus the metadata needed to open it. */
class DownloadedAttachment(
val filename: String,
val mimeType: String,
val bytes: ByteArray,
)
/** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */
@Singleton
class ImapClient @Inject constructor() {
/** Connects and returns the account's folder names. Throws on failure. */
suspend fun listFolders(params: ImapConnectionParams): List<String> = withContext(Dispatchers.IO) {
withStore(params) { store ->
store.defaultFolder.list("*").map { it.fullName }
}
}
/** Fetches the most recent [limit] INBOX headers, newest first. */
suspend fun fetchRecentInbox(params: ImapConnectionParams, limit: Int): List<FetchedMessage> =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_ONLY)
try {
val total = inbox.messageCount
if (total == 0) return@withStore emptyList()
val messages = inbox.getMessages(maxOf(1, total - limit + 1), total)
inbox.fetch(
messages,
FetchProfile().apply {
add(FetchProfile.Item.ENVELOPE)
add(FetchProfile.Item.FLAGS)
add(UIDFolder.FetchProfileItem.UID)
},
)
val uidFolder = inbox as UIDFolder
messages.reversed().map { it.toFetchedMessage(uidFolder) }
} finally {
runCatching { inbox.close(false) }
}
}
}
/** Runs an IMAP SEARCH over the whole INBOX (subject/from/body) and returns matching headers. */
suspend fun search(params: ImapConnectionParams, query: String, limit: Int): List<FetchedMessage> =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_ONLY)
try {
val term = OrTerm(arrayOf(SubjectTerm(query), FromStringTerm(query), BodyTerm(query)))
val matches = inbox.search(term).toList()
if (matches.isEmpty()) return@withStore emptyList()
val recent = if (matches.size > limit) matches.takeLast(limit) else matches
inbox.fetch(
recent.toTypedArray(),
FetchProfile().apply {
add(FetchProfile.Item.ENVELOPE)
add(FetchProfile.Item.FLAGS)
add(UIDFolder.FetchProfileItem.UID)
},
)
val uidFolder = inbox as UIDFolder
recent.reversed().map { it.toFetchedMessage(uidFolder) }
} finally {
runCatching { inbox.close(false) }
}
}
}
/** Fetches a message body by UID and marks it \Seen on the server. */
suspend fun fetchBodyMarkingSeen(params: ImapConnectionParams, uid: String): MessageContent =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
try {
val message = (inbox as UIDFolder).getMessageByUID(uid.toLong())
?: error("Message $uid not found")
val content = (extractBody(message) ?: MessageContent("", isHtml = false))
.copy(attachments = collectAttachments(message))
message.setFlag(Flags.Flag.SEEN, true)
content
} finally {
runCatching { inbox.close(false) }
}
}
}
/** Downloads the bytes of one attachment part (identified by its [partIndex]). */
suspend fun fetchAttachment(params: ImapConnectionParams, uid: String, partIndex: Int): DownloadedAttachment =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_ONLY)
try {
val message = (inbox as UIDFolder).getMessageByUID(uid.toLong())
?: error("Message $uid not found")
val parts = mutableListOf<Part>()
collectAttachmentParts(message, parts)
val part = parts.getOrNull(partIndex) ?: error("Attachment $partIndex not found")
val bytes = part.inputStream.use { it.readBytes() }
DownloadedAttachment(
filename = attachmentName(part) ?: "attachment",
mimeType = baseType(part),
bytes = bytes,
)
} finally {
runCatching { inbox.close(false) }
}
}
}
suspend fun setFlag(params: ImapConnectionParams, uid: String, flag: Flags.Flag, value: Boolean) =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
try {
(inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(flag, value)
} finally {
runCatching { inbox.close(false) }
}
}
}
suspend fun deleteMessage(params: ImapConnectionParams, uid: String) =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
try {
(inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(Flags.Flag.DELETED, true)
inbox.expunge()
} finally {
runCatching { inbox.close(false) }
}
}
}
/**
* Holds a long-lived IMAP connection and uses IMAP IDLE to wait for server activity. The
* server pushes new-mail notifications while [IMAPFolder.idle] blocks; Jakarta dispatches them
* to the message-count listener (not by returning from idle()), so we forward each push to
* [onActivity] via a conflated channel. Runs until the coroutine is cancelled (which closes the
* connection to unblock idle()) or a connection error is thrown, leaving reconnection to the caller.
*/
suspend fun idle(params: ImapConnectionParams, onActivity: suspend () -> Unit) =
withContext(Dispatchers.IO) {
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol)
store.connect(params.host, params.port, params.username, params.secret)
// Close the just-connected store if opening the folder fails, so a failed connect in the
// IDLE reconnect loop can't leak connections until the server's per-account limit is hit.
val inbox = try {
(store.getFolder("INBOX") as IMAPFolder).also { it.open(Folder.READ_ONLY) }
} catch (e: Throwable) {
runCatching { store.close() }
throw e
}
Log.d(TAG, "IDLE connected")
val pushes = Channel<Unit>(Channel.CONFLATED)
inbox.addMessageCountListener(object : MessageCountAdapter() {
override fun messagesAdded(event: MessageCountEvent) {
Log.d(TAG, "IDLE push: ${event.messages.size} new message(s)")
pushes.trySend(Unit)
}
})
coroutineScope {
val syncer = launch {
for (signal in pushes) onActivity()
}
// Close the connection the moment this scope is cancelled (renewal timeout or
// service stop). Doing it here — at cancellation *start*, not job completion —
// unblocks the blocking idle() read below so the loop exits promptly; a
// completion handler would never run while idle() is still blocked.
val closer = launch {
try {
awaitCancellation()
} finally {
withContext(NonCancellable) { runCatching { store.close() } }
}
}
// Sync once on connect to catch anything that arrived before IDLE was established.
pushes.trySend(Unit)
try {
while (isActive) {
inbox.idle()
}
} catch (e: Exception) {
if (isActive) throw e // a real connection error: let the caller reconnect
} finally {
closer.cancel()
syncer.cancel()
pushes.close()
runCatching { inbox.close(false) }
runCatching { store.close() }
}
}
}
/** Recursively finds the best body part: HTML preferred, plain text otherwise. */
private fun extractBody(part: Part): MessageContent? {
if (part.isMimeType("text/html")) return MessageContent(part.content.toString(), isHtml = true)
if (part.isMimeType("text/plain")) return MessageContent(part.content.toString(), isHtml = false)
if (part.isMimeType("multipart/*")) {
val multipart = part.content as? Multipart ?: return null
var plain: MessageContent? = null
for (i in 0 until multipart.count) {
val child = multipart.getBodyPart(i)
if (Part.ATTACHMENT.equals(child.disposition, ignoreCase = true)) continue
val result = extractBody(child) ?: continue
if (result.isHtml) return result
if (plain == null) plain = result
}
return plain
}
return null
}
/** Walks the MIME tree and returns attachment metadata in a stable, depth-first order. */
private fun collectAttachments(message: Part): List<AttachmentPart> {
val parts = mutableListOf<Part>()
collectAttachmentParts(message, parts)
return parts.mapIndexed { index, part ->
AttachmentPart(
partIndex = index,
filename = attachmentName(part) ?: "attachment",
mimeType = baseType(part),
sizeBytes = part.size.toLong().coerceAtLeast(0L),
)
}
}
private fun collectAttachmentParts(part: Part, into: MutableList<Part>) {
when {
part.isMimeType("multipart/*") -> {
val multipart = part.content as? Multipart ?: return
for (i in 0 until multipart.count) collectAttachmentParts(multipart.getBodyPart(i), into)
}
isAttachment(part) -> into.add(part)
}
}
private fun isAttachment(part: Part): Boolean =
Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) || !part.fileName.isNullOrBlank()
private fun attachmentName(part: Part): String? =
part.fileName?.let { runCatching { MimeUtility.decodeText(it) }.getOrDefault(it) }
private fun baseType(part: Part): String =
runCatching { ContentType(part.contentType).baseType }.getOrDefault("application/octet-stream")
private fun Message.toFetchedMessage(uidFolder: UIDFolder): FetchedMessage {
val from = from?.firstOrNull() as? InternetAddress
return FetchedMessage(
uid = uidFolder.getUID(this).toString(),
sender = from?.personal ?: from?.address ?: "(unknown sender)",
senderEmail = from?.address.orEmpty(),
subject = subject ?: "(no subject)",
timestampMillis = (sentDate ?: receivedDate)?.time ?: System.currentTimeMillis(),
isRead = isSet(Flags.Flag.SEEN),
isFlagged = isSet(Flags.Flag.FLAGGED),
)
}
private inline fun <T> withStore(params: ImapConnectionParams, block: (Store) -> T): T {
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol)
store.connect(params.host, params.port, params.username, params.secret)
return try {
block(store)
} finally {
runCatching { store.close() }
}
}
private fun buildProps(protocol: String, params: ImapConnectionParams): Properties = Properties().apply {
put("mail.store.protocol", protocol)
put("mail.$protocol.host", params.host)
put("mail.$protocol.port", params.port.toString())
put("mail.$protocol.connectiontimeout", TIMEOUT_MS)
put("mail.$protocol.timeout", TIMEOUT_MS)
put("mail.$protocol.writetimeout", TIMEOUT_MS)
if (params.security == MailSecurity.STARTTLS) {
put("mail.$protocol.starttls.enable", "true")
put("mail.$protocol.starttls.required", params.strictStartTls.toString())
}
// Verify the server certificate matches the host whenever TLS is used. Angus already
// defaults this to true; set it explicitly so a future library-default change can't
// silently disable hostname checking and expose us to MITM. (No-op for MailSecurity.NONE.)
put("mail.$protocol.ssl.checkserveridentity", "true")
if (params.useXoauth2) {
put("mail.$protocol.auth.mechanisms", "XOAUTH2")
}
}
private companion object {
const val TIMEOUT_MS = "15000"
const val TAG = "LibreMailIdle"
}
}
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import jakarta.mail.Message
import jakarta.mail.Session
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeBodyPart
import jakarta.mail.internet.MimeMessage
import jakarta.mail.internet.MimeMultipart
import java.io.File
import java.util.Date
import java.util.Properties
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.SmtpParams
/** Sends mail via SMTP over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */
@Singleton
class SmtpSender @Inject constructor() {
suspend fun send(
params: SmtpParams,
from: String,
message: OutgoingMessage,
attachments: List<File> = emptyList(),
) =
withContext(Dispatchers.IO) {
val protocol = if (params.security == MailSecurity.SSL_TLS) "smtps" else "smtp"
val props = Properties().apply {
put("mail.transport.protocol", protocol)
put("mail.$protocol.host", params.host)
put("mail.$protocol.port", params.port.toString())
put("mail.$protocol.auth", "true")
put("mail.$protocol.connectiontimeout", TIMEOUT_MS)
put("mail.$protocol.timeout", TIMEOUT_MS)
put("mail.$protocol.writetimeout", TIMEOUT_MS)
if (params.security == MailSecurity.SSL_TLS) {
put("mail.$protocol.ssl.enable", "true")
}
if (params.security == MailSecurity.STARTTLS) {
put("mail.$protocol.starttls.enable", "true")
put("mail.$protocol.starttls.required", params.strictStartTls.toString())
}
// Verify the server certificate matches the host whenever TLS is used (explicit so a
// future Angus default change can't silently disable it). No-op for MailSecurity.NONE.
put("mail.$protocol.ssl.checkserveridentity", "true")
if (params.useXoauth2) {
put("mail.$protocol.auth.mechanisms", "XOAUTH2")
}
}
val session = Session.getInstance(props)
val mime = MimeMessage(session).apply {
setFrom(InternetAddress(from))
setRecipients(Message.RecipientType.TO, InternetAddress.parse(message.to))
if (message.cc.isNotBlank()) {
setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc))
}
subject = message.subject
sentDate = Date()
}
if (attachments.isEmpty()) {
mime.setText(message.body, "UTF-8")
} else {
val multipart = MimeMultipart()
multipart.addBodyPart(MimeBodyPart().apply { setText(message.body, "UTF-8") })
attachments.forEach { file ->
multipart.addBodyPart(MimeBodyPart().apply { attachFile(file) })
}
mime.setContent(multipart)
}
val transport = session.getTransport(protocol)
transport.connect(params.host, params.port, params.username, params.secret)
try {
transport.sendMessage(mime, mime.allRecipients)
} finally {
runCatching { transport.close() }
}
}
private companion object {
const val TIMEOUT_MS = "15000"
}
}
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.notifications
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import org.libremail.MainActivity
import org.libremail.R
import org.libremail.data.local.entity.MessageEntity
/** Posts on-device new-mail notifications (no push service involved). */
@Singleton
class MailNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) {
// Permission is checked via hasPermission() below; lint can't trace the indirect guard.
@SuppressLint("MissingPermission")
fun notifyNewMail(messages: List<MessageEntity>) {
if (messages.isEmpty() || !hasPermission()) return
ensureChannel()
val manager = NotificationManagerCompat.from(context)
val contentIntent = contentIntent()
// One notification per message, keyed by a stable id, so a later batch never overwrites an
// earlier, still-unacknowledged one. setOnlyAlertOnce avoids re-buzzing for the same message.
messages.forEach { message ->
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(message.sender)
.setContentText(message.subject)
.setStyle(NotificationCompat.BigTextStyle().bigText(message.subject))
.setCategory(NotificationCompat.CATEGORY_EMAIL)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.setContentIntent(contentIntent)
.build()
manager.notify(notificationId(message.id), notification)
}
// Group summary (the system shows it only once two or more children are present).
val summary = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(context.getString(R.string.notif_channel_new_mail))
.setStyle(
NotificationCompat.InboxStyle().also { style ->
messages.take(SUMMARY_LINES).forEach { style.addLine("${it.sender}: ${it.subject}") }
},
)
.setCategory(NotificationCompat.CATEGORY_EMAIL)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setContentIntent(contentIntent)
.build()
manager.notify(SUMMARY_ID, summary)
}
private fun contentIntent(): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
return PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
/** Stable per-message id distinct from the summary id, so each message gets its own notification. */
private fun notificationId(messageId: String): Int {
val hash = messageId.hashCode()
return if (hash == SUMMARY_ID) hash + 1 else hash
}
private fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
private fun ensureChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notif_channel_new_mail),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
// Redact sender/subject on a secure lock screen (the system shows a generic placeholder
// instead). Applies on fresh installs; Android ignores channel changes after creation.
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
private companion object {
const val CHANNEL_ID = "new_mail"
const val GROUP_KEY = "org.libremail.NEW_MAIL"
const val SUMMARY_ID = 1001
const val SUMMARY_LINES = 5
}
}
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/** Starts and stops [IdleService] to match the user's push-mail (IMAP IDLE) preference. */
@Singleton
class IdlePushManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun start() {
// Starting a foreground service from a background process is disallowed on modern Android;
// swallow that case — periodic WorkManager sync still covers mail, and IDLE starts the next
// time the app is in the foreground.
runCatching {
ContextCompat.startForegroundService(context, Intent(context, IdleService::class.java))
}
}
fun stop() {
runCatching { context.stopService(Intent(context, IdleService::class.java)) }
}
}
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.libremail.R
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.toDomain
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.data.sync.MailSyncer
import org.libremail.domain.model.Account
import org.libremail.mail.ImapClient
/**
* Foreground service that holds a long-lived IMAP IDLE connection per account so the server can
* push new mail to us instantly — no polling, and no third-party push service. When IDLE reports
* activity we run a normal sync, which writes to Room and fires the new-mail notification.
*/
@AndroidEntryPoint
class IdleService : Service() {
@Inject lateinit var accountDao: AccountDao
@Inject lateinit var connectionFactory: MailConnectionFactory
@Inject lateinit var imapClient: ImapClient
@Inject lateinit var mailSyncer: MailSyncer
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var watching = false
/** Active IDLE watcher per account id, so we can start/stop them as accounts change. */
private val watchers = mutableMapOf<String, Job>()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startAsForeground()
if (!watching) {
watching = true
scope.launch { reconcileWatchers() }
}
return START_STICKY
}
/**
* Observes the account list and keeps one IDLE watcher per account: a watcher is started for a
* newly-added account and cancelled when its account is removed (which promptly closes that
* account's IDLE connection). The service is started/stopped by the app based on whether any
* accounts exist, so reaching zero here is just a transient state.
*/
private suspend fun reconcileWatchers() {
accountDao.observeAll().collect { entities ->
val accounts = entities.map { it.toDomain() }
val currentIds = accounts.mapTo(mutableSetOf()) { it.id }
(watchers.keys - currentIds).forEach { id -> watchers.remove(id)?.cancel() }
accounts.forEach { account ->
if (account.id !in watchers) {
watchers[account.id] = scope.launch { watchAccount(account) }
}
}
}
}
/**
* Holds IDLE for one account, reconnecting with exponential backoff whenever it drops.
* Each IDLE session is bounded by [IDLE_RENEWAL_MS]: when it elapses, [withTimeoutOrNull]
* cancels idle() (which closes the connection to unblock it) and we reconnect with a fresh
* IDLE. This re-issues IDLE well within RFC 2177's 29-minute limit and before NAT/firewall
* idle-socket timeouts would silently strand the connection. Each reconnect catches up via
* idle()'s on-connect sync, so no mail is missed across renewals.
*/
private suspend fun watchAccount(account: Account) {
var backoffMs = INITIAL_BACKOFF_MS
while (scope.isActive) {
try {
val params = connectionFactory.imapParamsFor(account)
withTimeoutOrNull(IDLE_RENEWAL_MS) {
// Sync just this account on its own push — not every account.
imapClient.idle(params) { mailSyncer.syncAccount(account.id) }
}
backoffMs = INITIAL_BACKOFF_MS
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
}
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startAsForeground() {
NotificationManagerCompat.from(this).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.notif_channel_push_status),
NotificationManager.IMPORTANCE_LOW,
),
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(getString(R.string.notif_push_status_title))
.setContentText(getString(R.string.notif_push_status_text))
.setOngoing(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
ServiceCompat.startForeground(
this,
FOREGROUND_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private companion object {
const val CHANNEL_ID = "push_status"
const val FOREGROUND_ID = 1002
const val INITIAL_BACKOFF_MS = 5_000L
const val MAX_BACKOFF_MS = 5 * 60_000L
// Re-establish IDLE on this cadence — under RFC 2177's 29-minute ceiling and short enough
// to beat typical NAT/firewall idle-socket timeouts.
const val IDLE_RENEWAL_MS = 9 * 60_000L
}
}
@@ -20,9 +20,12 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import org.libremail.R
import org.libremail.ui.accountsetup.AccountSetupScreen
import org.libremail.ui.accountsetup.ManualSetupScreen
import org.libremail.ui.compose.ComposeScreen
import org.libremail.ui.drafts.DraftsScreen
import org.libremail.ui.mailbox.MailboxScreen
import org.libremail.ui.navigation.Routes
import org.libremail.ui.outbox.OutboxScreen
import org.libremail.ui.reader.ReaderScreen
import org.libremail.ui.settings.SettingsScreen
@@ -43,7 +46,10 @@ fun LibreMailApp() {
composable(Routes.MAILBOX) {
MailboxScreen(
onOpenMessage = { id -> navController.navigate(Routes.reader(id)) },
onCompose = { navController.navigate(Routes.COMPOSE) },
onCompose = { navController.navigate(Routes.compose()) },
onOpenDrafts = { navController.navigate(Routes.DRAFTS) },
onOpenOutbox = { navController.navigate(Routes.OUTBOX) },
onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) },
onSelectTab = navController::navigateTab,
)
}
@@ -51,9 +57,20 @@ fun LibreMailApp() {
route = Routes.READER_PATTERN,
arguments = listOf(navArgument(Routes.READER_ARG_ID) { type = NavType.StringType }),
) {
ReaderScreen(onBack = navController::popBackStack)
ReaderScreen(
onBack = navController::popBackStack,
onReply = { to, subject, from -> navController.navigate(Routes.compose(to, subject, from)) },
)
}
composable(Routes.COMPOSE) {
composable(
route = Routes.COMPOSE_PATTERN,
arguments = listOf(
navArgument(Routes.COMPOSE_ARG_TO) { type = NavType.StringType; defaultValue = "" },
navArgument(Routes.COMPOSE_ARG_SUBJECT) { type = NavType.StringType; defaultValue = "" },
navArgument(Routes.COMPOSE_ARG_FROM) { type = NavType.StringType; defaultValue = "" },
navArgument(Routes.COMPOSE_ARG_DRAFT) { type = NavType.StringType; defaultValue = "" },
),
) {
ComposeScreen(onBack = navController::popBackStack)
}
composable(Routes.SETTINGS) {
@@ -63,7 +80,34 @@ fun LibreMailApp() {
)
}
composable(Routes.ACCOUNT_SETUP) {
AccountSetupScreen(onBack = navController::popBackStack)
AccountSetupScreen(
onBack = navController::popBackStack,
onManualSetup = { navController.navigate(Routes.MANUAL_SETUP) },
onAccountAdded = {
navController.navigate(Routes.MAILBOX) {
popUpTo(Routes.MAILBOX) { inclusive = true }
}
},
)
}
composable(Routes.MANUAL_SETUP) {
ManualSetupScreen(
onBack = navController::popBackStack,
onAccountAdded = {
navController.navigate(Routes.MAILBOX) {
popUpTo(Routes.MAILBOX) { inclusive = true }
}
},
)
}
composable(Routes.DRAFTS) {
DraftsScreen(
onBack = navController::popBackStack,
onOpenDraft = { id -> navController.navigate(Routes.composeDraft(id)) },
)
}
composable(Routes.OUTBOX) {
OutboxScreen(onBack = navController::popBackStack)
}
}
}
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -13,65 +17,114 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSetupScreen(onBack: () -> Unit) {
fun AccountSetupScreen(
onBack: () -> Unit,
onManualSetup: () -> Unit,
onAccountAdded: () -> Unit,
viewModel: AccountSetupViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val outlookLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result -> viewModel.onOutlookResult(result.data) }
LaunchedEffect(state.status) {
if (state.status == SetupStatus.DONE) onAccountAdded()
}
LaunchedEffect(state.error) {
state.error?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
val busy = state.status == SetupStatus.CONNECTING
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_account_setup)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Filled.Email,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.account_setup_subtitle),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
// OAuth + IMAP/SMTP wiring arrives in the next increment.
Button(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.account_setup_gmail))
Box(Modifier.fillMaxSize().padding(padding)) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Filled.Email,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.account_setup_subtitle),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { outlookLauncher.launch(viewModel.outlookAuthIntent()) },
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.account_setup_outlook))
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onManualSetup,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.account_setup_other))
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.account_setup_other))
if (busy) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.auth.OutlookAuthManager
import org.libremail.domain.repository.AccountRepository
/** Stage of an account-setup attempt, shared by the Outlook and manual flows. */
enum class SetupStatus { IDLE, CONNECTING, DONE }
data class AccountSetupUiState(
val status: SetupStatus = SetupStatus.IDLE,
val error: String? = null,
)
@HiltViewModel
class AccountSetupViewModel @Inject constructor(
private val outlookAuthManager: OutlookAuthManager,
private val accountRepository: AccountRepository,
) : ViewModel() {
private val _state = MutableStateFlow(AccountSetupUiState())
val state: StateFlow<AccountSetupUiState> = _state.asStateFlow()
val isOutlookConfigured: Boolean get() = outlookAuthManager.isConfigured
fun outlookAuthIntent(): Intent = outlookAuthManager.createAuthIntent()
fun onOutlookResult(data: Intent?) {
if (data == null) {
_state.update { it.copy(error = "Microsoft sign-in was cancelled") }
return
}
viewModelScope.launch {
_state.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
runCatching {
val oauth = outlookAuthManager.exchangeToken(data)
accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow()
}.fold(
onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } },
onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } },
)
}
}
fun consumeError() = _state.update { it.copy(error = null) }
}
@@ -0,0 +1,232 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.domain.model.MailSecurity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ManualSetupScreen(
onBack: () -> Unit,
onAccountAdded: () -> Unit,
viewModel: ManualSetupViewModel = hiltViewModel(),
) {
val form by viewModel.form.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(form.status) {
if (form.status == SetupStatus.DONE) onAccountAdded()
}
LaunchedEffect(form.error) {
form.error?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
val busy = form.status == SetupStatus.CONNECTING
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.manual_setup_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
OutlinedTextField(
value = form.email,
onValueChange = viewModel::onEmail,
label = { Text(stringResource(R.string.manual_email)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = form.password,
onValueChange = viewModel::onPassword,
label = { Text(stringResource(R.string.manual_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(20.dp))
SectionLabel(stringResource(R.string.manual_incoming))
OutlinedTextField(
value = form.imapHost,
onValueChange = viewModel::onImapHost,
label = { Text(stringResource(R.string.manual_imap_server)) },
placeholder = { Text("imap.example.com") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
SectionLabel(stringResource(R.string.manual_outgoing))
OutlinedTextField(
value = form.smtpHost,
onValueChange = viewModel::onSmtpHost,
label = { Text(stringResource(R.string.manual_smtp_server)) },
placeholder = { Text("smtp.example.com") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
AdvancedToggle(expanded = form.advancedExpanded, onToggle = viewModel::toggleAdvanced)
AnimatedVisibility(visible = form.advancedExpanded) {
Column {
OutlinedTextField(
value = form.imapPort,
onValueChange = viewModel::onImapPort,
label = { Text(stringResource(R.string.manual_imap_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
SecuritySelector(stringResource(R.string.manual_imap_security), form.imapSecurity, viewModel::onImapSecurity)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = form.smtpPort,
onValueChange = viewModel::onSmtpPort,
label = { Text(stringResource(R.string.manual_smtp_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
SecuritySelector(stringResource(R.string.manual_smtp_security), form.smtpSecurity, viewModel::onSmtpSecurity)
}
}
Spacer(Modifier.height(24.dp))
Button(
onClick = viewModel::testAndSave,
enabled = form.isValid && !busy,
modifier = Modifier.fillMaxWidth(),
) {
if (busy) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
}
Text(stringResource(R.string.manual_test_and_add))
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SecuritySelector(label: String, selected: MailSecurity, onSelect: (MailSecurity) -> Unit) {
Column {
Text(label, style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// NONE (no transport security) is deliberately not offered in the UI: selecting it would
// send the account password/token in cleartext. It stays in the enum only for local
// test servers, which are configured in tests rather than through this screen.
MailSecurity.entries.filter { it != MailSecurity.NONE }.forEach { security ->
FilterChip(
selected = selected == security,
onClick = { onSelect(security) },
label = { Text(security.label()) },
)
}
}
}
}
@Composable
private fun AdvancedToggle(expanded: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.settings_advanced),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Filled.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (expanded) 180f else 0f),
)
}
}
private fun MailSecurity.label(): String = when (this) {
MailSecurity.SSL_TLS -> "SSL/TLS"
MailSecurity.STARTTLS -> "STARTTLS"
MailSecurity.NONE -> "None"
}
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.accountsetup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.repository.AccountRepository
data class ManualSetupForm(
val email: String = "",
val password: String = "",
val imapHost: String = "",
val imapPort: String = "993",
val imapSecurity: MailSecurity = MailSecurity.SSL_TLS,
val smtpHost: String = "",
val smtpPort: String = "465",
val smtpSecurity: MailSecurity = MailSecurity.SSL_TLS,
val advancedExpanded: Boolean = false,
val status: SetupStatus = SetupStatus.IDLE,
val error: String? = null,
) {
val isValid: Boolean
get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank()
}
@HiltViewModel
class ManualSetupViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
private val _form = MutableStateFlow(ManualSetupForm())
val form: StateFlow<ManualSetupForm> = _form.asStateFlow()
fun onEmail(value: String) = _form.update { it.copy(email = value) }
fun onPassword(value: String) = _form.update { it.copy(password = value) }
fun onImapHost(value: String) = _form.update { it.copy(imapHost = value) }
fun onImapPort(value: String) = _form.update { it.copy(imapPort = value.filter(Char::isDigit).take(5)) }
fun onImapSecurity(security: MailSecurity) = _form.update { it.copy(imapSecurity = security) }
fun onSmtpHost(value: String) = _form.update { it.copy(smtpHost = value) }
fun onSmtpPort(value: String) = _form.update { it.copy(smtpPort = value.filter(Char::isDigit).take(5)) }
fun onSmtpSecurity(security: MailSecurity) = _form.update { it.copy(smtpSecurity = security) }
fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) }
fun consumeError() = _form.update { it.copy(error = null) }
fun testAndSave() {
val f = _form.value
if (!f.isValid) {
_form.update { it.copy(error = "Enter email, password, and both servers") }
return
}
val account = Account(
id = "imap:${f.email.trim()}",
email = f.email.trim(),
displayName = f.email.trim(),
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig(f.imapHost.trim(), f.imapPort.toIntOrNull() ?: 993, f.imapSecurity),
smtp = ServerConfig(f.smtpHost.trim(), f.smtpPort.toIntOrNull() ?: 465, f.smtpSecurity),
)
viewModelScope.launch {
_form.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
accountRepository.addImapAccount(account, f.password).fold(
onSuccess = { _form.update { it.copy(status = SetupStatus.DONE) } },
onFailure = { e ->
_form.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Could not connect to the server") }
},
)
}
}
}
@@ -1,35 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
import org.libremail.R
import org.libremail.domain.model.Account
import org.libremail.domain.model.OutgoingAttachment
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -38,59 +72,213 @@ fun ComposeScreen(
viewModel: ComposeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val notImplemented = stringResource(R.string.compose_not_implemented)
val context = LocalContext.current
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> viewModel.onContactsPermission(granted) }
val attachmentPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
viewModel.addAttachments(
uris.map { uri ->
runCatching {
context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
OutgoingAttachment(uri.toString(), queryFileName(context, uri))
},
)
}
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
if (granted) viewModel.onContactsPermission(true) else permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
LaunchedEffect(Unit) { viewModel.finished.collect { onBack() } }
BackHandler { viewModel.onExit() }
LaunchedEffect(state.error) {
state.error?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_compose)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
IconButton(onClick = viewModel::onExit) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
IconButton(onClick = { scope.launch { snackbarHostState.showSnackbar(notImplemented) } }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.action_send),
)
IconButton(
onClick = viewModel::send,
enabled = state.to.isNotBlank() && !state.sending,
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.action_send))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
) {
OutlinedTextField(
value = state.to,
onValueChange = viewModel::onToChange,
label = { Text(stringResource(R.string.compose_to)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.subject,
onValueChange = viewModel::onSubjectChange,
label = { Text(stringResource(R.string.compose_subject)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.body,
onValueChange = viewModel::onBodyChange,
label = { Text(stringResource(R.string.compose_body)) },
modifier = Modifier.fillMaxWidth().weight(1f),
Box(Modifier.fillMaxSize().padding(padding)) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
FromRow(accounts = accounts, selectedId = state.fromAccountId, onSelect = viewModel::selectFrom)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.to,
onValueChange = viewModel::onToChange,
label = { Text(stringResource(R.string.compose_to)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
if (state.suggestions.isNotEmpty()) {
SuggestionList(state.suggestions, viewModel::pickSuggestion)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.cc,
onValueChange = viewModel::onCcChange,
label = { Text(stringResource(R.string.compose_cc)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.subject,
onValueChange = viewModel::onSubjectChange,
label = { Text(stringResource(R.string.compose_subject)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
AttachmentsSection(
attachments = state.attachments,
onAttach = { attachmentPicker.launch(arrayOf("*/*")) },
onRemove = viewModel::removeAttachment,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.body,
onValueChange = viewModel::onBodyChange,
label = { Text(stringResource(R.string.compose_body)) },
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
if (state.sending) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
private fun FromRow(accounts: List<Account>, selectedId: String?, onSelect: (String) -> Unit) {
val from = accounts.firstOrNull { it.id == selectedId } ?: accounts.firstOrNull()
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.compose_from) + ": ",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (from == null) {
Text(stringResource(R.string.compose_no_account), style = MaterialTheme.typography.bodyMedium)
} else if (accounts.size <= 1) {
Text(from.email, style = MaterialTheme.typography.bodyMedium)
} else {
var open by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { open = true }) {
Text(from.email)
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
accounts.forEach { account ->
DropdownMenuItem(
text = { Text(account.email) },
onClick = { onSelect(account.id); open = false },
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AttachmentsSection(
attachments: List<OutgoingAttachment>,
onAttach: () -> Unit,
onRemove: (String) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
TextButton(onClick = onAttach) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.compose_attach))
}
attachments.forEach { attachment ->
InputChip(
selected = false,
onClick = { onRemove(attachment.uri) },
label = { Text(attachment.name, maxLines = 1) },
trailingIcon = {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.compose_attachment_remove))
},
)
}
}
}
private fun queryFileName(context: Context, uri: Uri): String {
val name = context.contentResolver
.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
return name ?: uri.lastPathSegment?.substringAfterLast('/') ?: "attachment"
}
@Composable
private fun SuggestionList(
suggestions: List<org.libremail.contacts.ContactSuggestion>,
onPick: (org.libremail.contacts.ContactSuggestion) -> Unit,
) {
Surface(tonalElevation = 2.dp, modifier = Modifier.fillMaxWidth()) {
Column {
suggestions.forEach { suggestion ->
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(suggestion) }
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(suggestion.name, style = MaterialTheme.typography.bodyMedium)
Text(
suggestion.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
}
}
}
}
@@ -1,26 +1,195 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.contacts.ContactSuggestion
import org.libremail.contacts.ContactsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.Draft
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
data class ComposeUiState(
val to: String = "",
val cc: String = "",
val subject: String = "",
val body: String = "",
val fromAccountId: String? = null,
val attachments: List<OutgoingAttachment> = emptyList(),
val suggestions: List<ContactSuggestion> = emptyList(),
val contactsAllowed: Boolean = false,
val sending: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class ComposeViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow(ComposeUiState())
class ComposeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mailRepository: MailRepository,
private val accountRepository: AccountRepository,
private val contactsRepository: ContactsRepository,
) : ViewModel() {
private val draftId: String? =
savedStateHandle.get<String>(Routes.COMPOSE_ARG_DRAFT)?.takeIf { it.isNotBlank() }
private val _state = MutableStateFlow(
ComposeUiState(
to = savedStateHandle.get<String>(Routes.COMPOSE_ARG_TO).orEmpty(),
subject = savedStateHandle.get<String>(Routes.COMPOSE_ARG_SUBJECT).orEmpty(),
fromAccountId = savedStateHandle.get<String>(Routes.COMPOSE_ARG_FROM)?.takeIf { it.isNotBlank() },
),
)
val state: StateFlow<ComposeUiState> = _state.asStateFlow()
fun onToChange(value: String) = _state.update { it.copy(to = value) }
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Emitted when the screen should close — after the draft is saved/deleted, or after sending. */
private val _finished = Channel<Unit>(Channel.BUFFERED)
val finished = _finished.receiveAsFlow()
private var searchJob: Job? = null
/** Guards against double-navigation and against saving a draft for an already-sent message. */
@Volatile private var navigated = false
init {
if (draftId != null) {
viewModelScope.launch {
mailRepository.getDraft(draftId)?.let { draft ->
_state.update {
it.copy(
to = draft.to,
cc = draft.cc,
subject = draft.subject,
body = draft.body,
fromAccountId = draft.accountId ?: it.fromAccountId,
attachments = draft.attachments,
)
}
}
}
}
}
fun onToChange(value: String) {
_state.update { it.copy(to = value) }
searchContacts(value)
}
fun onCcChange(value: String) = _state.update { it.copy(cc = value) }
fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) }
fun onBodyChange(value: String) = _state.update { it.copy(body = value) }
fun selectFrom(accountId: String) = _state.update { it.copy(fromAccountId = accountId) }
fun addAttachments(items: List<OutgoingAttachment>) =
_state.update { it.copy(attachments = it.attachments + items) }
fun removeAttachment(uri: String) =
_state.update { it.copy(attachments = it.attachments.filterNot { a -> a.uri == uri }) }
fun consumeError() = _state.update { it.copy(error = null) }
fun onContactsPermission(granted: Boolean) = _state.update { it.copy(contactsAllowed = granted) }
fun pickSuggestion(suggestion: ContactSuggestion) {
val current = _state.value.to
val prefix = if (current.contains(',')) current.substringBeforeLast(',') + ", " else ""
_state.update { it.copy(to = prefix + suggestion.email, suggestions = emptyList()) }
}
private fun searchContacts(value: String) {
searchJob?.cancel()
if (!_state.value.contactsAllowed) return
val token = value.substringAfterLast(',').trim()
if (token.length < 2) {
_state.update { it.copy(suggestions = emptyList()) }
return
}
searchJob = viewModelScope.launch {
val results = contactsRepository.search(token)
_state.update { it.copy(suggestions = results) }
}
}
/** Leaving the screen: keep a draft if there's anything worth keeping, then close. */
fun onExit() {
if (navigated) return
viewModelScope.launch {
// Don't save a draft for a message that's mid-send (send() will finish the screen).
if (!_state.value.sending) saveOrDeleteDraft()
finish()
}
}
/** Closes the screen exactly once, so send() and a stray back-press can't double-pop. */
private suspend fun finish() {
if (navigated) return
navigated = true
_finished.send(Unit)
}
private suspend fun saveOrDeleteDraft() {
val s = _state.value
val hasContent = s.to.isNotBlank() || s.cc.isNotBlank() || s.subject.isNotBlank() ||
s.body.isNotBlank() || s.attachments.isNotEmpty()
when {
hasContent -> mailRepository.saveDraft(
Draft(
id = draftId ?: UUID.randomUUID().toString(),
accountId = s.fromAccountId,
to = s.to,
cc = s.cc,
subject = s.subject,
body = s.body,
updatedAt = System.currentTimeMillis(),
attachments = s.attachments,
),
)
draftId != null -> mailRepository.deleteDraft(draftId) // an existing draft was emptied out
}
}
fun send() {
viewModelScope.launch {
val s = _state.value
// Await the account list if it hasn't emitted yet, so an early tap doesn't wrongly
// report "Add an account first".
val available = accounts.value.ifEmpty { accountRepository.observeAccounts().first() }
val account = available.firstOrNull { it.id == s.fromAccountId } ?: available.firstOrNull()
when {
account == null -> _state.update { it.copy(error = "Add an account first") }
s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") }
else -> {
_state.update { it.copy(sending = true, error = null) }
mailRepository.sendMessage(
OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments),
).fold(
onSuccess = {
draftId?.let { mailRepository.deleteDraft(it) }
_state.update { it.copy(sending = false) }
finish()
},
onFailure = { e -> _state.update { it.copy(sending = false, error = e.message ?: "Could not send") } },
)
}
}
}
}
}
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.drafts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.domain.model.Draft
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DraftsScreen(
onBack: () -> Unit,
onOpenDraft: (String) -> Unit,
viewModel: DraftsViewModel = hiltViewModel(),
) {
val drafts by viewModel.drafts.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.drafts_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
)
},
) { padding ->
if (drafts.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.drafts_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(drafts, key = { it.id }) { draft ->
DraftRow(
draft = draft,
onClick = { onOpenDraft(draft.id) },
onDelete = { viewModel.deleteDraft(draft.id) },
)
HorizontalDivider()
}
}
}
}
}
@Composable
private fun DraftRow(draft: Draft, onClick: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = draft.subject.ifBlank { stringResource(R.string.draft_no_subject) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = draft.to.ifBlank { stringResource(R.string.draft_no_recipient) },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (draft.body.isNotBlank()) {
Text(
text = draft.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.width(8.dp))
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.draft_delete))
}
}
}
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.drafts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.domain.model.Draft
import org.libremail.domain.repository.MailRepository
@HiltViewModel
class DraftsViewModel @Inject constructor(
private val repository: MailRepository,
) : ViewModel() {
val drafts: StateFlow<List<Draft>> = repository.observeDrafts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun deleteDraft(id: String) {
viewModelScope.launch { repository.deleteDraft(id) }
}
}
@@ -2,8 +2,10 @@
package org.libremail.ui.mailbox
import android.text.format.DateUtils
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,23 +19,39 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -41,22 +59,73 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.domain.model.Account
import org.libremail.domain.model.Message
import org.libremail.ui.LibreMailBottomBar
import org.libremail.ui.TopDest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MailboxScreen(
onOpenMessage: (String) -> Unit,
onCompose: () -> Unit,
onSelectTab: (TopDest) -> Unit,
onOpenDrafts: () -> Unit,
onOpenOutbox: () -> Unit,
onAddAccount: () -> Unit,
onSelectTab: (org.libremail.ui.TopDest) -> Unit,
viewModel: MailboxViewModel = hiltViewModel(),
) {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle()
val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle()
val draftCount by viewModel.draftCount.collectAsStateWithLifecycle()
val outboxCount by viewModel.outboxCount.collectAsStateWithLifecycle()
val searchActive by viewModel.searchActive.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
BackHandler(enabled = searchActive) { viewModel.closeSearch() }
LaunchedEffect(error) {
error?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.title_mailbox)) }) },
bottomBar = { LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = onSelectTab) },
topBar = {
TopAppBar(
title = {
if (searchActive) {
SearchField(query = searchQuery, onQueryChange = viewModel::onSearchQuery)
} else {
Text(stringResource(R.string.title_mailbox))
}
},
navigationIcon = {
if (searchActive) {
IconButton(onClick = viewModel::closeSearch) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_close))
}
}
},
actions = {
if (hasAccounts && !searchActive) {
IconButton(onClick = viewModel::openSearch) {
Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search))
}
}
},
)
},
bottomBar = {
org.libremail.ui.LibreMailBottomBar(
current = org.libremail.ui.TopDest.MAILBOX,
onSelect = onSelectTab,
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onCompose,
@@ -64,14 +133,56 @@ fun MailboxScreen(
text = { Text(stringResource(R.string.action_compose)) },
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
if (messages.isEmpty()) {
EmptyState(Modifier.padding(padding))
} else {
LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
items(messages, key = { it.id }) { message ->
MessageRow(message = message, onClick = { onOpenMessage(message.id) })
HorizontalDivider()
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (!hasAccounts) {
NoAccountState(onAddAccount = onAddAccount)
} else {
val accountsById = remember(accounts) { accounts.associateBy { it.id } }
val showAccount = selectedAccountId == null && accounts.size >= 2
Column(Modifier.fillMaxSize()) {
if (accounts.size >= 2) {
AccountFilterRow(
accounts = accounts,
selectedId = selectedAccountId,
onSelect = viewModel::selectAccount,
)
}
if (draftCount > 0 && !searchActive) {
DraftsEntry(count = draftCount, onClick = onOpenDrafts)
HorizontalDivider()
}
if (outboxCount > 0 && !searchActive) {
OutboxEntry(count = outboxCount, onClick = onOpenOutbox)
HorizontalDivider()
}
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize(),
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
if (messages.isEmpty()) {
item {
if (searchActive && searchQuery.isNotBlank()) {
NoResultsState(Modifier.fillParentMaxSize())
} else {
NoMessagesState(Modifier.fillParentMaxSize())
}
}
} else {
items(messages, key = { it.id }) { message ->
MessageRow(
message = message,
accountLabel = if (showAccount) accountsById[message.accountId]?.email else null,
onClick = { onOpenMessage(message.id) },
)
HorizontalDivider()
}
}
}
}
}
}
}
@@ -79,7 +190,93 @@ fun MailboxScreen(
}
@Composable
private fun MessageRow(message: Message, onClick: () -> Unit) {
private fun SearchField(query: String, onQueryChange: (String) -> Unit) {
val focusRequester = remember { FocusRequester() }
BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
decorationBox = { innerTextField ->
if (query.isEmpty()) {
Text(
stringResource(R.string.search_hint),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
innerTextField()
},
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
@Composable
private fun DraftsEntry(count: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Edit, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.drafts_count, count),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun OutboxEntry(count: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.outbox_count, count),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun AccountFilterRow(accounts: List<Account>, selectedId: String?, onSelect: (String?) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = selectedId == null,
onClick = { onSelect(null) },
label = { Text(stringResource(R.string.mailbox_all_accounts)) },
)
accounts.forEach { account ->
FilterChip(
selected = selectedId == account.id,
onClick = { onSelect(account.id) },
label = { Text(account.email, maxLines = 1) },
)
}
}
}
@Composable
private fun MessageRow(message: Message, accountLabel: String?, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -113,13 +310,24 @@ private fun MessageRow(message: Message, onClick: () -> Unit) {
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = message.snippet,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (message.snippet.isNotBlank()) {
Text(
text = message.snippet,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (accountLabel != null) {
Text(
text = accountLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -142,9 +350,37 @@ private fun Avatar(name: String) {
}
@Composable
private fun EmptyState(modifier: Modifier = Modifier) {
private fun NoAccountState(onAddAccount: () -> Unit) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Filled.Email,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Text(stringResource(R.string.mailbox_welcome_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.mailbox_welcome_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
Button(onClick = onAddAccount) {
Text(stringResource(R.string.settings_add_account))
}
}
}
@Composable
private fun NoMessagesState(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -158,13 +394,31 @@ private fun EmptyState(modifier: Modifier = Modifier) {
Text(stringResource(R.string.mailbox_empty), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.mailbox_empty_subtitle),
stringResource(R.string.mailbox_pull_to_refresh),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun NoResultsState(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Text(stringResource(R.string.search_no_results), style = MaterialTheme.typography.titleMedium)
}
}
private fun formatTimestamp(millis: Long): String =
DateUtils.getRelativeTimeSpanString(
millis,
@@ -5,17 +5,127 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.data.sync.MailSyncer
import org.libremail.domain.model.Account
import org.libremail.domain.model.Message
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
@HiltViewModel
class MailboxViewModel @Inject constructor(
repository: MailRepository,
private val mailRepository: MailRepository,
accountRepository: AccountRepository,
private val mailSyncer: MailSyncer,
) : ViewModel() {
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val hasAccounts: StateFlow<Boolean> = accounts
.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
/** null = unified "All inboxes"; otherwise the account whose mail is shown. */
private val _selectedAccountId = MutableStateFlow<String?>(null)
val selectedAccountId: StateFlow<String?> = _selectedAccountId.asStateFlow()
private val _searchActive = MutableStateFlow(false)
val searchActive: StateFlow<Boolean> = _searchActive.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val messages: StateFlow<List<Message>> =
repository.observeMessages()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
combine(mailRepository.observeMessages(), _selectedAccountId, _searchQuery) { all, accountId, query ->
val q = query.trim()
all.filter { message ->
(accountId == null || message.accountId == accountId) &&
// Outside of search show only inbox rows; while searching show every match,
// including transient server-search hits that aren't in the inbox.
(if (q.isEmpty()) message.inInbox else message.matchesSearch(q))
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val draftCount: StateFlow<Int> = mailRepository.observeDrafts()
.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
val outboxCount: StateFlow<Int> = mailRepository.observeOutbox()
.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
init {
// Fall back to the unified view if the filtered account is removed.
viewModelScope.launch {
accounts.collect { list ->
val selected = _selectedAccountId.value
if (selected != null && list.none { it.id == selected }) _selectedAccountId.value = null
}
}
// Server-side search: fetch matches into the cache; the list filter then surfaces them.
viewModelScope.launch {
_searchQuery
.debounce(400L)
.map { it.trim() }
.filter { it.length >= 2 }
.distinctUntilChanged()
.collect { query -> mailRepository.searchServer(query) }
}
}
fun selectAccount(accountId: String?) {
_selectedAccountId.value = accountId
}
fun openSearch() {
_searchActive.value = true
}
fun closeSearch() {
_searchActive.value = false
_searchQuery.value = ""
// Drop the transient server-search hits so they don't linger in the inbox.
viewModelScope.launch { mailRepository.clearSearchResults() }
}
fun onSearchQuery(query: String) {
_searchQuery.value = query
}
fun refresh() {
if (_isRefreshing.value) return
viewModelScope.launch {
_isRefreshing.value = true
mailSyncer.syncAll().onFailure { _error.value = it.message ?: "Sync failed" }
_isRefreshing.value = false
}
}
fun consumeError() {
_error.value = null
}
}
/** Local match over the always-populated header fields (and snippet, once a body is cached). */
private fun Message.matchesSearch(query: String): Boolean =
sender.contains(query, ignoreCase = true) ||
senderEmail.contains(query, ignoreCase = true) ||
subject.contains(query, ignoreCase = true) ||
snippet.contains(query, ignoreCase = true)
@@ -1,14 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.navigation
import android.net.Uri
/** Navigation routes. String-based to avoid extra serialization setup for the MVP. */
object Routes {
const val MAILBOX = "mailbox"
const val SETTINGS = "settings"
const val COMPOSE = "compose"
const val ACCOUNT_SETUP = "account_setup"
const val MANUAL_SETUP = "manual_setup"
const val DRAFTS = "drafts"
const val OUTBOX = "outbox"
const val READER_ARG_ID = "messageId"
const val READER_PATTERN = "reader/{$READER_ARG_ID}"
fun reader(messageId: String) = "reader/$messageId"
fun reader(messageId: String) = "reader/${Uri.encode(messageId)}"
const val COMPOSE_ARG_TO = "to"
const val COMPOSE_ARG_SUBJECT = "subject"
const val COMPOSE_ARG_FROM = "from"
const val COMPOSE_ARG_DRAFT = "draft"
const val COMPOSE_PATTERN =
"compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}&draft={$COMPOSE_ARG_DRAFT}"
fun compose(to: String = "", subject: String = "", from: String = ""): String =
"compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}&from=${Uri.encode(from)}"
fun composeDraft(draftId: String): String = "compose?draft=${Uri.encode(draftId)}"
}
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.outbox
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.domain.model.OutboxMessage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OutboxScreen(
onBack: () -> Unit,
viewModel: OutboxViewModel = hiltViewModel(),
) {
val messages by viewModel.messages.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.outbox_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
if (messages.isNotEmpty()) {
TextButton(onClick = viewModel::retry) {
Text(stringResource(R.string.outbox_retry))
}
}
},
)
},
) { padding ->
if (messages.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.outbox_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(messages, key = { it.id }) { message ->
OutboxRow(message = message, onCancel = { viewModel.cancel(message.id) })
HorizontalDivider()
}
}
}
}
}
@Composable
private fun OutboxRow(message: OutboxMessage, onCancel: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = message.subject.ifBlank { stringResource(R.string.draft_no_subject) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = message.to.ifBlank { stringResource(R.string.draft_no_recipient) },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (message.lastError == null) {
Text(
text = stringResource(R.string.outbox_status_queued),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
text = stringResource(R.string.outbox_status_failed),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
Spacer(Modifier.width(8.dp))
IconButton(onClick = onCancel) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.outbox_cancel))
}
}
}
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.outbox
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.repository.MailRepository
@HiltViewModel
class OutboxViewModel @Inject constructor(
private val repository: MailRepository,
) : ViewModel() {
val messages: StateFlow<List<OutboxMessage>> = repository.observeOutbox()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun cancel(id: String) {
viewModelScope.launch { repository.cancelOutboxMessage(id) }
}
fun retry() {
viewModelScope.launch { repository.retryOutbox() }
}
}
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reader
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
/**
* Renders an HTML email body in a hardened WebView: JavaScript and file/content access are
* disabled, links open in the system browser, and remote content is blocked until the user
* opts in (tracking-pixel protection).
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun HtmlBody(
html: String,
loadRemoteImages: Boolean,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
// Tracks the content actually loaded so recompositions (star/attachment state changes) don't
// reload the page and throw away the user's scroll position.
val lastLoaded = remember { mutableStateOf<Pair<String, Boolean>?>(null) }
AndroidView(
modifier = modifier,
factory = { ctx ->
WebView(ctx).apply {
with(settings) {
javaScriptEnabled = false
allowFileAccess = false
allowContentAccess = false
domStorageEnabled = false
cacheMode = WebSettings.LOAD_NO_CACHE
setSupportZoom(true)
builtInZoomControls = true
displayZoomControls = false
}
setBackgroundColor(Color.TRANSPARENT)
isVerticalScrollBarEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url ?: return false
// Only open ordinary web/mail links, and only on an actual user tap — never
// auto-launch arbitrary intent:/market:/custom-scheme URIs (e.g. via a
// meta-refresh in a malicious email) or navigate inside the WebView.
val scheme = url.scheme?.lowercase()
if (scheme != "http" && scheme != "https" && scheme != "mailto") return true
if (!request.hasGesture()) return true
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, url).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
return true
}
}
}
},
update = { webView ->
webView.settings.blockNetworkLoads = !loadRemoteImages
val key = html to loadRemoteImages
if (lastLoaded.value != key) {
lastLoaded.value = key
webView.loadDataWithBaseURL(null, wrapHtml(html), "text/html", "UTF-8", null)
}
},
)
}
private fun wrapHtml(body: String): String =
"""
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src http: https: data: cid:; style-src 'unsafe-inline'; font-src data:">
<style>
body { font-family: sans-serif; line-height: 1.5; padding: 16px; word-wrap: break-word; }
img { max-width: 100%; height: auto; }
a { color: #0B57D0; }
</style>
</head>
<body>$body</body>
</html>
""".trimIndent()
@@ -1,21 +1,30 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reader
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -23,88 +32,311 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import java.io.File
import org.libremail.R
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderScreen(
onBack: () -> Unit,
onReply: (to: String, subject: String, from: String) -> Unit,
viewModel: ReaderViewModel = hiltViewModel(),
) {
val message by viewModel.message.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val noAppMessage = stringResource(R.string.attachment_no_app)
val downloadFailedTemplate = stringResource(R.string.attachment_download_failed)
LaunchedEffect(state.deleted) {
if (state.deleted) onBack()
}
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is ReaderEvent.OpenFile ->
if (!openAttachment(context, event.file, event.mimeType)) {
snackbarHostState.showSnackbar(noAppMessage)
}
is ReaderEvent.DownloadFailed ->
snackbarHostState.showSnackbar(downloadFailedTemplate.format(event.name))
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_reader)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
val message = state.message
if (message != null) {
TextButton(onClick = { onReply(message.senderEmail, "Re: ${message.subject}", message.accountId) }) {
Text(stringResource(R.string.reader_reply))
}
IconButton(onClick = viewModel::toggleStar) {
Icon(
Icons.Filled.Star,
contentDescription = stringResource(R.string.reader_star),
tint = if (message.isStarred) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
IconButton(onClick = viewModel::delete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.reader_delete))
}
}
},
)
},
) { padding ->
val msg = message
if (msg == null) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
val message = state.message
when {
state.loading -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(msg.subject, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = msg.sender.trim().firstOrNull()?.uppercase() ?: "?",
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(Modifier.width(12.dp))
Column {
Text(msg.sender, style = MaterialTheme.typography.titleMedium)
Text(
msg.senderEmail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
Text(msg.body, style = MaterialTheme.typography.bodyLarge)
message != null -> MessageBody(
message = message,
attachments = state.attachments,
downloading = state.downloading,
onDownloadAttachment = viewModel::downloadAttachment,
loadRemoteImages = state.loadRemoteImages,
onLoadRemoteImages = viewModel::loadRemoteImages,
contentPadding = padding,
)
else -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(state.error ?: stringResource(R.string.reader_empty))
}
}
}
}
@Composable
private fun MessageBody(
message: Message,
attachments: List<Attachment>,
downloading: Set<Int>,
onDownloadAttachment: (Attachment) -> Unit,
loadRemoteImages: Boolean,
onLoadRemoteImages: () -> Unit,
contentPadding: PaddingValues,
) {
Column(Modifier.fillMaxSize().padding(contentPadding)) {
Header(message)
HorizontalDivider()
if (attachments.isNotEmpty()) {
Attachments(attachments, downloading, onDownloadAttachment)
HorizontalDivider()
}
if (message.isHtml && !loadRemoteImages) {
RemoteImagesBanner(onLoadRemoteImages)
}
when {
message.body.isBlank() -> Text(
stringResource(R.string.reader_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
message.isHtml -> HtmlBody(
html = message.body,
loadRemoteImages = loadRemoteImages,
modifier = Modifier.fillMaxSize(),
)
else -> SelectionContainer(modifier = Modifier.fillMaxSize()) {
Text(
text = message.body,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
)
}
}
}
}
@Composable
private fun Attachments(
attachments: List<Attachment>,
downloading: Set<Int>,
onDownload: (Attachment) -> Unit,
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
stringResource(R.string.attachments_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
attachments.forEach { attachment ->
AttachmentRow(
attachment = attachment,
downloading = attachment.partIndex in downloading,
onClick = { onDownload(attachment) },
)
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun AttachmentRow(attachment: Attachment, downloading: Boolean, onClick: () -> Unit) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !downloading, onClick = onClick),
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
if (downloading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(
text = fileExtension(attachment.filename),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
attachment.filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val size = formatSize(attachment.sizeBytes)
if (size.isNotEmpty()) {
Text(
size,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun Header(message: Message) {
Column(Modifier.padding(16.dp)) {
Text(message.subject, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = message.sender.trim().firstOrNull()?.uppercase() ?: "?",
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(Modifier.width(12.dp))
Column {
Text(message.sender, style = MaterialTheme.typography.titleMedium)
Text(
message.senderEmail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun RemoteImagesBanner(onLoadRemoteImages: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.reader_images_blocked),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onLoadRemoteImages) {
Text(stringResource(R.string.reader_show_images))
}
}
}
/** Launches a viewer for the downloaded file via a FileProvider URI. Returns false if no app handles it. */
private fun openAttachment(context: Context, file: File, mimeType: String): Boolean {
val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType.ifBlank { "*/*" })
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
return try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
}
private fun fileExtension(filename: String): String =
filename.substringAfterLast('.', "").uppercase().take(4).ifBlank { "FILE" }
private fun formatSize(bytes: Long): String = when {
bytes <= 0 -> ""
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "%.0f KB".format(bytes / 1024.0)
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
@@ -5,23 +5,100 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
data class ReaderUiState(
val loading: Boolean = true,
val message: Message? = null,
val attachments: List<Attachment> = emptyList(),
val downloading: Set<Int> = emptySet(),
val loadRemoteImages: Boolean = false,
val deleted: Boolean = false,
val error: String? = null,
)
/** One-shot effects the reader screen acts on (launching a viewer, showing a message). */
sealed interface ReaderEvent {
data class OpenFile(val file: File, val mimeType: String, val name: String) : ReaderEvent
data class DownloadFailed(val name: String) : ReaderEvent
}
@HiltViewModel
class ReaderViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: MailRepository,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val messageId: String = checkNotNull(savedStateHandle[Routes.READER_ARG_ID])
val message: StateFlow<Message?> = flow {
emit(repository.getMessage(messageId))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _state = MutableStateFlow(ReaderUiState())
val state: StateFlow<ReaderUiState> = _state.asStateFlow()
private val _events = Channel<ReaderEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
init {
// Honor the global "load remote images by default" preference.
viewModelScope.launch {
if (settingsRepository.settings.first().loadRemoteImages) {
_state.update { it.copy(loadRemoteImages = true) }
}
}
viewModelScope.launch {
repository.openMessage(messageId).fold(
onSuccess = { message -> _state.update { it.copy(loading = false, message = message) } },
onFailure = { e -> _state.update { it.copy(loading = false, error = e.message ?: "Could not load message") } },
)
}
viewModelScope.launch {
repository.observeAttachments(messageId).collect { attachments ->
_state.update { it.copy(attachments = attachments) }
}
}
}
fun downloadAttachment(attachment: Attachment) {
if (attachment.partIndex in _state.value.downloading) return
_state.update { it.copy(downloading = it.downloading + attachment.partIndex) }
viewModelScope.launch {
repository.downloadAttachment(attachment.messageId, attachment.partIndex).fold(
onSuccess = { file ->
_events.send(ReaderEvent.OpenFile(file, attachment.mimeType, attachment.filename))
},
onFailure = { _events.send(ReaderEvent.DownloadFailed(attachment.filename)) },
)
_state.update { it.copy(downloading = it.downloading - attachment.partIndex) }
}
}
fun toggleStar() {
val message = _state.value.message ?: return
val starred = !message.isStarred
_state.update { it.copy(message = message.copy(isStarred = starred)) }
viewModelScope.launch { repository.setStarred(messageId, starred) }
}
fun loadRemoteImages() = _state.update { it.copy(loadRemoteImages = true) }
fun delete() {
viewModelScope.launch {
repository.deleteMessage(messageId)
_state.update { it.copy(deleted = true) }
}
}
}
@@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -42,7 +43,10 @@ fun SettingsScreen(
onSelectTab: (TopDest) -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) },
bottomBar = { LibreMailBottomBar(current = TopDest.SETTINGS, onSelect = onSelectTab) },
@@ -53,39 +57,84 @@ fun SettingsScreen(
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
SectionHeader(stringResource(R.string.settings_accounts))
if (accounts.isEmpty()) {
Text(
text = stringResource(R.string.settings_no_accounts),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} else {
accounts.forEach { account ->
AccountRow(email = account.email, onRemove = { viewModel.removeAccount(account.id) })
}
}
ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_notifications))
SwitchRow(
title = stringResource(R.string.settings_new_mail),
checked = settings.newMailNotifications,
onCheckedChange = viewModel::setNewMailNotifications,
subtitle = stringResource(R.string.settings_new_mail_summary),
)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_appearance))
SwitchRow(
title = stringResource(R.string.settings_dynamic_color),
checked = state.dynamicColor,
checked = settings.dynamicColor,
onCheckedChange = viewModel::setDynamicColor,
subtitle = stringResource(R.string.settings_dynamic_color_summary),
)
ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount)
HorizontalDivider()
AdvancedHeader(expanded = state.advancedExpanded, onToggle = viewModel::toggleAdvanced)
AnimatedVisibility(visible = state.advancedExpanded) {
AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced)
AnimatedVisibility(visible = advancedExpanded) {
Column {
SwitchRow(
title = stringResource(R.string.settings_adv_idle),
checked = state.pushIdle,
checked = settings.pushIdle,
onCheckedChange = viewModel::setPushIdle,
)
SwitchRow(
title = stringResource(R.string.settings_adv_starttls),
checked = state.allowStartTls,
checked = settings.allowStartTls,
onCheckedChange = viewModel::setAllowStartTls,
subtitle = stringResource(R.string.settings_adv_starttls_summary),
)
SwitchRow(
title = stringResource(R.string.settings_adv_remote_images),
checked = state.loadRemoteImages,
checked = settings.loadRemoteImages,
onCheckedChange = viewModel::setLoadRemoteImages,
)
SwitchRow(
title = stringResource(R.string.settings_adv_encrypt_cache),
checked = settings.encryptCache,
onCheckedChange = viewModel::setEncryptCache,
subtitle = stringResource(R.string.settings_adv_encrypt_cache_summary),
)
}
}
}
}
}
@Composable
private fun AccountRow(email: String, onRemove: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(email, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
TextButton(onClick = onRemove) { Text(stringResource(R.string.account_remove)) }
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
@@ -2,29 +2,48 @@
package org.libremail.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
data class SettingsUiState(
val dynamicColor: Boolean = true,
val advancedExpanded: Boolean = false,
val pushIdle: Boolean = true,
val allowStartTls: Boolean = false,
val loadRemoteImages: Boolean = false,
)
import kotlinx.coroutines.launch
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.repository.AccountRepository
@HiltViewModel
class SettingsViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow(SettingsUiState())
val state: StateFlow<SettingsUiState> = _state.asStateFlow()
class SettingsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
fun setDynamicColor(value: Boolean) = _state.update { it.copy(dynamicColor = value) }
fun toggleAdvanced() = _state.update { it.copy(advancedExpanded = !it.advancedExpanded) }
fun setPushIdle(value: Boolean) = _state.update { it.copy(pushIdle = value) }
fun setAllowStartTls(value: Boolean) = _state.update { it.copy(allowStartTls = value) }
fun setLoadRemoteImages(value: Boolean) = _state.update { it.copy(loadRemoteImages = value) }
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val settings: StateFlow<AppSettings> = settingsRepository.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings())
private val _advancedExpanded = MutableStateFlow(false)
val advancedExpanded: StateFlow<Boolean> = _advancedExpanded.asStateFlow()
fun toggleAdvanced() = _advancedExpanded.update { !it }
fun removeAccount(id: String) = viewModelScope.launch { accountRepository.deleteAccount(id) }.let {}
fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) }
fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) }
fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) }
fun setAllowStartTls(value: Boolean) = update { settingsRepository.setAllowStartTls(value) }
fun setLoadRemoteImages(value: Boolean) = update { settingsRepository.setLoadRemoteImages(value) }
fun setEncryptCache(value: Boolean) = update { settingsRepository.setEncryptCache(value) }
private inline fun update(crossinline action: suspend () -> Unit) {
viewModelScope.launch { action() }
}
}
+73 -4
View File
@@ -6,6 +6,11 @@
<!-- Destinations -->
<string name="title_mailbox">Inbox</string>
<string name="title_reader">Message</string>
<string name="reader_star">Star</string>
<string name="reader_delete">Delete</string>
<string name="reader_empty">This message has no text content</string>
<string name="reader_images_blocked">Remote images are blocked</string>
<string name="reader_show_images">Show images</string>
<string name="title_compose">Compose</string>
<string name="title_settings">Settings</string>
<string name="title_account_setup">Add account</string>
@@ -19,20 +24,81 @@
<!-- Mailbox -->
<string name="mailbox_empty">No messages yet</string>
<string name="mailbox_empty_subtitle">Add an account to start syncing your mail.</string>
<string name="mailbox_all_accounts">All</string>
<string name="search">Search</string>
<string name="search_close">Close search</string>
<string name="search_hint">Search mail</string>
<string name="search_no_results">No results</string>
<string name="mailbox_pull_to_refresh">Pull down to refresh</string>
<string name="mailbox_welcome_title">Welcome to LibreMail</string>
<string name="mailbox_welcome_subtitle">Add an account to start reading your mail.</string>
<!-- Compose -->
<string name="compose_from">From</string>
<string name="compose_to">To</string>
<string name="compose_cc">Cc</string>
<string name="compose_subject">Subject</string>
<string name="compose_body">Message</string>
<string name="compose_not_implemented">Sending is not available yet</string>
<string name="compose_no_account">Add an account before composing</string>
<string name="compose_attach">Attach file</string>
<string name="compose_attachment_remove">Remove attachment</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
<string name="drafts_empty">No drafts</string>
<string name="drafts_count">Drafts (%1$d)</string>
<string name="draft_no_subject">(no subject)</string>
<string name="draft_no_recipient">(no recipient)</string>
<string name="draft_delete">Delete draft</string>
<!-- Outbox -->
<string name="outbox_title">Outbox</string>
<string name="outbox_empty">Outbox is empty</string>
<string name="outbox_count">Outbox (%1$d)</string>
<string name="outbox_status_queued">Queued</string>
<string name="outbox_status_failed">Couldn\'t send — will retry</string>
<string name="outbox_cancel">Cancel send</string>
<string name="outbox_retry">Retry</string>
<!-- Reader -->
<string name="reader_reply">Reply</string>
<string name="attachments_title">Attachments</string>
<string name="attachment_download_failed">Couldn\'t download %1$s</string>
<string name="attachment_no_app">No app can open this file</string>
<!-- Account setup -->
<string name="account_setup_gmail">Sign in with Google</string>
<string name="account_setup_outlook">Sign in with Microsoft</string>
<string name="account_setup_other">Other (IMAP/SMTP)</string>
<string name="account_setup_subtitle">Choose how you want to connect your mailbox.</string>
<!-- Manual IMAP/SMTP setup -->
<string name="manual_setup_title">IMAP / SMTP</string>
<string name="manual_email">Email address</string>
<string name="manual_password">Password or app password</string>
<string name="manual_incoming">Incoming mail (IMAP)</string>
<string name="manual_outgoing">Outgoing mail (SMTP)</string>
<string name="manual_imap_server">IMAP server</string>
<string name="manual_smtp_server">SMTP server</string>
<string name="manual_imap_port">IMAP port</string>
<string name="manual_smtp_port">SMTP port</string>
<string name="manual_imap_security">IMAP security</string>
<string name="manual_smtp_security">SMTP security</string>
<string name="manual_test_and_add">Test &amp; add account</string>
<!-- Notifications -->
<string name="notif_channel_new_mail">New mail</string>
<string name="notif_new_mail_count">%1$d new messages</string>
<string name="notif_channel_push_status">Push (IMAP IDLE)</string>
<string name="notif_push_status_title">Watching for new mail</string>
<string name="notif_push_status_text">Connected for instant delivery</string>
<!-- Settings -->
<string name="settings_accounts">Accounts</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_new_mail">New-mail notifications</string>
<string name="settings_new_mail_summary">Show a notification when new mail arrives</string>
<string name="settings_no_accounts">No accounts yet</string>
<string name="account_remove">Remove</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_dynamic_color">Use wallpaper colors</string>
<string name="settings_dynamic_color_summary">Material You dynamic theming (Android 12+)</string>
@@ -40,6 +106,9 @@
<string name="settings_advanced">Advanced Settings</string>
<string name="settings_advanced_summary">Power-user options for sync and connections</string>
<string name="settings_adv_idle">Push mail (IMAP IDLE)</string>
<string name="settings_adv_starttls">Allow STARTTLS</string>
<string name="settings_adv_starttls">Allow insecure STARTTLS fallback</string>
<string name="settings_adv_starttls_summary">Connect even when the server can\'t upgrade to an encrypted (STARTTLS) connection. Leave this off unless a specific server requires it — while on, your username and password may be sent unencrypted.</string>
<string name="settings_adv_remote_images">Load remote images by default</string>
<string name="settings_adv_encrypt_cache">Encrypt local cache</string>
<string name="settings_adv_encrypt_cache_summary">Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail.</string>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
LibreMail stores encrypted IMAP/OAuth credentials and cached mail in its Room database, encrypted
with a non-exportable Android Keystore key. The key never leaves the device, so backing up or
transferring the ciphertext would only restore undecryptable data (and needlessly ship mail and
credential blobs off-device). Exclude all app data from both cloud backup and device-to-device
transfer; accounts are simply re-added on a new device.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<paths>
<cache-path name="attachments" path="attachments/" />
</paths>
@@ -9,29 +9,35 @@ import kotlin.test.assertTrue
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.sample.SampleData
class MailRepositoryImplTest {
private val messageDao = mockk<MessageDao>()
private val accountDao = mockk<AccountDao>()
private val repository = MailRepositoryImpl(
context = mockk(),
messageDao = messageDao,
accountDao = mockk(),
attachmentDao = mockk(),
outboxDao = mockk(),
draftDao = mockk(),
imapClient = mockk(),
connectionFactory = mockk(),
sendScheduler = mockk(),
)
@Test
fun `observeMessages emits sample data when cache is empty`() = runTest {
fun `observeMessages is empty when the cache is empty`() = runTest {
every { messageDao.observeAll() } returns flowOf(emptyList())
val repository = MailRepositoryImpl(messageDao, accountDao)
repository.observeMessages().test {
assertEquals(SampleData.messages, awaitItem())
assertTrue(awaitItem().isEmpty())
awaitComplete()
}
}
@Test
fun `observeMessages maps cached entities when present`() = runTest {
fun `observeMessages maps cached entities`() = runTest {
val entity = MessageEntity(
id = "1",
accountId = "a",
@@ -45,8 +51,6 @@ class MailRepositoryImplTest {
isStarred = false,
)
every { messageDao.observeAll() } returns flowOf(listOf(entity))
val repository = MailRepositoryImpl(messageDao, accountDao)
repository.observeMessages().test {
val items = awaitItem()
assertEquals(1, items.size)
@@ -1,22 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sample
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.junit.Test
class SampleDataTest {
@Test
fun `byId returns a known sample message`() {
val message = SampleData.byId("sample-1")
assertNotNull(message)
assertEquals("sample-1", message.id)
}
@Test
fun `byId returns null for an unknown id`() {
assertNull(SampleData.byId("does-not-exist"))
}
}
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.json.JSONObject
import org.junit.Test
import org.libremail.domain.model.OutgoingMessage
class GraphSenderTest {
private fun message(to: String, cc: String = "", subject: String = "Hi", body: String = "Body") =
OutgoingMessage(accountId = "outlook:me@example.com", to = to, cc = cc, subject = subject, body = body)
@Test
fun `payload carries subject, body and parsed recipients`() {
val json = JSONObject(buildSendMailPayload(message(to = "a@x.com, b@y.com", cc = "c@z.com"), emptyList()))
assertTrue(json.getBoolean("saveToSentItems"))
val msg = json.getJSONObject("message")
assertEquals("Hi", msg.getString("subject"))
assertEquals("Text", msg.getJSONObject("body").getString("contentType"))
assertEquals("Body", msg.getJSONObject("body").getString("content"))
val to = msg.getJSONArray("toRecipients")
assertEquals(2, to.length())
assertEquals("a@x.com", to.getJSONObject(0).getJSONObject("emailAddress").getString("address"))
assertEquals("b@y.com", to.getJSONObject(1).getJSONObject("emailAddress").getString("address"))
val cc = msg.getJSONArray("ccRecipients")
assertEquals(1, cc.length())
assertEquals("c@z.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address"))
}
@Test
fun `recipients parse RFC822 display names into bare addresses`() {
val json = JSONObject(
buildSendMailPayload(
message(to = "John Doe <john@example.com>", cc = "\"Doe, Jane\" <jane@example.com>"),
emptyList(),
),
)
val msg = json.getJSONObject("message")
val to = msg.getJSONArray("toRecipients")
assertEquals(1, to.length())
val toAddress = to.getJSONObject(0).getJSONObject("emailAddress")
assertEquals("john@example.com", toAddress.getString("address"))
assertEquals("John Doe", toAddress.getString("name"))
// The comma inside the quoted display name must not be treated as an address separator.
val cc = msg.getJSONArray("ccRecipients")
assertEquals(1, cc.length())
assertEquals("jane@example.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address"))
}
@Test
fun `payload omits cc when blank and encodes attachments as base64`() {
val file = File.createTempFile("graph-att", ".txt").apply { writeText("hello") }
try {
val msg = JSONObject(buildSendMailPayload(message(to = "a@x.com"), listOf(file))).getJSONObject("message")
assertFalse(msg.has("ccRecipients"))
val attachments = msg.getJSONArray("attachments")
assertEquals(1, attachments.length())
val attachment = attachments.getJSONObject(0)
assertEquals("#microsoft.graph.fileAttachment", attachment.getString("@odata.type"))
assertEquals(file.name, attachment.getString("name"))
assertEquals("aGVsbG8=", attachment.getString("contentBytes")) // base64("hello")
} finally {
file.delete()
}
}
}
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import com.icegreen.greenmail.util.GreenMail
import com.icegreen.greenmail.util.GreenMailUtil
import com.icegreen.greenmail.util.ServerSetupTest
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
class ImapClientTest {
private lateinit var greenMail: GreenMail
private val client = ImapClient()
@Before
fun setUp() {
greenMail = GreenMail(ServerSetupTest.SMTP_IMAP)
greenMail.start()
greenMail.setUser("alice@example.org", "secret")
}
@After
fun tearDown() {
greenMail.stop()
}
private fun params(secret: String = "secret") = ImapConnectionParams(
host = "127.0.0.1",
port = greenMail.imap.port,
security = MailSecurity.NONE,
username = "alice@example.org",
secret = secret,
useXoauth2 = false,
)
@Test
fun `listFolders returns INBOX for a valid login`() = runTest {
assertTrue(client.listFolders(params()).any { it.equals("INBOX", ignoreCase = true) })
}
@Test
fun `listFolders fails for a wrong password`() = runTest {
assertFailsWith<Exception> { client.listFolders(params(secret = "wrong-password")) }
}
@Test
fun `fetchRecentInbox returns delivered messages newest first`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "First subject", "Body one")
GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Second subject", "Body two")
greenMail.waitForIncomingEmail(2)
val messages = client.fetchRecentInbox(params(), limit = 50)
assertEquals(2, messages.size)
assertEquals("Second subject", messages.first().subject)
assertEquals(setOf("First subject", "Second subject"), messages.map { it.subject }.toSet())
assertEquals("bob@example.org", messages.first { it.subject == "First subject" }.senderEmail)
}
@Test
fun `fetchBodyMarkingSeen returns the body and marks the message read`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Hello", "The quick brown fox.")
greenMail.waitForIncomingEmail(1)
val uid = client.fetchRecentInbox(params(), limit = 50).first().uid
val content = client.fetchBodyMarkingSeen(params(), uid)
assertTrue(content.body.contains("quick brown fox"), "body=${content.body}")
assertTrue(client.fetchRecentInbox(params(), limit = 50).first().isRead, "should be marked read")
}
@Test
fun `search returns only messages matching the query`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Vacation plans", "Beach trip")
GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Invoice 42", "Payment due")
greenMail.waitForIncomingEmail(2)
val results = client.search(params(), query = "Vacation", limit = 50)
assertEquals(1, results.size)
assertEquals("Vacation plans", results.first().subject)
}
}
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import com.icegreen.greenmail.util.GreenMail
import com.icegreen.greenmail.util.GreenMailUtil
import com.icegreen.greenmail.util.ServerSetupTest
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.SmtpParams
class SmtpSenderTest {
private lateinit var greenMail: GreenMail
private val sender = SmtpSender()
@Before
fun setUp() {
greenMail = GreenMail(ServerSetupTest.SMTP)
greenMail.start()
greenMail.setUser("sender@example.org", "secret")
}
@After
fun tearDown() {
greenMail.stop()
}
@Test
fun `send delivers the message to the SMTP server`() = runTest {
val params = SmtpParams(
host = "127.0.0.1",
port = greenMail.smtp.port,
security = MailSecurity.NONE,
username = "sender@example.org",
secret = "secret",
useXoauth2 = false,
)
sender.send(
params = params,
from = "sender@example.org",
message = OutgoingMessage(
accountId = "x",
to = "bob@example.org",
subject = "Hi Bob",
body = "Hello there from LibreMail.",
),
)
greenMail.waitForIncomingEmail(1)
val received = greenMail.receivedMessages
assertEquals(1, received.size)
assertEquals("Hi Bob", received[0].subject)
assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there"))
}
@Test
fun `send delivers a message with an attachment`() = runTest {
val file = File.createTempFile("libremail-report", ".txt").apply { writeText("quarterly numbers") }
val params = SmtpParams(
host = "127.0.0.1",
port = greenMail.smtp.port,
security = MailSecurity.NONE,
username = "sender@example.org",
secret = "secret",
useXoauth2 = false,
)
sender.send(
params = params,
from = "sender@example.org",
message = OutgoingMessage(
accountId = "x",
to = "bob@example.org",
subject = "With file",
body = "See the attached report.",
),
attachments = listOf(file),
)
greenMail.waitForIncomingEmail(1)
val received = greenMail.receivedMessages.single()
assertTrue(received.contentType.contains("multipart", ignoreCase = true))
val raw = GreenMailUtil.getWholeMessage(received)
assertTrue(raw.contains("See the attached report."))
assertTrue(raw.contains(file.name))
file.delete()
}
}
+10
View File
@@ -10,6 +10,7 @@ activityCompose = "1.12.4"
navigationCompose = "2.9.8"
composeBom = "2026.06.00"
room = "2.8.4"
sqlcipher = "4.16.0"
datastore = "1.2.1"
work = "2.11.2"
coroutines = "1.10.2"
@@ -20,6 +21,8 @@ androidxJunit = "1.2.1"
espresso = "3.6.1"
turbine = "1.2.1"
mockk = "1.14.11"
greenmail = "2.1.9"
errorprone = "2.50.0"
[libraries]
# AndroidX core / lifecycle / activity / navigation
@@ -43,13 +46,19 @@ androidx-compose-material-icons-core = { group = "androidx.compose.material", na
# Hilt (DI)
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
# Pulled onto the compile classpath because Hilt/Dagger generated code references it.
error-prone-annotations = { group = "com.google.errorprone", name = "error_prone_annotations", version.ref = "errorprone" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" }
androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" }
# Room (local cache)
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# SQLCipher — opt-in at-rest encryption of the Room cache.
sqlcipher-android = { group = "net.zetetic", name = "sqlcipher-android", version.ref = "sqlcipher" }
# DataStore (settings) / WorkManager (sync) — wired in later increments
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
@@ -68,6 +77,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
Vendored Regular → Executable
View File

Some files were not shown because too many files have changed in this diff Show More