Merge main into feat-326-logging-authlock

This commit is contained in:
Jason Ross
2026-07-04 22:29:34 -05:00
committed by GitHub
5 changed files with 221 additions and 70 deletions
@@ -2,7 +2,6 @@
package org.libremail.data.sync
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
@@ -24,6 +23,8 @@ import org.libremail.mail.GraphSendException
import org.libremail.mail.GraphSender
import org.libremail.mail.SendableAttachment
import org.libremail.mail.SmtpSender
import org.libremail.reporting.AppLog
import org.libremail.reporting.accountLogRef
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -57,66 +58,89 @@ class SendWorker @AssistedInject constructor(
val attachmentUriGrants = this.attachmentUriGrants.get()
val pending = outboxDao.getAll()
if (pending.isEmpty()) return Result.success()
AppLog.i(TAG, "outbox drain: ${pending.size} queued")
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()
attachmentUriGrants.releaseUnreferenced(entity.attachmentUris())
continue
}
runCatching {
val message = OutgoingMessage(
accountId = entity.accountId,
to = entity.toAddresses,
cc = entity.ccAddresses,
bcc = entity.bccAddresses,
subject = entity.subject,
body = entity.body,
bodyHtml = entity.bodyHtml,
)
val attachments = stagedAttachments(attachmentDir, entity.attachments.toOutgoingAttachments())
if (account.authType == AuthType.OAUTH_OUTLOOK) {
sendOutlook(connectionFactory, account, message, attachments)
} else {
smtpSender.send(
connectionFactory.smtpParamsFor(account),
from = account.email,
message = message,
attachments = attachments,
)
}
}.fold(
onSuccess = {
outboxDao.delete(entity.id)
attachmentDir.deleteRecursively()
// The picked bytes were staged at enqueue; with the row sent, drop the persistable
// grant unless a live draft/outbox row still references the same URI (security review).
attachmentUriGrants.releaseUnreferenced(entity.attachmentUris())
},
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
}
},
)
val failed = sendQueued(entity, outboxDao, accountDao, connectionFactory, attachmentUriGrants)
if (failed) anyFailed = true
}
// Retry (with WorkManager backoff) so failed sends are reattempted when conditions improve.
return if (anyFailed) Result.retry() else Result.success()
}
/**
* Sends one queued [entity] — or drops it if its account was removed — updating the outbox row
* and releasing its staged attachment grant. Returns true if the send genuinely failed (should
* count toward a WorkManager retry); the ambiguous "may have sent" Graph case returns false, since
* it is deliberately left queued rather than retried (see [sendOutlook]).
*/
private suspend fun sendQueued(
entity: OutboxEntity,
outboxDao: OutboxDao,
accountDao: AccountDao,
connectionFactory: MailConnectionFactory,
attachmentUriGrants: AttachmentUriGrants,
): Boolean {
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()
attachmentUriGrants.releaseUnreferenced(entity.attachmentUris())
return false
}
var failed = false
runCatching {
val message = OutgoingMessage(
accountId = entity.accountId,
to = entity.toAddresses,
cc = entity.ccAddresses,
bcc = entity.bccAddresses,
subject = entity.subject,
body = entity.body,
bodyHtml = entity.bodyHtml,
)
val attachments = stagedAttachments(attachmentDir, entity.attachments.toOutgoingAttachments())
if (account.authType == AuthType.OAUTH_OUTLOOK) {
sendOutlook(connectionFactory, account, message, attachments)
} else {
smtpSender.send(
connectionFactory.smtpParamsFor(account),
from = account.email,
message = message,
attachments = attachments,
)
}
}.fold(
onSuccess = {
outboxDao.delete(entity.id)
attachmentDir.deleteRecursively()
// The picked bytes were staged at enqueue; with the row sent, drop the persistable
// grant unless a live draft/outbox row still references the same URI (security review).
attachmentUriGrants.releaseUnreferenced(entity.attachmentUris())
val via = if (account.authType == AuthType.OAUTH_OUTLOOK) "Graph" else "SMTP"
AppLog.i(TAG, "sent ${accountLogRef(account.id)} via $via")
},
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)
failed = true
AppLog.w(TAG, "send failed for ${accountLogRef(account.id)}; will retry")
}
},
)
return failed
}
/**
* 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
@@ -143,7 +167,7 @@ class SendWorker @AssistedInject constructor(
throw e
} catch (e: Exception) {
// Graph was never reached (e.g. token refresh failed) — SMTP cannot duplicate it.
Log.w(TAG, "Graph send failed for ${account.email}; falling back to SMTP", e)
AppLog.w(TAG, "Graph send failed for ${accountLogRef(account.id)}; falling back to SMTP", e)
smtpSender.send(
connectionFactory.smtpParamsFor(account),
from = account.email,
@@ -1,7 +1,6 @@
// 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
@@ -33,6 +32,7 @@ import org.eclipse.angus.mail.imap.IMAPFolder
import org.eclipse.angus.mail.imap.IMAPMessage
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.reporting.AppLog
import java.util.Properties
import javax.inject.Inject
import javax.inject.Singleton
@@ -474,12 +474,12 @@ class ImapClient(private val reuseConnections: Boolean) {
runCatching { store.close() }
throw e
}
Log.d(TAG, "IDLE connected")
AppLog.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)")
AppLog.d(TAG, "IDLE push: ${event.messages.size} new message(s)")
pushes.trySend(Unit)
}
})
@@ -8,7 +8,6 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
@@ -38,6 +37,7 @@ import org.libremail.domain.model.Account
import org.libremail.mail.ImapClient
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
import org.libremail.reporting.accountLogRef
import javax.inject.Inject
/**
@@ -91,7 +91,7 @@ class IdleService : Service() {
// Can't open the encrypted DB without the user present. Defer (stop) and let the app
// restart push after the next unlock, rather than block the service and ANR.
if (cacheGuard.isCacheLocked()) {
Log.i(TAG, "encrypted cache locked; deferring IDLE push until the app is unlocked")
AppLog.i(TAG, "encrypted cache locked; deferring IDLE push until the app is unlocked")
stopSelf()
return@launch
}
@@ -200,6 +200,7 @@ class IdleService : Service() {
* idle()'s on-connect sync, so no mail is missed across renewals.
*/
private suspend fun watchAccount(account: Account) {
AppLog.i(TAG, "IDLE watch start ${accountLogRef(account.id)}")
var backoffMs = INITIAL_BACKOFF_MS
while (scope.isActive) {
try {
@@ -212,7 +213,7 @@ class IdleService : Service() {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "IDLE for ${account.email} dropped; retrying in ${backoffMs}ms", e)
AppLog.w(TAG, "IDLE for ${accountLogRef(account.id)} dropped; retrying in ${backoffMs}ms", e)
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
}