Merge branch 'main' into feat-release-workflow
This commit is contained in:
+135
@@ -0,0 +1,135 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# LibreMail Privacy Policy
|
||||
|
||||
**Effective date: 2026-07-01** · Applies to the LibreMail Android app (`org.libremail.app`).
|
||||
|
||||
LibreMail is a free and open-source (GPL-3.0-or-later) email client. This policy describes what
|
||||
the app does with your data. Because the source code is public, every statement here can be
|
||||
verified against the code at <https://github.com/JMR-dev/LibreMail>.
|
||||
|
||||
## Summary
|
||||
|
||||
- **We run no servers and receive no data from you.** The LibreMail project has no backend: the
|
||||
app talks only to the email provider(s) *you* configure (e.g. your Gmail, Outlook, Yahoo,
|
||||
iCloud, or self-hosted IMAP/SMTP server) and, for Outlook accounts, to Microsoft's sign-in and
|
||||
Graph endpoints.
|
||||
- **Your mail stays on your device.** Messages are cached locally so the app works offline; the
|
||||
cache can optionally be encrypted at rest.
|
||||
- **No ads, no analytics, no tracking.** The app contains no advertising, analytics, or tracking
|
||||
SDK of any kind, and no Google Play Services or Firebase dependency.
|
||||
- **Nothing is sent to the developers** — including crash reports, which are strictly opt-in,
|
||||
stored locally, shown to you for review, and (in this build) cannot be uploaded at all because
|
||||
no ingest endpoint is configured.
|
||||
|
||||
## What the app stores on your device
|
||||
|
||||
All of the following lives in the app's private storage on your device only:
|
||||
|
||||
- **Account settings** — your email address, display name, and server host/port/security
|
||||
settings for each account you add.
|
||||
- **Credentials** — your per-account app password or OAuth tokens, encrypted with a hardware-
|
||||
backed key in the Android Keystore before being written to storage.
|
||||
- **Mail cache** — headers, message bodies, and folder state, in a local database so your mail is
|
||||
available offline. You can optionally enable **cache encryption** (SQLCipher) in Settings; the
|
||||
database key is random, never leaves the device, and is itself sealed by the Android Keystore.
|
||||
- **Attachments** you download or attach, in the app's cache directory (Android may clear this
|
||||
automatically to reclaim space).
|
||||
- **Preferences** — theme, notification, sync, and privacy toggles.
|
||||
- **Debug reports** — only if a crash occurs or you ask the app to capture one; see
|
||||
[Diagnostics](#diagnostics-and-debug-reports).
|
||||
|
||||
Uninstalling the app, or clearing its storage in Android settings, deletes all of the above.
|
||||
|
||||
## What leaves your device
|
||||
|
||||
The app makes network connections **only** to servers that operate your email service:
|
||||
|
||||
- **Your mail servers** — the IMAP and SMTP hosts of each account you configure (for the built-in
|
||||
presets: `imap/smtp.gmail.com`, `imap/smtp.mail.yahoo.com`, `imap/smtp.mail.me.com`;
|
||||
`outlook.office.com` for Outlook). This traffic is your email itself: signing in, downloading
|
||||
your mail, sending the messages you write, and — when you use server search — your search
|
||||
query. That is the app doing its job as your email client; none of it goes to us.
|
||||
- **Microsoft identity platform and Graph** (`login.microsoftonline.com`,
|
||||
`graph.microsoft.com`) — only for Outlook/Hotmail accounts, to sign you in with OAuth 2.0 and
|
||||
to send mail via Microsoft's API.
|
||||
- **Remote images in emails** — blocked by default. If you enable "load remote images", the
|
||||
message viewer will fetch images from the servers referenced by the email (which can reveal
|
||||
your IP address to the sender), so it stays off unless you turn it on.
|
||||
|
||||
Every mail connection uses TLS (SSL/TLS or STARTTLS) with server-certificate hostname
|
||||
verification; the account-setup UI does not offer an unencrypted option.
|
||||
|
||||
The app never transmits your data to the LibreMail project or any third party of ours. There is
|
||||
no telemetry, no "phone home", and no ad or analytics traffic.
|
||||
|
||||
## Contacts (`READ_CONTACTS` permission)
|
||||
|
||||
When composing a message, LibreMail can suggest recipients from your device contacts. The app
|
||||
asks for the contacts permission the first time you open the compose screen:
|
||||
|
||||
- Contact lookups run **entirely on the device** and return at most a handful of name/email
|
||||
matches for what you typed. Your contact list is never uploaded, copied, or synced anywhere.
|
||||
- The only way a contact detail leaves the device is when *you* put an address in an email you
|
||||
send — it then appears in that email, like in any mail client.
|
||||
- The permission is optional: if you deny it, autocomplete is silently disabled and everything
|
||||
else keeps working.
|
||||
|
||||
## Notifications (`POST_NOTIFICATIONS` permission)
|
||||
|
||||
Used to show new-mail notifications (per-account, with sender/subject hidden on a locked screen)
|
||||
and the persistent low-priority status notification Android requires while the optional
|
||||
instant-push connection is active. New-mail notifications are generated **on the device** from
|
||||
your synced mail — there is no push server and no cloud messaging service involved. You can
|
||||
decline the permission or disable notifications per account in system settings.
|
||||
|
||||
## Instant push (foreground service)
|
||||
|
||||
For instant mail delivery the app can hold an open IMAP IDLE connection to your mail server in a
|
||||
foreground service (shown as a persistent notification). This connects only to your own mail
|
||||
server, and can be turned off in Settings ("push mail"), which falls back to periodic background
|
||||
sync.
|
||||
|
||||
## Diagnostics and debug reports
|
||||
|
||||
LibreMail has **no automatic crash or usage reporting**. What exists instead:
|
||||
|
||||
- If the app crashes, or you use "Report a problem", a report is saved **locally** on your
|
||||
device. It contains the app version, Android version, device make/model, a stack trace (for
|
||||
crashes), a short summary of non-identifying settings, and recent internal log lines — by
|
||||
design no account addresses, server names, or message content fields are collected.
|
||||
- You can view the full report text (with a plain-language notice to check it for anything
|
||||
personal), copy it, share it yourself, or delete it. It is transmitted **only** if you
|
||||
explicitly tap Submit — never in the background.
|
||||
- In the builds produced from this repository **no upload endpoint is configured**, so even an
|
||||
explicit Submit cannot send anything; the report simply stays on your device. If a future
|
||||
release adds an endpoint, submission will remain strictly opt-in and user-initiated, and this
|
||||
policy will be updated.
|
||||
|
||||
## Android Backup
|
||||
|
||||
Android's cloud backup is **off by default** for LibreMail. If you enable "Include settings in
|
||||
Android Backup" in Settings, only your app preferences are backed up through your device's
|
||||
Android Backup transport (typically Google's). Your credentials, the mail cache, and the cache
|
||||
encryption key are always excluded from backups.
|
||||
|
||||
## Data deletion
|
||||
|
||||
- **Remove an account** (in the app's account settings) — deletes that account's stored
|
||||
credentials, its cached messages, folders, and per-account settings from the local database,
|
||||
and its notification channels. Copies of downloaded attachments in the app's cache directory
|
||||
are cleared by Android's normal cache management, or immediately via "Clear cache" in system
|
||||
settings.
|
||||
- **Uninstall the app / clear storage** — removes all locally stored app data.
|
||||
- **Your mailbox is unaffected**: mail lives with your email provider; deleting data in
|
||||
LibreMail does not delete mail from the server unless you explicitly delete messages in the
|
||||
app. We hold no copy of your data, so there is nothing for us to delete on any server.
|
||||
|
||||
## Children
|
||||
|
||||
LibreMail is a general-audience utility that requires an existing email account. It is not
|
||||
directed at children, and — as described above — it collects no data from any user.
|
||||
|
||||
## Changes and contact
|
||||
|
||||
Changes to this policy are made in the public repository with full version history. Questions or
|
||||
concerns: open an issue at <https://github.com/JMR-dev/LibreMail/issues>.
|
||||
@@ -121,8 +121,11 @@ token. A working client ID ships with the build; to use your own Azure app regis
|
||||
|
||||
LibreMail is offline-first: your mail lives in a local cache, and by default network traffic
|
||||
goes only to your mail providers (IMAP/SMTP, plus Microsoft's OAuth and Graph endpoints for
|
||||
Outlook). There is no analytics SDK and no always-on telemetry. The privacy-sensitive extras
|
||||
are all **opt-in**:
|
||||
Outlook). There is no analytics SDK and no always-on telemetry. The full privacy policy lives in
|
||||
[`PRIVACY.md`](PRIVACY.md); Google Play compliance notes (data-safety mapping, permissions
|
||||
justification) are under [`docs/`](docs/). Because Gmail uses an app password (no Google OAuth
|
||||
scopes), no Google restricted-scope verification or CASA assessment applies; Outlook's OAuth
|
||||
client is governed by Microsoft's Azure rules. The privacy-sensitive extras are all **opt-in**:
|
||||
|
||||
- **Cache encryption** — the Room cache can be encrypted at rest with **SQLCipher**. With the
|
||||
optional **app lock** (biometric or device credential) enabled, the cache key is bound to
|
||||
@@ -151,6 +154,18 @@ The UI observes Room via `Flow`; a sync engine (Angus Mail over IMAP/SMTP, plus
|
||||
Graph for Outlook send) writes into Room, and an auth layer (AppAuth for OAuth and an Android
|
||||
Keystore-backed credential store for app passwords) handles sign-in.
|
||||
|
||||
## F-Droid
|
||||
|
||||
LibreMail is built to meet F-Droid's inclusion criteria: every dependency is
|
||||
FOSS-licensed, there are no Google Play Services / Firebase / proprietary SDKs, the
|
||||
build needs no `secrets.properties`, and there are **no anti-features to declare**
|
||||
(the privacy-sensitive extras above are all opt-in). The full dependency license
|
||||
audit, anti-feature review, and clean-room build verification live in
|
||||
[`docs/fdroid-compliance.md`](docs/fdroid-compliance.md); the store listing is under
|
||||
[`fastlane/metadata/android/`](fastlane/metadata/android/en-US), and
|
||||
[`docs/fdroid/org.libremail.app.yml`](docs/fdroid/org.libremail.app.yml) is the
|
||||
template for the eventual fdroiddata build recipe.
|
||||
|
||||
## License
|
||||
|
||||
LibreMail is licensed under the **GNU General Public License v3.0** — see
|
||||
|
||||
@@ -96,6 +96,16 @@ android {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// F-Droid compliance (issue #16): by default AGP embeds a "dependency info block" in the APK
|
||||
// signing block — a list of every dependency, encrypted so that ONLY Google Play can read it.
|
||||
// F-Droid's inclusion policy treats that opaque, Google-only blob as a blocker (it cannot be
|
||||
// verified from source and breaks reproducible builds), so keep it out of APKs and bundles.
|
||||
// See docs/fdroid-compliance.md.
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// Angus Mail / Jakarta Activation (added later) ship duplicate META-INF entries.
|
||||
|
||||
@@ -54,8 +54,8 @@ class MailboxScreenTest {
|
||||
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private fun message(uid: String, subject: String, bodyFetched: Boolean = false) = Message(
|
||||
id = "imap:a:INBOX:$uid",
|
||||
private fun message(uid: String, subject: String, bodyFetched: Boolean = false, folder: String = "INBOX") = Message(
|
||||
id = "imap:a:$folder:$uid",
|
||||
accountId = "imap:a",
|
||||
sender = "Sender $uid",
|
||||
senderEmail = "s$uid@example.org",
|
||||
@@ -66,12 +66,12 @@ class MailboxScreenTest {
|
||||
timestampMillis = 1_000L,
|
||||
isRead = true,
|
||||
isStarred = false,
|
||||
folder = "INBOX",
|
||||
folder = folder,
|
||||
inInbox = true,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
private fun setContent(repo: FakeMailRepository) {
|
||||
private fun setContent(repo: FakeMailRepository): MailboxViewModel {
|
||||
val viewModel = MailboxViewModel(
|
||||
repo,
|
||||
FakeAccountRepository(accounts = listOf(account)),
|
||||
@@ -92,6 +92,7 @@ class MailboxScreenTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
return viewModel
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
@@ -132,11 +133,58 @@ class MailboxScreenTest {
|
||||
composeTestRule.onNodeWithText("Second").performClick()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.action_archive)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_select_all)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveIcon_isDirect_andArchivesTheSelection() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second")))
|
||||
setContent(repo)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithText("Second").performClick()
|
||||
// A direct icon button — no trip through the overflow menu.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.archivedIds.isNotEmpty() }
|
||||
assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), repo.archivedIds.first().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spamIcon_isDirect_andConfirmsBeforeReporting() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
|
||||
setContent(repo)
|
||||
waitForText("First")
|
||||
|
||||
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.spammedIds.isNotEmpty() }
|
||||
assertEquals(listOf("imap:a:INBOX:1"), repo.spammedIds.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveIcon_hides_whileViewingTheArchiveFolder() {
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message("1", "Old news", folder = "Archive")),
|
||||
folders = listOf(Folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE, selectable = true)),
|
||||
)
|
||||
val viewModel = setContent(repo)
|
||||
viewModel.selectFolder("imap:a", "Archive")
|
||||
waitForText("Old news")
|
||||
|
||||
composeTestRule.onNodeWithText("Old news").performTouchInput { longClick() }
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delete_confirmsMoveToTrash_thenTrashesViaRepository() {
|
||||
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
|
||||
|
||||
@@ -256,9 +256,16 @@ class MailRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the full name of an account's folder for [role], refreshing the cache once if needed. */
|
||||
/**
|
||||
* Resolves the full name of an account's folder for [role], refreshing the cache once if needed.
|
||||
* Among same-role selectable folders (e.g. `[Gmail]/Spam` via RFC 6154 `\Junk` plus a user label
|
||||
* "Spam" matched by name), the server-advertised special-use folder wins regardless of LIST order,
|
||||
* so mail reaches the provider's built-in mailbox; absent one, the earliest LISTed folder is kept
|
||||
* (`maxByOrNull` returns the first max).
|
||||
*/
|
||||
private suspend fun resolveRoleFolder(accountId: String, role: FolderRole): String? {
|
||||
fun pick(folders: List<FolderEntity>) = folders.firstOrNull { it.role == role.name && it.selectable }?.fullName
|
||||
fun pick(folders: List<FolderEntity>) =
|
||||
folders.filter { it.role == role.name && it.selectable }.maxByOrNull { it.specialUse }?.fullName
|
||||
pick(folderDao.getForAccountOnce(accountId))?.let { return it }
|
||||
// The folder cache can be cold (the user may not have opened the drawer yet); refresh and retry.
|
||||
runCatching { refreshFolders(accountId) }
|
||||
|
||||
@@ -31,11 +31,13 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -530,9 +532,12 @@ private fun SelectedAvatar() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The contextual action bar shown while messages are selected. Archive/Delete are the common actions;
|
||||
* the overflow holds the rest. Reply/Reply All/Forward appear only for a single selected message, and
|
||||
* Archive/Spam are hidden while already viewing that role's folder.
|
||||
* The contextual action bar shown while messages are selected. The common actions — Archive, Spam,
|
||||
* Delete — are direct icon buttons (matching the reader's icons-not-menus app bar); Archive/Spam are
|
||||
* hidden while already viewing that role's folder. The overflow keeps only the long tail: Move (no
|
||||
* usable glyph in material-icons-core) and Select all, plus Reply/Reply All/Forward for a single
|
||||
* selected message. At most four 48dp actions plus the close button fit a 320dp-wide bar; the
|
||||
* count title just truncates earlier on such screens.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -558,6 +563,18 @@ private fun SelectionTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (folderRole != FolderRole.ARCHIVE) {
|
||||
IconButton(onClick = onArchive) {
|
||||
// material-icons-core ships no archive glyph; the checkmark leans on the
|
||||
// "done with it = archive it" mail idiom (Google Inbox's sweep).
|
||||
Icon(Icons.Filled.Done, contentDescription = stringResource(R.string.action_archive))
|
||||
}
|
||||
}
|
||||
if (folderRole != FolderRole.SPAM) {
|
||||
IconButton(onClick = onSpam) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = stringResource(R.string.action_spam))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
@@ -566,24 +583,6 @@ private fun SelectionTopBar(
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more))
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (folderRole != FolderRole.ARCHIVE) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_archive)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onArchive()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (folderRole != FolderRole.SPAM) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_spam)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSpam()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canMove) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.action_move)) },
|
||||
|
||||
@@ -399,8 +399,73 @@ class MailRepositoryImplTest {
|
||||
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("14"), "Archive") }
|
||||
}
|
||||
|
||||
private fun folderEntity(fullName: String, role: String) =
|
||||
FolderEntity("acct", fullName, fullName.substringAfterLast('/'), role, selectable = true, sortOrder = 0)
|
||||
@Test
|
||||
fun `reportSpam prefers the special-use spam folder when the user folder is listed first`() = runTest {
|
||||
val id = "acct:INBOX:16"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
// A user label "Spam" (role from its name) is LISTed before Gmail's built-in \Junk folder.
|
||||
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
|
||||
folderEntity("Spam", "SPAM"),
|
||||
folderEntity("[Gmail]/Spam", "SPAM", specialUse = true),
|
||||
)
|
||||
|
||||
val result = repository.reportSpam(listOf(id))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("16"), "[Gmail]/Spam") }
|
||||
coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), "Spam") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reportSpam prefers the special-use spam folder when it is listed first`() = runTest {
|
||||
val id = "acct:INBOX:18"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
|
||||
folderEntity("[Gmail]/Spam", "SPAM", specialUse = true),
|
||||
folderEntity("Spam", "SPAM"),
|
||||
)
|
||||
|
||||
val result = repository.reportSpam(listOf(id))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("18"), "[Gmail]/Spam") }
|
||||
coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), "Spam") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reportSpam keeps the first listed folder when no special-use folder holds the role`() = runTest {
|
||||
val id = "acct:INBOX:20"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
// No SPECIAL-USE advertised (common outside the big providers): LIST order still decides.
|
||||
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
|
||||
folderEntity("Junk", "SPAM"),
|
||||
folderEntity("Spam", "SPAM"),
|
||||
)
|
||||
|
||||
val result = repository.reportSpam(listOf(id))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("20"), "Junk") }
|
||||
}
|
||||
|
||||
private fun folderEntity(fullName: String, role: String, specialUse: Boolean = false) = FolderEntity(
|
||||
accountId = "acct",
|
||||
fullName = fullName,
|
||||
displayName = fullName.substringAfterLast('/'),
|
||||
role = role,
|
||||
selectable = true,
|
||||
sortOrder = 0,
|
||||
specialUse = specialUse,
|
||||
)
|
||||
|
||||
private fun attachmentEntity(messageId: String, partIndex: Int, filename: String) =
|
||||
AttachmentEntity(messageId, partIndex, filename, "application/octet-stream", 10L)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# F-Droid compliance
|
||||
|
||||
Audit for issue #16, performed 2026-07-01 against `main` (versionName 0.1.0 /
|
||||
versionCode 1). **Verdict: LibreMail meets F-Droid's inclusion criteria with no
|
||||
anti-features to declare.** Every runtime dependency is FOSS-licensed and
|
||||
GPL-3.0-or-later-compatible, there are no Google Play Services / Firebase /
|
||||
proprietary artifacts, no non-free Gradle plugins, and a clean-room build (no
|
||||
`secrets.properties`, no proprietary keys) produces an installable APK.
|
||||
|
||||
Companion deliverables:
|
||||
|
||||
- `fastlane/metadata/android/en-US/` — the store listing F-Droid reads from this repo.
|
||||
- `docs/fdroid/org.libremail.app.yml` — template + instructions for the build recipe
|
||||
that goes into [fdroiddata](https://gitlab.com/fdroid/fdroiddata).
|
||||
|
||||
## 1. Dependency license audit
|
||||
|
||||
### 1.1 Runtime dependencies (what ships in the APK)
|
||||
|
||||
Enumerated with `./gradlew :app:dependencies --configuration releaseRuntimeClasspath`.
|
||||
Direct dependencies, with the resolved versions at audit time:
|
||||
|
||||
| Dependency | Version | License | Role |
|
||||
|---|---|---|---|
|
||||
| `androidx.core:core-ktx` | 1.17.0 | Apache-2.0 | AndroidX core |
|
||||
| `androidx.lifecycle:lifecycle-runtime-ktx` / `-runtime-compose` / `-viewmodel-compose` | 2.9.4 | Apache-2.0 | Lifecycle/MVVM |
|
||||
| `androidx.activity:activity-compose` | 1.12.4 | Apache-2.0 | Compose host activity |
|
||||
| `androidx.navigation:navigation-compose` | 2.9.8 | Apache-2.0 | Navigation |
|
||||
| `androidx.compose.*` (BOM 2026.06.00: ui, ui-graphics, material3, material-icons-core, …) | 1.11.3 / m3 1.4.0 | Apache-2.0 | UI toolkit |
|
||||
| `androidx.webkit:webkit` | 1.12.1 | Apache-2.0 | Hardened WebView compat |
|
||||
| `androidx.work:work-runtime-ktx` | 2.11.2 | Apache-2.0 | Background sync/outbox |
|
||||
| `androidx.datastore:datastore-preferences` | 1.2.1 | Apache-2.0 | Settings store |
|
||||
| `androidx.room:room-runtime` / `room-ktx` | 2.8.4 | Apache-2.0 | Local mail cache |
|
||||
| `androidx.hilt:hilt-navigation-compose` / `hilt-work` | 1.3.0 | Apache-2.0 | Hilt integrations |
|
||||
| `org.jetbrains.kotlin:kotlin-stdlib` | 2.4.0 | Apache-2.0 | Kotlin runtime |
|
||||
| `org.jetbrains.kotlinx:kotlinx-coroutines-android` | 1.10.2 | Apache-2.0 | Coroutines |
|
||||
| `com.google.dagger:hilt-android` (Dagger/Hilt) | 2.60 | Apache-2.0 | Dependency injection |
|
||||
| `org.eclipse.angus:angus-mail` (+ `angus-activation`) | 2.0.5 / 2.0.3 | EPL-2.0 OR GPL-2.0 w/ Classpath-exception OR EDL-1.0 (BSD-3-Clause) | IMAP/SMTP transport |
|
||||
| `net.openid:appauth` | 0.11.1 | Apache-2.0 | OAuth 2.0 + PKCE (Outlook) |
|
||||
| `net.zetetic:sqlcipher-android` | 4.16.0 | BSD-3-Clause-style (SQLCipher Community Edition) | Opt-in cache encryption |
|
||||
|
||||
Transitive dependencies, grouped (full tree available from the Gradle command above):
|
||||
|
||||
| Group | License | Notes |
|
||||
|---|---|---|
|
||||
| `androidx.*` (~60 artifacts: appcompat, browser, collection, emoji2, fragment, savedstate, sqlite, startup, tracing, window, …) | Apache-2.0 | AndroidX |
|
||||
| `org.jetbrains.*` (kotlin-stdlib, kotlinx-coroutines, kotlinx-serialization, annotations) | Apache-2.0 | JetBrains |
|
||||
| `com.google.dagger:*` (dagger, hilt-core, dagger-lint-aar) | Apache-2.0 | via Hilt |
|
||||
| `com.google.code.findbugs:jsr305` 3.0.2 | Apache-2.0 | annotations only |
|
||||
| `com.google.guava:listenablefuture` 1.0 | Apache-2.0 | empty stub artifact (not Guava) |
|
||||
| `com.squareup.okio:okio` 3.9.1 | Apache-2.0 | via DataStore |
|
||||
| `jakarta.mail:jakarta.mail-api` 2.1.5 | EPL-2.0 OR GPL-2.0 w/ CPE OR EDL-1.0 | via Angus Mail |
|
||||
| `jakarta.activation:jakarta.activation-api` 2.1.4 | EDL-1.0 (BSD-3-Clause) | via Angus Mail |
|
||||
| `jakarta.inject:jakarta.inject-api` 2.0.1, `javax.inject:javax.inject` 1 | Apache-2.0 | DI annotations |
|
||||
| `org.jspecify:jspecify` 1.0.0 | Apache-2.0 | nullness annotations |
|
||||
|
||||
**GPL compatibility.** Everything is Apache-2.0 or BSD-3-Clause except the
|
||||
Jakarta/Angus mail stack, which is tri-licensed; LibreMail uses it under the
|
||||
EDL-1.0 (BSD-3-Clause) / GPL-2.0-with-Classpath-exception options, both of which are
|
||||
GPL-3.0-or-later-compatible. **No proprietary, source-unavailable, or
|
||||
"free for open source use only" artifact appears anywhere in the tree.** In
|
||||
particular there is **no** `com.google.android.gms:*` (Play Services), **no**
|
||||
`com.google.firebase:*`, no Play Billing/Install Referrer, and no analytics or
|
||||
crash-reporting SDK.
|
||||
|
||||
Native libraries in the release APK — all from the audited dependencies above:
|
||||
`libsqlcipher.so` (SQLCipher), `libdatastore_shared_counter.so` (AndroidX DataStore),
|
||||
`libandroidx.graphics.path.so` (AndroidX, via Compose).
|
||||
|
||||
### 1.2 Build-time dependencies (never ship in the APK)
|
||||
|
||||
Enumerated with `./gradlew buildEnvironment :app:buildEnvironment`:
|
||||
|
||||
| Plugin / tool | License |
|
||||
|---|---|
|
||||
| Android Gradle Plugin 9.2.x (`com.android.tools.*`) | Apache-2.0 |
|
||||
| Kotlin Gradle plugin + Compose compiler 2.4.0 | Apache-2.0 |
|
||||
| KSP 2.3.9 | Apache-2.0 |
|
||||
| Hilt Gradle plugin 2.60 | Apache-2.0 |
|
||||
| ktlint-gradle 14.2.0 (`org.jlleitschuh.gradle`) | MIT |
|
||||
| detekt 2.0.0-alpha.5 (`dev.detekt`) | Apache-2.0 |
|
||||
|
||||
Their transitive tooling deps (protobuf, Tink, flatbuffers, bouncycastle,
|
||||
juniversalchardet, jose4j, …) are Apache-2.0/MIT/MPL — all FOSS. **No non-free
|
||||
Gradle plugin is used** (no Play Publisher, no Crashlytics/Google Services plugin,
|
||||
no proprietary obfuscator; R8 ships with AGP and is Apache-2.0).
|
||||
|
||||
Artifacts resolve exclusively from open repositories: `google()` and
|
||||
`mavenCentral()` (plus `gradlePluginPortal()` for the lint/format plugins).
|
||||
`gradle-wrapper.jar` is the standard Gradle 9.6 wrapper (Apache-2.0), verifiable
|
||||
against the official distribution.
|
||||
|
||||
### 1.3 APK payload hygiene
|
||||
|
||||
AGP by default embeds a *dependency info block* in the APK signing block: a list of
|
||||
every dependency **encrypted with a Google Play public key**, readable only by
|
||||
Google. That opaque blob is a known F-Droid blocker (it cannot be verified from
|
||||
source and breaks reproducible-build verification), so this repo disables it in
|
||||
`app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Anti-feature review
|
||||
|
||||
Reviewed against the [F-Droid anti-feature list](https://f-droid.org/docs/Anti-Features/),
|
||||
based on the manifest and source at audit time. **Declared anti-features: none.**
|
||||
|
||||
| Anti-feature | Verdict | Reasoning |
|
||||
|---|---|---|
|
||||
| `Ads` | Clear | No advertising of any kind. |
|
||||
| `Tracking` | Clear | No analytics/telemetry SDK; no identifiers are collected. Debug reporting is off by default, local-only, user-reviewed, and user-submitted (§2.1). |
|
||||
| `NonFreeNet` | Clear | Generic IMAP/SMTP client, fully functional against free-software mail servers; the Microsoft integration is optional and user-chosen (§2.2). |
|
||||
| `NonFreeAdd` | Clear | No add-ons; nothing is upsold. |
|
||||
| `NonFreeDep` | Clear | Dependency audit in §1: every dependency is FOSS. |
|
||||
| `NonFreeAssets` | Clear | All assets are first-party vector drawables carrying the repo's GPL SPDX headers; no bundled proprietary art, fonts, or blobs. |
|
||||
| `NSFW` | Clear | N/A. |
|
||||
| `UpstreamNonFree` | Clear | This repo is the upstream and is wholly GPL-3.0-or-later. |
|
||||
| `KnownVuln` | Clear | No dependency with a known security vulnerability is pinned at audit time (all on current stable lines). |
|
||||
| `ApplicationDebuggable` | Clear | Release builds are non-debuggable (AGP default) and R8-minified. |
|
||||
| `TetheredNet` | Clear | No tethered/proprietary backend; the app talks to the user's own mail servers. |
|
||||
| `NoSourceSince` | Clear | N/A — source is published. |
|
||||
|
||||
### 2.1 Debug reporting is not `Tracking`
|
||||
|
||||
The crash/debug-report pipeline (`org.libremail.reporting.*`) is designed to stay on
|
||||
the right side of F-Droid's Tracking definition ("reports user activity ... without
|
||||
consent"):
|
||||
|
||||
- **Off by default.** Nothing is captured until the user enables debug reporting.
|
||||
- **Local capture only.** Reports (app/OS version, device model, stack trace, a
|
||||
non-PII settings summary, recent in-app log lines) are stored on-device.
|
||||
`DiagnosticsCollector` deliberately collects no account emails, server names,
|
||||
message content, or hardware/advertising identifiers.
|
||||
- **User-initiated, reviewed submission.** A report leaves the device only when the
|
||||
user opens it, sees the full contents plus a PII disclaimer, and taps Submit
|
||||
(`ReportSubmitter` is the single egress seam).
|
||||
- **No endpoint in F-Droid builds.** The ingest URL comes from
|
||||
`BuildConfig.DEBUG_REPORT_ENDPOINT`, default **empty** (settable only via the
|
||||
git-ignored `secrets.properties`). An F-Droid build therefore *cannot* transmit a
|
||||
report anywhere; the UI steers users to copy/save the report instead.
|
||||
|
||||
### 2.2 Outlook OAuth / Microsoft Graph is not `NonFreeNet`
|
||||
|
||||
`NonFreeNet` applies to apps that *promote or depend entirely on* a non-free network
|
||||
service. LibreMail is a general-purpose email client: it works fully against any
|
||||
IMAP/SMTP server, including self-hosted free-software stacks (Dovecot/Postfix, …),
|
||||
and no Microsoft endpoint is ever contacted unless the user adds an
|
||||
Outlook/Microsoft account. For transparency:
|
||||
|
||||
- Adding an Outlook account uses OAuth 2.0 + PKCE against
|
||||
`login.microsoftonline.com` and sends via Microsoft Graph
|
||||
(`graph.microsoft.com`), with SMTP/XOAUTH2 fallback — proprietary services, but
|
||||
the *user's own mailbox provider*, exactly like connecting to any other mail host.
|
||||
- The build bundles a default Azure **public client id** (`OUTLOOK_OAUTH_CLIENT_ID`
|
||||
in `app/build.gradle.kts`). A public-client id is an identifier, not a secret or a
|
||||
key, and is overridable via `secrets.properties`. This mirrors what established
|
||||
F-Droid mail clients (K-9 Mail / Thunderbird) ship for Gmail/Outlook OAuth without
|
||||
a `NonFreeNet` flag.
|
||||
- Gmail/Yahoo/iCloud accounts use plain app-password IMAP/SMTP — no proprietary
|
||||
SDK. Onboarding links to each vendor's app-password page open in the system
|
||||
browser only on an explicit tap.
|
||||
|
||||
Should F-Droid reviewers read the built-in Outlook convenience differently,
|
||||
declaring `NonFreeNet` on the fdroiddata side is the documented fallback; nothing
|
||||
in the app needs to change.
|
||||
|
||||
### 2.3 Android Backup (Google transport) is opt-in
|
||||
|
||||
`android:allowBackup="true"` is required at the manifest level, but
|
||||
`LibreMailBackupAgent` enforces the runtime preference: **backup is off by
|
||||
default**, and with it disabled the agent ships nothing. When the user opts in, the
|
||||
allowlist in `res/xml/data_extraction_rules.xml` / `backup_rules.xml` backs up
|
||||
*only* the settings DataStore — never credentials, the mail cache, or the
|
||||
Keystore-sealed cache passphrase. Because Android Auto Backup can route through
|
||||
Google's transport, the feature stays disabled unless explicitly chosen; F-Droid
|
||||
has no anti-feature for opt-in platform backup.
|
||||
|
||||
### 2.4 Complete network surface
|
||||
|
||||
| Destination | When | Consent |
|
||||
|---|---|---|
|
||||
| User-configured IMAP/SMTP servers | Mail sync/send | Inherent (user adds the account) |
|
||||
| `login.microsoftonline.com` | Outlook sign-in / token refresh | Only if an Outlook account is added |
|
||||
| `graph.microsoft.com` (`sendMail`) | Outlook send | Only if an Outlook account is added |
|
||||
| Vendor app-password help pages (Google/Yahoo/Apple) | Opened in the system browser | Explicit tap during setup |
|
||||
| Remote images in HTML mail | Blocked by default | Per-user opt-in (tracking-pixel protection) |
|
||||
| `DEBUG_REPORT_ENDPOINT` | Debug-report submission | Empty by default → impossible; otherwise explicit Submit tap |
|
||||
|
||||
No other endpoint exists in the code; there is no update checker, no push relay
|
||||
(new-mail notifications are generated on-device; instant push is a direct IMAP IDLE
|
||||
connection to the user's server), and no font/asset CDN.
|
||||
|
||||
## 3. Clean-room build verification
|
||||
|
||||
Verified 2026-07-01 on this branch, in a checkout containing **no
|
||||
`secrets.properties`** (and no other proprietary keys — the file is optional by
|
||||
design; `OUTLOOK_OAUTH_CLIENT_ID` has an in-tree default and
|
||||
`DEBUG_REPORT_ENDPOINT` defaults to empty):
|
||||
|
||||
```
|
||||
$ JAVA_HOME=<JDK 21> ./gradlew :app:assembleRelease
|
||||
BUILD SUCCESSFUL
|
||||
app/build/outputs/apk/release/app-release.apk (~12.6 MiB)
|
||||
```
|
||||
|
||||
The APK is installable: with no release keystore configured, release builds are
|
||||
signed with the debug key (see `signingConfigs` in `app/build.gradle.kts`) — fine
|
||||
for local testing and irrelevant to F-Droid, which builds from source and signs
|
||||
with its own key. APK contents were inspected: single `classes.dex`, resources,
|
||||
and the three native libraries listed in §1.1 — no bundled binaries of unknown
|
||||
origin. `./gradlew :app:assembleDebug`, the unit tests, static analysis
|
||||
(ktlint/detekt), and the emulator E2E suites run on every PR in CI, likewise
|
||||
without any secrets configured.
|
||||
|
||||
## 4. Publishing checklist (for the maintainer)
|
||||
|
||||
1. Tag releases `v<versionName>` **and bump `versionCode`** in
|
||||
`app/build.gradle.kts` in the same commit. (At audit time tags `v0.1.0` and
|
||||
`v0.2.0` both point at versionCode 1 / versionName 0.1.0 — F-Droid's
|
||||
`UpdateCheckMode: Tags` needs the code to increase per release tag.)
|
||||
2. Copy `docs/fdroid/org.libremail.app.yml` into a fork of fdroiddata as
|
||||
`metadata/org.libremail.app.yml`, run `fdroid lint org.libremail.app` and a test
|
||||
`fdroid build`, then open the merge request.
|
||||
3. The store listing (title/short/full description, per-release changelogs) is read
|
||||
from `fastlane/metadata/android/en-US/` in this repo — add a
|
||||
`changelogs/<versionCode>.txt` for each release, and optionally
|
||||
`images/phoneScreenshots/`.
|
||||
4. Keep this document current when dependencies or network behaviors change; if a
|
||||
future feature genuinely trips an anti-feature, declare it in the fdroiddata
|
||||
metadata rather than hiding it.
|
||||
@@ -0,0 +1,49 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# TEMPLATE for LibreMail's F-Droid build recipe ("app metadata").
|
||||
#
|
||||
# The real recipe does NOT live in this repository: F-Droid builds every app from a
|
||||
# metadata file kept in the fdroiddata repo (https://gitlab.com/fdroid/fdroiddata) at
|
||||
# metadata/org.libremail.app.yml. To submit LibreMail, fork fdroiddata, copy this file
|
||||
# there (dropping this comment block — `fdroid rewritemeta` strips comments anyway),
|
||||
# validate it, and open a merge request:
|
||||
#
|
||||
# fdroid readmeta # parse check
|
||||
# fdroid lint org.libremail.app # style/policy check
|
||||
# fdroid build -v -l org.libremail.app # test build (needs the Android SDK)
|
||||
#
|
||||
# Notes for the submitter:
|
||||
# - Summary/Description/changelogs are deliberately NOT set here: F-Droid pulls the
|
||||
# localized store listing from this repo's fastlane/metadata/android/ tree.
|
||||
# - Each release must be git-tagged (v<versionName>) AND bump versionCode in
|
||||
# app/build.gradle.kts; UpdateCheckMode: Tags matches tags against versionCode.
|
||||
# - The build needs no secrets.properties: the Outlook OAuth client id (a public,
|
||||
# non-secret GUID) has an in-tree default, and the debug-report endpoint defaults
|
||||
# to empty (reports then cannot be submitted anywhere). See docs/fdroid-compliance.md.
|
||||
# - JDK: 17-21 (AGP 9.x does not support JDK 25).
|
||||
|
||||
Categories:
|
||||
- Internet
|
||||
License: GPL-3.0-or-later
|
||||
AuthorName: Jason Ross
|
||||
SourceCode: https://github.com/JMR-dev/LibreMail
|
||||
IssueTracker: https://github.com/JMR-dev/LibreMail/issues
|
||||
Changelog: https://github.com/JMR-dev/LibreMail/releases
|
||||
|
||||
AutoName: LibreMail
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/JMR-dev/LibreMail.git
|
||||
|
||||
Builds:
|
||||
- versionName: 0.1.0
|
||||
versionCode: 1
|
||||
commit: v0.1.0
|
||||
subdir: app
|
||||
gradle:
|
||||
- yes
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags ^v[0-9]+\.[0-9]+\.[0-9]+$
|
||||
CurrentVersion: 0.1.0
|
||||
CurrentVersionCode: 1
|
||||
@@ -0,0 +1,152 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# Google Play technical compliance & console checklist (issue #17)
|
||||
|
||||
Verified 2026-07-01 against this repository (commit on `main` at time of writing). Companion
|
||||
docs: [`PRIVACY.md`](../PRIVACY.md), [`play-data-safety.md`](play-data-safety.md),
|
||||
[`play-permissions.md`](play-permissions.md).
|
||||
|
||||
## 1. Target API level — PASS
|
||||
|
||||
| Fact | Value | Source |
|
||||
|---|---|---|
|
||||
| `targetSdk` | **37** | `app/build.gradle.kts:52` |
|
||||
| `compileSdk` | 37 | `app/build.gradle.kts:46` |
|
||||
| `minSdk` | 29 (Android 10) | `app/build.gradle.kts:51` |
|
||||
| Play requirement (new apps & updates, phones/tablets) | target API **35** (Android 15)+ since 2025-08-31 | [Play target-API policy](https://support.google.com/googleplay/android-developer/answer/11926878) |
|
||||
|
||||
Target 37 exceeds the requirement with two versions of headroom; no action needed. When Google
|
||||
announces the 2026 deadline (expected: API 36 for the Aug 2026 window), 37 still passes.
|
||||
|
||||
## 2. 16 KB page-size support — PASS (verified empirically)
|
||||
|
||||
Play requires new apps and updates targeting Android 15+ to support 16 KB memory page sizes on
|
||||
64-bit devices since 2025-11-01 ([Android developers blog](https://android-developers.googleblog.com/2025/05/prepare-play-apps-for-devices-with-16kb-page-size.html)).
|
||||
Compliance = every `PT_LOAD` segment of every packaged 64-bit `.so` aligned to ≥ 0x4000 (16384).
|
||||
|
||||
The release AAB packages exactly three native libraries. All were extracted from
|
||||
`app-release.aab` and their ELF program headers checked (same check as AOSP's
|
||||
`check_elf_alignment.sh`); **every one reports `p_align = 0x4000` on every ABI**:
|
||||
|
||||
| Library | From dependency | arm64-v8a | x86_64 | armeabi-v7a / x86 (32-bit, not gated) |
|
||||
|---|---|---|---|---|
|
||||
| `libsqlcipher.so` | `net.zetetic:sqlcipher-android:4.16.0` | 0x4000 OK | 0x4000 OK | 0x4000 OK |
|
||||
| `libandroidx.graphics.path.so` | Compose (BOM `2026.06.00`) | 0x4000 OK | 0x4000 OK | 0x4000 OK |
|
||||
| `libdatastore_shared_counter.so` | `androidx.datastore:1.2.1` | 0x4000 OK | 0x4000 OK | 0x4000 OK |
|
||||
|
||||
SQLCipher — the dependency called out in issue #17 — has shipped 16 KB-aligned binaries since
|
||||
well before 4.16.0, and the pinned version is confirmed aligned above. AGP 9.2 also emits 16
|
||||
KB-zip-aligned uncompressed libraries by default (AGP ≥ 8.5.1 behavior), and Play regenerates
|
||||
delivery APKs from the AAB anyway. Re-verify after any bump of `sqlcipher`, `datastore`, or
|
||||
`composeBom` in `gradle/libs.versions.toml`: Play Console → **App bundle explorer** shows a
|
||||
16 KB compliance verdict per upload.
|
||||
|
||||
## 3. App Bundle (AAB) — PASS, signing is a human step
|
||||
|
||||
- `./gradlew :app:bundleRelease` succeeds and produces
|
||||
`app/build/outputs/bundle/release/app-release.aab` (~10.4 MB, R8-minified). Verified
|
||||
2026-07-01 with JDK 21.
|
||||
- **Signing:** without `secrets.properties` the release build intentionally falls back to the
|
||||
**debug** keystore (`app/build.gradle.kts:86` — installable locally, not publishable). For
|
||||
Play the maintainer must create an upload keystore, set `RELEASE_STORE_FILE` /
|
||||
`RELEASE_STORE_PASSWORD` / `RELEASE_KEY_ALIAS` / `RELEASE_KEY_PASSWORD` in
|
||||
`secrets.properties`, rebuild, and enroll in **Play App Signing** on first upload (Play holds
|
||||
the app signing key; the local key becomes the upload key).
|
||||
- `versionCode 1` / `versionName "0.1.0"` (`app/build.gradle.kts:53`) — bump per release.
|
||||
|
||||
## 4. OAuth / CASA — no Google verification applies
|
||||
|
||||
Verified in source, 2026-07-01:
|
||||
|
||||
- **Gmail onboarding uses an app password over IMAP/SMTP, not OAuth.** The Gmail preset
|
||||
(`domain/model/MailProvider.kt:37`) is plain `imap.gmail.com:993` / `smtp.gmail.com:587`
|
||||
authenticating with a user-created app password; the only Google URL in the app is the
|
||||
`myaccount.google.com/apppasswords` help link opened in the browser. There is **no Google
|
||||
OAuth client, no Google sign-in flow, and no Gmail API scope anywhere in the code** — so the
|
||||
Google restricted-scope verification and **CASA security assessment do not apply** to
|
||||
LibreMail. (This is deliberate — issue #9; do not "fix" Gmail back to OAuth.)
|
||||
- **Outlook OAuth is Microsoft-side only.** `auth/OutlookAuthManager.kt` uses AppAuth (PKCE,
|
||||
public client) against `login.microsoftonline.com` with Microsoft Graph
|
||||
(`Mail.Send`) and Exchange Online (`IMAP.AccessAsUser.All`, `SMTP.Send`) scopes. Verification
|
||||
of that client is governed by **Microsoft's** app-registration/publisher rules in Azure —
|
||||
nothing on the Google side. Google Play itself imposes no OAuth review; only the data-safety
|
||||
and permissions declarations above cover it.
|
||||
- README follow-up: tracked as issue **#20** (a fuller README pass); `README.md`'s privacy
|
||||
section now links `PRIVACY.md`.
|
||||
|
||||
## 5. Console checklist (human steps, in order)
|
||||
|
||||
Everything below happens in Play Console and cannot be done from the repo. Drafted answers are
|
||||
ready to paste.
|
||||
|
||||
1. **Developer account** — one-time registration + identity verification.
|
||||
2. **Create app** — name *LibreMail*, default language, **App** (not game), **Free**.
|
||||
Free-to-paid can never be toggled later; LibreMail is GPL and free.
|
||||
3. **Store listing** (assets required):
|
||||
- App icon **512×512 PNG** (≤1 MB); feature graphic **1024×500**; **2–8 phone screenshots**
|
||||
(16:9 or 9:16, 320–3840 px; onboarding, inbox, reader, compose, settings are good
|
||||
candidates); optional 7"/10" tablet screenshots.
|
||||
- Short description (≤80 chars), draft:
|
||||
> Open-source email for Outlook, Gmail, Yahoo, iCloud and any IMAP provider.
|
||||
- Full description (≤4000 chars), draft:
|
||||
> LibreMail is a free and open-source (GPL-3.0) email client with a friendly Material You
|
||||
> design. Add Outlook/Hotmail (OAuth sign-in), Gmail, Yahoo, iCloud (app password), or any
|
||||
> IMAP/SMTP provider; read, search, and manage your mail offline-first; compose with rich
|
||||
> text, signatures, attachments, and contact autocomplete; get instant new-mail
|
||||
> notifications via IMAP IDLE push — no tracking, no ads, no analytics, and your mail
|
||||
> never touches our servers because we don't have any. Optional extras: encrypted local
|
||||
> cache (SQLCipher), unified inbox for multiple accounts, and full mail-history backfill
|
||||
> with a retention cap.
|
||||
- Category **Communication**; contact email (maintainer's); privacy policy URL
|
||||
`https://github.com/JMR-dev/LibreMail/blob/main/PRIVACY.md`.
|
||||
4. **App content declarations:**
|
||||
- **Privacy policy** — URL above.
|
||||
- **Ads** — *No, my app does not contain ads* (no ad SDK; see dependency audit in
|
||||
[`play-data-safety.md`](play-data-safety.md)).
|
||||
- **App access** — reviewers need a mail account to exercise the app. Provide either
|
||||
"All functionality is available without special access" plus a note that any IMAP account
|
||||
works, or (safer) supply a disposable test account (e.g. a throwaway IMAP mailbox) under
|
||||
*Special access instructions*. Do **not** hand over a personal account.
|
||||
- **Content rating (IARC questionnaire)** — draft answers: email/communication app; category
|
||||
**Utility / Communication**; violence/sex/language/drugs/gambling: **No** to all;
|
||||
user interaction: **Yes** (users exchange email — expect an "Interactive elements: Users
|
||||
Interact" notice); shares user-provided location: **No**; digital purchases: **No**.
|
||||
Expected rating: **Everyone / PEGI 3** with the Users-Interact disclosure.
|
||||
- **Target audience** — **13 and over** (requires an email account; not directed at
|
||||
children — do not select under-13, which triggers Families policy).
|
||||
- **News app** — No. **COVID-19 app** — No. **Government app** — No.
|
||||
- **Financial features** — None. **Health apps** — Not a health app.
|
||||
- **Data safety** — answers and evidence in [`play-data-safety.md`](play-data-safety.md).
|
||||
- **Foreground service permissions** (`FOREGROUND_SERVICE_DATA_SYNC`) — declaration text and
|
||||
demo-video script in [`play-permissions.md`](play-permissions.md).
|
||||
- **Account deletion** — Play's deletion-URL requirement applies to apps that let users
|
||||
*create an account with the developer*. LibreMail creates no such accounts (users connect
|
||||
their own third-party mailboxes), so answer the "App access/account creation" question
|
||||
with **no account creation** and the deletion section does not apply. In-app truth, if a
|
||||
free-text answer is wanted:
|
||||
> LibreMail has no user accounts of its own and stores data only on the device. Removing
|
||||
> an account inside the app deletes its saved credentials and its locally cached
|
||||
> messages, folders, and settings (`AccountRepositoryImpl.deleteAccount`); uninstalling
|
||||
> the app removes all app data. The user's mailbox at their email provider is unaffected.
|
||||
5. **Upload** the properly signed release AAB to **Internal testing** first; check **App bundle
|
||||
explorer** (16 KB verdict) and the **pre-launch report** (automated crawl on real devices;
|
||||
supply the test-account credentials so it can get past onboarding).
|
||||
6. **Countries/regions**, pricing (Free), then promote Internal → Closed/Open testing →
|
||||
Production. Note: new personal developer accounts must run a closed test (12 testers /
|
||||
14 days) before production access.
|
||||
|
||||
## 6. Findings for the maintainer (repo-side, discovered during verification)
|
||||
|
||||
1. **"Push mail" is on by default, not opt-in.** `SettingsRepository.kt:41` defaults
|
||||
`pushIdle = true`, so the dataSync foreground service starts as soon as the first account is
|
||||
added. The manifest comment (`AndroidManifest.xml:88` — "opt-in via Advanced Settings") and
|
||||
README wording say opt-in. Either flip the default to `false` or fix the comments; the Play
|
||||
FGS declaration drafted here describes the **actual** behavior (default-on, user-visible
|
||||
toggle, persistent notification), which is acceptable to declare but must stay truthful.
|
||||
2. **README tech-stack table says min SDK 33**; the build uses `minSdk 29`
|
||||
(`app/build.gradle.kts:51`). Fix with the #20 README pass.
|
||||
3. **README advertises an "app lock" (biometric/device-credential)** that does not exist in the
|
||||
code yet (no biometric API usage anywhere in `app/src/main`). `PRIVACY.md` deliberately does
|
||||
not claim it; remove or de-scope the README claim until implemented (#20), and update
|
||||
`PRIVACY.md` when it ships.
|
||||
4. **Release signing falls back to the debug key** without `secrets.properties` — fine for CI,
|
||||
but the Play upload must be built with the real upload keystore (section 3).
|
||||
@@ -0,0 +1,110 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# Google Play Data safety form — mapping (issue #17)
|
||||
|
||||
Fill-in guide for Play Console → **App content → Data safety**. Every answer below is grounded
|
||||
in this repository's code; re-verify against source if the data flows change. Companion docs:
|
||||
[`PRIVACY.md`](../PRIVACY.md) (the policy to link in the form),
|
||||
[`play-permissions.md`](play-permissions.md), [`play-compliance.md`](play-compliance.md).
|
||||
|
||||
## How Play defines "collection", and why LibreMail declares none
|
||||
|
||||
Play's definition ([Play Console Help — Provide information for Google Play's Data safety
|
||||
section](https://support.google.com/googleplay/android-developer/answer/10787469)): *"Collect"
|
||||
means transmitting data from your app off a user's device*, with exemptions that do **not** need
|
||||
to be disclosed:
|
||||
|
||||
1. **On-device access/processing** — data "only processed locally on the user's device and not
|
||||
sent off device".
|
||||
2. **End-to-end encryption** — data unreadable by anyone other than sender and recipient.
|
||||
3. **Ephemeral processing** — data held in memory and "retained for no longer than necessary to
|
||||
service a specific request in real time".
|
||||
|
||||
LibreMail's data flows fall under exemptions 1 and 3:
|
||||
|
||||
- The developer **operates no servers and receives no user data**. There is no analytics,
|
||||
crash-reporting, or ad SDK in the dependency tree (see audit below), and the only
|
||||
developer-directed channel that exists in code — opt-in debug-report upload
|
||||
(`app/src/main/kotlin/org/libremail/reporting/ReportUploadWorker.kt`) — is dead in shipped
|
||||
builds because `DEBUG_REPORT_ENDPOINT` defaults to `""` (`app/build.gradle.kts`), which makes
|
||||
the upload worker fail without transmitting.
|
||||
- All other traffic is the app doing its job as the user's mail agent against **servers the
|
||||
user chose** (their own IMAP/SMTP provider; Microsoft's OAuth/Graph endpoints for the Outlook
|
||||
account type). Each transfer services a specific user request in real time (sign-in, sync,
|
||||
send, server search); the app retains nothing off-device and the developer can never access
|
||||
any of it.
|
||||
|
||||
The same reasoning is the established practice of comparable open-source mail clients on Play
|
||||
that declare no collection. If a Play reviewer pushes back, use the conservative alternative at
|
||||
the bottom of this page — it is also truthful.
|
||||
|
||||
## Form answers
|
||||
|
||||
| Form question | Answer |
|
||||
|---|---|
|
||||
| Does your app collect or share any of the required user data types? | **No** |
|
||||
| Is all of the user data collected by your app encrypted in transit? | Not asked when "No" above; for the record: **yes**, all connections are TLS (`ImapClient.kt`, `SmtpSender.kt` set `ssl.checkserveridentity=true`; `MailSecurity.NONE` is not offered in the UI — `ManualSetupScreen.kt:211`) |
|
||||
| Do you provide a way for users to request that their data is deleted? | Not asked when "No" above; see account-deletion notes in [`play-compliance.md`](play-compliance.md) |
|
||||
| Privacy policy URL | `https://github.com/JMR-dev/LibreMail/blob/main/PRIVACY.md` |
|
||||
|
||||
Result shown on the store listing: **"No data collected"** / **"No data shared with third
|
||||
parties"**.
|
||||
|
||||
## Category-by-category evidence
|
||||
|
||||
Every Play data-safety category, the truthful answer, and where the code proves it:
|
||||
|
||||
| Play category | Collected? | Shared? | Evidence in code |
|
||||
|---|---|---|---|
|
||||
| Personal info → Name | No | No | Account display name stored in local Room DB only (`data/local/entity/AccountEntity` via `AccountRepositoryImpl.kt`); appears off-device only inside mail the user sends |
|
||||
| Personal info → Email address | No | No | The user's own address is their mail login, sent only to their chosen provider to authenticate/send (`ImapClient.kt`, `SmtpSender.kt`, `GraphSender.kt`) — user-initiated, real-time, never to the developer |
|
||||
| Personal info → User IDs | No | No | No developer-side accounts or IDs exist; OAuth tokens go only between the device and `login.microsoftonline.com` (`auth/OutlookAuthManager.kt`) |
|
||||
| Financial info / Health / Location | No | No | No such APIs or permissions anywhere in the merged manifest (see [`play-permissions.md`](play-permissions.md)) |
|
||||
| Messages → Emails | No | No | Mail syncs from the user's server *to* the device (`MailSyncer`), is cached locally (Room, optional SQLCipher — `di/DatabaseModule.kt`), and is transmitted only when the user sends a message to their own SMTP/Graph endpoint |
|
||||
| Photos and videos / Audio files / Files and docs | No | No | Attachments are chosen via the system document picker (`ComposeScreen.kt` `OpenMultipleDocuments`, no storage permission), stored under `cacheDir` (`MailRepositoryImpl.kt:361`), and leave the device only inside mail the user sends |
|
||||
| Calendar | No | No | No calendar API usage |
|
||||
| Contacts | **No** | No | `contacts/ContactsRepository.kt` queries `ContactsContract` **on-device** for ≤8 autocomplete matches; results are held in memory for the compose screen. Nothing is uploaded — Play's on-device exemption applies |
|
||||
| App activity (interactions, search history, installed apps) | No | No | No analytics SDK; server search sends the query string to the *user's own* IMAP server as an IMAP `SEARCH` command (user-initiated, ephemeral) |
|
||||
| Web browsing | No | No | The reader WebView has JavaScript disabled and network loads blocked unless the user enables remote images (`ui/reader/HtmlBody.kt:62,98`) — and even then requests go to hosts referenced by the email, not to the developer |
|
||||
| App info and performance (crash logs, diagnostics) | **No** | No | Crash/debug reports are written to local app storage only (`reporting/ReportStore.kt` → `filesDir/debug_reports`); upload requires an explicit user tap **and** a configured endpoint, and the endpoint is empty in this repo (`ReportSubmitter.isEnabled` → false) |
|
||||
| Device or other IDs | No | No | No advertising ID (no `AD_ID` permission in the merged manifest), no device-ID reads; debug reports include only `Build.MANUFACTURER`/`MODEL`/OS version, and stay on device (`reporting/DiagnosticsCollector.kt`) |
|
||||
|
||||
### Dependency audit (no ads / analytics / tracking SDKs)
|
||||
|
||||
The complete runtime dependency list (`app/build.gradle.kts` + `gradle/libs.versions.toml`) is:
|
||||
AndroidX (core, lifecycle, activity, navigation, webkit, Compose BOM, Room, DataStore,
|
||||
WorkManager, Hilt-androidx), Dagger Hilt, kotlinx-coroutines, Eclipse Angus Mail (IMAP/SMTP),
|
||||
AppAuth-Android (OAuth), and Zetetic SQLCipher. There is **no** Google Play Services, Firebase,
|
||||
ad, analytics, or crash-reporting dependency, and the merged release manifest contains no
|
||||
`com.google.android.gms.permission.AD_ID` permission (verified in
|
||||
`app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml`).
|
||||
|
||||
## Security-practices section of the form
|
||||
|
||||
- **Encrypted in transit:** yes — TLS everywhere, hostname verification pinned on
|
||||
(`mail.<proto>.ssl.checkserveridentity=true` in `ImapClient.kt:480` / `SmtpSender.kt:50`).
|
||||
- **Encryption at rest (optional extra credit, not a form field):** credentials are always
|
||||
encrypted with an Android Keystore key (`data/security/KeystoreCrypto.kt`,
|
||||
`CredentialStore.kt`); the mail cache can be SQLCipher-encrypted with a Keystore-sealed random
|
||||
key (`data/security/DatabaseKeyStore.kt`, opt-in, default off —
|
||||
`SettingsRepository.kt:44`).
|
||||
- **Independent security review badge:** not requested (optional program).
|
||||
|
||||
## If anything changes, this form must change
|
||||
|
||||
| Future change | Data-safety impact |
|
||||
|---|---|
|
||||
| Configuring a real `DEBUG_REPORT_ENDPOINT` (issue #34) | Declare **App info and performance → Crash logs / Diagnostics**: collected, optional (user-initiated), not shared, encrypted in transit, user can delete (reports are deletable pre-submit) |
|
||||
| Any opt-in telemetry from issues #10/#11 | Declare the specific types as collected + optional; backlog decision requires it stay strictly opt-in (F-Droid constraint) |
|
||||
| Any new SDK with network access | Re-run this audit; SDKs count toward the form ("data transmitted by libraries/SDKs") |
|
||||
|
||||
## Conservative alternative declaration (only if Google rejects "no collection")
|
||||
|
||||
Declare the following, all with *Collected: yes · Optional: no · Shared: no · Processed
|
||||
ephemerally: yes · Purpose: App functionality · Encrypted in transit: yes · Deletion: user can
|
||||
delete data in-app (remove account)*:
|
||||
|
||||
- Personal info → Email address (account sign-in)
|
||||
- Messages → Emails (sending/syncing the user's own mail with their provider)
|
||||
|
||||
Contacts, crash logs, and diagnostics remain **not collected** under any reading — they
|
||||
demonstrably never leave the device in this codebase.
|
||||
@@ -0,0 +1,112 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# Permissions justification — merged manifest audit (issue #17)
|
||||
|
||||
Every permission in the **merged release manifest** (source of truth:
|
||||
`app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml`
|
||||
after `./gradlew :app:bundleRelease`; attribution from
|
||||
`app/build/outputs/logs/manifest-merger-release-report.txt`), why it exists, where it is used,
|
||||
and the text to paste into Play Console where a declaration is required.
|
||||
|
||||
## Complete merged-manifest permission list
|
||||
|
||||
| Permission | Declared by | Runtime prompt? | Purpose |
|
||||
|---|---|---|---|
|
||||
| `INTERNET` | app manifest | No | IMAP/SMTP/OAuth/Graph connections to the user's mail provider |
|
||||
| `ACCESS_NETWORK_STATE` | app manifest | No | Connectivity checks so sync/WorkManager runs only when online |
|
||||
| `READ_CONTACTS` | app manifest | **Yes** | On-device recipient autocomplete in the compose screen |
|
||||
| `POST_NOTIFICATIONS` | app manifest | **Yes** (API 33+) | New-mail notifications + mandatory foreground-service status notification |
|
||||
| `FOREGROUND_SERVICE` | app manifest | No | Prerequisite for running any foreground service (API 28+) |
|
||||
| `FOREGROUND_SERVICE_DATA_SYNC` | app manifest | No | Type-specific permission for the IMAP IDLE push service (API 34+) |
|
||||
| `WAKE_LOCK` | `androidx.work:work-runtime:2.11.2` | No | WorkManager keeps the CPU awake while a scheduled job (mail sync, outbox send) runs |
|
||||
| `RECEIVE_BOOT_COMPLETED` | `androidx.work:work-runtime:2.11.2` | No | WorkManager reschedules pending jobs (periodic sync, queued outbox mail) after reboot |
|
||||
| `org.libremail.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` | `androidx.core:core:1.17.0` | No | Auto-generated app-signature permission guarding non-exported runtime receivers; not user-facing |
|
||||
|
||||
Nothing else. Notably **absent** (worth stating in any review exchange):
|
||||
|
||||
- **No `AD_ID`** — no ads or analytics SDKs at all.
|
||||
- **No storage/media permissions** — attachments use the Storage Access Framework
|
||||
(`OpenMultipleDocuments` in `ui/compose/ComposeScreen.kt:88`) and a `FileProvider` for viewing
|
||||
(`AndroidManifest.xml:95`).
|
||||
- **No `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`** — deliberately avoided because Play restricts
|
||||
it; the app deep-links to the system app-details screen instead
|
||||
(`push/BatteryOptimizationManager.kt`, comment cites this issue).
|
||||
- No location, camera, microphone, SMS, call-log, accessibility, or `QUERY_ALL_PACKAGES`.
|
||||
|
||||
## `READ_CONTACTS` (Play "sensitive" permission — scrutinized, no declaration form)
|
||||
|
||||
- **Feature:** recipient autocomplete while composing. `contacts/ContactsRepository.kt` queries
|
||||
`ContactsContract.CommonDataKinds.Email` for at most 8 name/email matches of the typed text.
|
||||
- **Data handling:** query and results are entirely **on-device** (results live in memory for
|
||||
the suggestion dropdown). Nothing from the contacts provider is stored, logged, or
|
||||
transmitted; an address reaches the network only if the user puts it on an email they send.
|
||||
- **Request flow:** first composition of the compose screen (`ui/compose/ComposeScreen.kt:101`);
|
||||
denial is handled gracefully — `ContactsRepository.search` returns empty and composing works
|
||||
normally (manual address entry).
|
||||
- **Play-Console justification text (if asked in review):**
|
||||
> LibreMail is an email client. READ_CONTACTS powers recipient autocomplete on the compose
|
||||
> screen only: the app queries the on-device contacts provider for names/email addresses
|
||||
> matching what the user typed and shows up to 8 suggestions. Contact data is processed
|
||||
> entirely on the device — it is never uploaded, stored outside the suggestion list, or shared.
|
||||
> The permission is requested in context (first open of the compose screen) and the feature
|
||||
> degrades gracefully if denied.
|
||||
|
||||
## `POST_NOTIFICATIONS`
|
||||
|
||||
- **Features:** (1) per-account new-mail notifications, generated on-device from synced mail —
|
||||
`notifications/MailNotifier.kt` (no push/cloud-messaging service; lock-screen content
|
||||
redacted via `VISIBILITY_PRIVATE`); (2) the persistent low-importance status notification
|
||||
Android requires while the IMAP IDLE foreground service runs (`push/IdleService.kt:120`).
|
||||
- **Request flow:** once at first launch, API 33+ only (`MainActivity.kt`
|
||||
`NotificationPermissionEffect`). If denied, `MailNotifier.notifyNewMail` no-ops (permission
|
||||
re-checked before every post, `MailNotifier.kt:134`); mail sync itself is unaffected.
|
||||
- **Play-Console justification text (if asked):**
|
||||
> Notifies the user of newly received email (per-account channels, generated on the device
|
||||
> from the user's own mailbox — no push service) and shows the persistent status notification
|
||||
> Android requires for the optional foreground IMAP IDLE connection. Requested once at first
|
||||
> launch; all app functions except notifications work if declined.
|
||||
|
||||
## `FOREGROUND_SERVICE_DATA_SYNC` (requires the Play Console FGS declaration)
|
||||
|
||||
Play Console → App content → **Foreground service permissions** asks for the type's use case
|
||||
and a demo video. Facts to declare, all verifiable in `push/IdleService.kt`:
|
||||
|
||||
- **What runs:** one foreground service (`.push.IdleService`, manifest
|
||||
`foregroundServiceType="dataSync"`, `AndroidManifest.xml:89`) holding a long-lived IMAP IDLE
|
||||
(RFC 2177) connection per configured account so the user's own mail server can push new mail
|
||||
instantly. On server activity it triggers a normal sync into the local cache and a new-mail
|
||||
notification.
|
||||
- **Why a foreground service:** IMAP IDLE requires a continuously open TCP connection that
|
||||
survives while the app is backgrounded; it cannot be modeled as deferrable work. WorkManager
|
||||
**is** used for everything deferrable (periodic sync, outbox sending) — the service exists
|
||||
only for the always-connected push case. There is no push-notification alternative (FCM)
|
||||
because plain IMAP servers cannot address one, and the app deliberately uses no Google cloud
|
||||
services.
|
||||
- **User control / lifecycle:** starts only when at least one account exists **and** the "push
|
||||
mail" setting is enabled (`LibreMailApplication.kt:70`); the setting is a visible toggle in
|
||||
Settings (`ui/settings/SettingsScreen.kt:147`); the service stops reactively when the toggle
|
||||
turns off or the last account is removed, and shows a persistent low-importance status
|
||||
notification while running (`IdleService.startAsForeground`).
|
||||
- **Declaration text to paste:**
|
||||
> LibreMail is an email client. The dataSync foreground service maintains a long-lived IMAP
|
||||
> IDLE (RFC 2177) connection to the user's own mail server so new mail arrives instantly.
|
||||
> IMAP has no out-of-band push channel (such as FCM), so real-time delivery requires keeping
|
||||
> this user-visible connection open; all deferrable transfers (periodic sync, sending queued
|
||||
> mail) already use WorkManager instead. The service runs only while the user has an account
|
||||
> configured and the "push mail" setting enabled, displays a persistent status notification,
|
||||
> and stops immediately when the user disables the setting or removes their last account.
|
||||
- **Demo video (human step):** screen-record: Settings → toggle "push mail" on → the status
|
||||
notification appears → send the account a mail from elsewhere → the new-mail notification
|
||||
arrives with the app backgrounded → toggle off → status notification disappears.
|
||||
- **Note:** the app targets SDK 37, so the API-34 requirement to declare a type for every FGS
|
||||
is in force; `FOREGROUND_SERVICE` plus the typed permission are both declared and the service
|
||||
calls `ServiceCompat.startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)`
|
||||
(`IdleService.kt:136`).
|
||||
|
||||
## Library-injected permissions (`WAKE_LOCK`, `RECEIVE_BOOT_COMPLETED`)
|
||||
|
||||
Injected by `androidx.work:work-runtime:2.11.2` for its own machinery: holding a partial wake
|
||||
lock while an enqueued job executes, and re-registering scheduled jobs after a reboot. LibreMail
|
||||
uses WorkManager for periodic mail sync (`data/sync/` workers), reliable outbox sending
|
||||
(`SendWorker.kt`), and the opt-in debug-report upload (`ReportUploadWorker.kt`, dormant —
|
||||
endpoint unconfigured). Neither permission needs a Play declaration; keep this attribution handy
|
||||
for review questions.
|
||||
@@ -0,0 +1,8 @@
|
||||
First release of LibreMail.
|
||||
|
||||
* Outlook/Microsoft (OAuth 2.0), Gmail/Yahoo/iCloud (app password), and generic IMAP/SMTP accounts
|
||||
* Offline-first mail cache with full-history backfill and background sync
|
||||
* Rich-text compose, drafts, signatures, attachments, and a reliable outbox
|
||||
* Unified inbox across multiple accounts, local + server search
|
||||
* New-mail notifications and optional IMAP IDLE instant push
|
||||
* Hardened HTML reader: JavaScript off, remote images blocked by default
|
||||
@@ -0,0 +1,23 @@
|
||||
LibreMail is a free and open-source email client for Android, built with Kotlin, Jetpack Compose and Material 3 (Material You). It aims for a friendly default experience with power-user features tucked under an Advanced Settings group.
|
||||
|
||||
Features:
|
||||
|
||||
* Send and receive email with Outlook/Microsoft (OAuth 2.0), Gmail, Yahoo and iCloud (app password), and any other IMAP/SMTP provider
|
||||
* Guided first-run onboarding: welcome, vendor picker, per-vendor setup, then straight to your inbox
|
||||
* Offline-first: a local mail cache with full-history backfill (resumable) and an optional device-only retention limit
|
||||
* Multiple accounts with a unified inbox and per-account filtering
|
||||
* Rich-text compose with a formatting toolbar, per-account signatures, drafts, and a reliable background outbox
|
||||
* Message bodies rendered in a hardened WebView: JavaScript off, remote images blocked by default (tracking-pixel protection)
|
||||
* On-device new-mail notifications (no push service), with optional instant push via IMAP IDLE
|
||||
* Search across cached mail and the server (IMAP SEARCH)
|
||||
* Attachments: download on demand, open in a system viewer, and attach files when composing
|
||||
* mailto: link handling and share-to-email
|
||||
* Material You dynamic theming, light/dark, edge-to-edge
|
||||
|
||||
Privacy and security:
|
||||
|
||||
* No ads, no analytics SDK, no tracking. By default network traffic goes only to your mail providers.
|
||||
* Credentials are encrypted with the Android Keystore; OAuth uses Authorization Code + PKCE.
|
||||
* Optional (opt-in) extras: SQLCipher cache encryption, a biometric/device-credential app lock, Android Backup of settings only, and local-only debug reports that are sent nowhere unless you explicitly submit one.
|
||||
|
||||
LibreMail is licensed under the GNU General Public License v3.0 or later.
|
||||
@@ -0,0 +1 @@
|
||||
Privacy-respecting, offline-first email for any IMAP/SMTP provider
|
||||
@@ -0,0 +1 @@
|
||||
LibreMail
|
||||
Reference in New Issue
Block a user