diff --git a/README.md b/README.md
index b412f97..7c745b1 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,10 @@ experience with power-user features tucked under an **Advanced Settings** group.
> 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 over SMTP, plus reply; and **on-device new-mail
-> notifications** (no push service) with persisted settings. Outlook Graph send, IMAP
-> IDLE push, attachments, and multi-account polish are upcoming.
+> contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail
+> notifications** (no push service) with persisted settings; and **instant push** via a
+> foreground **IMAP IDLE** service. Outlook Graph send, attachments, and multi-account
+> polish are upcoming.
## Features (target MVP)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index afe04fb..e9a012d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,8 @@
+
+
+
+
+
diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt
index ce5597c..facdb01 100644
--- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt
+++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt
@@ -6,7 +6,15 @@ 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.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.push.IdlePushManager
@HiltAndroidApp
class LibreMailApplication : Application(), Configuration.Provider {
@@ -15,6 +23,12 @@ class LibreMailApplication : Application(), Configuration.Provider {
@Inject lateinit var syncScheduler: SyncScheduler
+ @Inject lateinit var settingsRepository: SettingsRepository
+
+ @Inject lateinit var idlePushManager: IdlePushManager
+
+ private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -23,5 +37,12 @@ class LibreMailApplication : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
syncScheduler.schedulePeriodicSync()
+ // Start or stop the IMAP IDLE push service to match the user's preference, reactively.
+ appScope.launch {
+ settingsRepository.settings
+ .map { it.pushIdle }
+ .distinctUntilChanged()
+ .collect { enabled -> if (enabled) idlePushManager.start() else idlePushManager.stop() }
+ }
}
}
diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
index 5590a58..11d36d9 100644
--- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
+++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
@@ -1,6 +1,7 @@
// 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
@@ -9,12 +10,20 @@ 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.InternetAddress
import java.util.Properties
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.job
+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
@@ -130,6 +139,54 @@ class ImapClient @Inject constructor() {
}
}
+ /**
+ * 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)
+ val inbox = store.getFolder("INBOX") as IMAPFolder
+ inbox.open(Folder.READ_ONLY)
+ Log.d(TAG, "IDLE connected for ${params.username}")
+
+ val pushes = Channel(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()
+ }
+ // Closing the store from the cancellation handler unblocks the blocking idle() below.
+ val handle = coroutineContext.job.invokeOnCompletion { 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 {
+ handle.dispose()
+ pushes.close()
+ syncer.cancel()
+ 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)
@@ -151,22 +208,7 @@ class ImapClient @Inject constructor() {
private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T {
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
- val props = 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", "true")
- }
- if (params.useXoauth2) {
- put("mail.$protocol.auth.mechanisms", "XOAUTH2")
- }
- }
- val store = Session.getInstance(props).getStore(protocol)
+ val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol)
store.connect(params.host, params.port, params.username, params.secret)
return try {
block(store)
@@ -175,7 +217,24 @@ class ImapClient @Inject constructor() {
}
}
+ 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", "true")
+ }
+ if (params.useXoauth2) {
+ put("mail.$protocol.auth.mechanisms", "XOAUTH2")
+ }
+ }
+
private companion object {
const val TIMEOUT_MS = "15000"
+ const val TAG = "LibreMailIdle"
}
}
diff --git a/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt b/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt
new file mode 100644
index 0000000..25f2089
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt
@@ -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)) }
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt
new file mode 100644
index 0000000..0484581
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt
@@ -0,0 +1,119 @@
+// 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.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+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
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ startAsForeground()
+ if (!watching) {
+ watching = true
+ scope.launch { watchAllAccounts() }
+ }
+ return START_STICKY
+ }
+
+ private suspend fun watchAllAccounts() {
+ val accounts = accountDao.getAll().map { it.toDomain() }
+ if (accounts.isEmpty()) {
+ stopSelf()
+ return
+ }
+ accounts.forEach { account -> scope.launch { watchAccount(account) } }
+ }
+
+ /** Holds IDLE for one account, reconnecting with exponential backoff whenever it drops. */
+ private suspend fun watchAccount(account: Account) {
+ var backoffMs = INITIAL_BACKOFF_MS
+ while (scope.isActive) {
+ try {
+ val params = connectionFactory.imapParamsFor(account)
+ imapClient.idle(params) { mailSyncer.syncAll() }
+ 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
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e2008b4..63b68b3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -62,6 +62,9 @@
New mail
%1$d new messages
+ Push (IMAP IDLE)
+ Watching for new mail
+ Connected for instant delivery
Accounts