diff --git a/README.md b/README.md index fef90b7..6cc4ab4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ experience with power-user features tucked under an **Advanced Settings** group. > system viewer, and attach files when composing; **multiple accounts** — a unified inbox > with per-account filtering; and > **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft** -> accounts (OAuth 2.0 sign-in, IMAP receive + SMTP send over XOAUTH2). +> accounts (OAuth 2.0 sign-in, IMAP receive + Microsoft Graph send, SMTP/XOAUTH2 fallback). ## Features (target MVP) @@ -91,18 +91,20 @@ assessment for the restricted scope. ## Outlook / Microsoft account setup (OAuth client) -Outlook uses the Microsoft identity platform with OAuth 2.0 + PKCE (no client secret), -requesting the `outlook.office.com` **IMAP** and **SMTP** scopes — a single token -authenticates both IMAP receive and SMTP send over XOAUTH2. A working client ID ships with -the build; to use your own Azure app registration instead: +Outlook uses the Microsoft identity platform with OAuth 2.0 + PKCE (no client secret). Send +goes through Microsoft **Graph** (`sendMail`, their preferred API) with SMTP/XOAUTH2 as a +fallback; receive is **IMAP**. Graph and Exchange Online are separate resources, so one +consent grants every scope and per-resource access tokens are minted from the one refresh +token. A working client ID ships with the build; to use your own Azure app registration: 1. [Azure portal](https://portal.azure.com/) → **App registrations → New registration.** Supported account types: *Accounts in any organizational directory and personal Microsoft accounts*. 2. **Authentication → Add a platform → Mobile and desktop applications**; add the redirect URI `org.libremail.outlook://oauth2redirect` and enable **Allow public client flows**. -3. **API permissions:** add the delegated **Office 365 Exchange Online** scopes - `IMAP.AccessAsUser.All` and `SMTP.Send` (`openid`/`email`/`offline_access` come from OIDC). +3. **API permissions** (delegated): **Microsoft Graph → `Mail.Send`** (primary send), plus + **Office 365 Exchange Online → `IMAP.AccessAsUser.All` and `SMTP.Send`** (receive + SMTP + fallback). `openid`/`email`/`offline_access` come from OIDC. 4. Copy the **Application (client) ID** into `secrets.properties` as `OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ece7c90..25905b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -129,6 +129,8 @@ dependencies { testImplementation(libs.turbine) testImplementation(libs.mockk) testImplementation(libs.greenmail) + // The real org.json for unit tests (android.jar ships a stubbed, no-op version). + testImplementation("org.json:json:20231013") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt index ebf8ad1..6c635da 100644 --- a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt +++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt @@ -17,17 +17,21 @@ import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationResponse import net.openid.appauth.AuthorizationService import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.GrantTypeValues import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenRequest import org.json.JSONObject import org.libremail.BuildConfig /** * Outlook / Microsoft OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. * - * One consent requests the `outlook.office.com` IMAP **and** SMTP scopes. Because both live - * under a single resource, the resulting access token authenticates IMAP receive and SMTP send - * over SASL XOAUTH2 — no second token or Graph call is needed. The "common" tenant endpoints - * accept both personal Microsoft accounts (outlook.com/hotmail) and work/school (Microsoft 365). + * Send goes through Microsoft **Graph** (`sendMail`, their first-class/preferred API); IMAP + * receive — and SMTP send as a fallback — go through **Exchange Online**. Those are two distinct + * resources (`graph.microsoft.com` vs `outlook.office.com`), and Microsoft's token endpoint issues + * an access token for one resource per request, so a single consent grants every scope and we mint + * resource-specific access tokens from the one refresh token on demand. The "common" tenant accepts + * both personal Microsoft accounts and work/school (Microsoft 365). */ @Singleton class OutlookAuthManager @Inject constructor( @@ -48,11 +52,8 @@ class OutlookAuthManager @Inject constructor( ResponseTypeValues.CODE, Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI), ) - .setScope( - "openid email offline_access " + - "https://outlook.office.com/IMAP.AccessAsUser.All " + - "https://outlook.office.com/SMTP.Send", - ) + // One consent covering both resources; per-resource access tokens are minted later. + .setScope("openid email $OFFLINE $GRAPH_SCOPE $OUTLOOK_SCOPE") .build() return AuthorizationService(context).getAuthorizationRequestIntent(request) } @@ -76,23 +77,39 @@ class OutlookAuthManager @Inject constructor( val authState = AuthState(response, exception).apply { update(tokenResponse, null) } val email = emailFromIdToken(tokenResponse.idToken) ?: throw IllegalStateException("Could not read the account email from the token") + // Mint an Exchange Online token so the caller can verify the account over IMAP. + val outlook = refreshForScope(authState, OUTLOOK_SCOPE) return OAuthResult( email = email, - accessToken = tokenResponse.accessToken.orEmpty(), - authStateJson = authState.jsonSerializeString(), + accessToken = outlook.accessToken, + authStateJson = outlook.authStateJson, ) } finally { service.dispose() } } - /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */ - suspend fun freshAccessToken(authStateJson: String): FreshToken { - val authState = AuthState.jsonDeserialize(authStateJson) + /** A fresh Exchange Online (outlook.office.com) token for IMAP receive and SMTP-fallback send. */ + suspend fun freshOutlookToken(authStateJson: String): FreshToken = + refreshForScope(AuthState.jsonDeserialize(authStateJson), OUTLOOK_SCOPE) + + /** A fresh Microsoft Graph token for the primary `sendMail` send path. */ + suspend fun freshGraphToken(authStateJson: String): FreshToken = + refreshForScope(AuthState.jsonDeserialize(authStateJson), GRAPH_SCOPE) + + /** Redeems the stored refresh token for an access token scoped to a single resource. */ + private suspend fun refreshForScope(authState: AuthState, scope: String): FreshToken { + val refreshToken = authState.refreshToken + ?: throw IllegalStateException("No refresh token available; please sign in again") val service = AuthorizationService(context) try { - val accessToken = suspendCancellableCoroutine { continuation -> - authState.performActionWithFreshTokens(service) { token, _, error -> + val request = TokenRequest.Builder(serviceConfig, BuildConfig.OUTLOOK_OAUTH_CLIENT_ID) + .setGrantType(GrantTypeValues.REFRESH_TOKEN) + .setRefreshToken(refreshToken) + .setScope("$OFFLINE $scope") + .build() + val tokenResponse = suspendCancellableCoroutine { continuation -> + service.performTokenRequest(request) { token, error -> if (token != null) { continuation.resume(token) } else { @@ -100,7 +117,11 @@ class OutlookAuthManager @Inject constructor( } } } - return FreshToken(accessToken = accessToken, authStateJson = authState.jsonSerializeString()) + authState.update(tokenResponse, null) + return FreshToken( + accessToken = tokenResponse.accessToken.orEmpty(), + authStateJson = authState.jsonSerializeString(), + ) } finally { service.dispose() } @@ -116,4 +137,11 @@ class OutlookAuthManager @Inject constructor( claims.optString("email").ifBlank { claims.optString("preferred_username") }.ifBlank { null } }.getOrNull() } + + private companion object { + const val OFFLINE = "offline_access" + const val GRAPH_SCOPE = "https://graph.microsoft.com/Mail.Send" + const val OUTLOOK_SCOPE = + "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send" + } } diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt index 75e1150..1d220e3 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -27,13 +27,20 @@ class MailConnectionFactory @Inject constructor( suspend fun smtpParamsFor(account: Account): SmtpParams = account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP) + /** A fresh Microsoft Graph access token for the primary Outlook (sendMail) send path. */ + suspend fun graphTokenFor(account: Account): String { + val stored = credentialStore.loadSecret(account.id) + ?: error("No stored credentials for ${account.email}") + return refreshedToken(account.id, stored, outlookAuthManager::freshGraphToken) + } + private suspend fun resolveSecret(account: Account): String { val stored = credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}") return when (account.authType) { AuthType.PASSWORD_IMAP -> stored AuthType.OAUTH_GMAIL -> refreshedToken(account.id, stored, gmailAuthManager::freshAccessToken) - AuthType.OAUTH_OUTLOOK -> refreshedToken(account.id, stored, outlookAuthManager::freshAccessToken) + AuthType.OAUTH_OUTLOOK -> refreshedToken(account.id, stored, outlookAuthManager::freshOutlookToken) } } diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index ada49ce..7cc18c4 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -11,7 +11,10 @@ import java.io.File import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.toDomain +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType import org.libremail.domain.model.OutgoingMessage +import org.libremail.mail.GraphSender import org.libremail.mail.SmtpSender /** Drains the outbox: sends each queued message over SMTP, deleting it on success. */ @@ -22,6 +25,7 @@ class SendWorker @AssistedInject constructor( private val outboxDao: OutboxDao, private val accountDao: AccountDao, private val smtpSender: SmtpSender, + private val graphSender: GraphSender, private val connectionFactory: MailConnectionFactory, ) : CoroutineWorker(appContext, workerParams) { @@ -39,18 +43,19 @@ class SendWorker @AssistedInject constructor( continue } runCatching { - smtpSender.send( - connectionFactory.smtpParamsFor(account), - from = account.email, - message = OutgoingMessage( - accountId = entity.accountId, - to = entity.toAddresses, - cc = entity.ccAddresses, - subject = entity.subject, - body = entity.body, - ), - attachments = attachmentDir.listFiles()?.toList().orEmpty(), + val message = OutgoingMessage( + accountId = entity.accountId, + to = entity.toAddresses, + cc = entity.ccAddresses, + subject = entity.subject, + body = entity.body, ) + val files = attachmentDir.listFiles()?.toList().orEmpty() + if (account.authType == AuthType.OAUTH_OUTLOOK) { + sendOutlook(account, message, files) + } else { + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) + } }.fold( onSuccess = { outboxDao.delete(entity.id) @@ -65,4 +70,13 @@ class SendWorker @AssistedInject constructor( // Retry (with WorkManager backoff) so failed sends are reattempted when conditions improve. return if (anyFailed) Result.retry() else Result.success() } + + /** Outlook prefers Microsoft Graph; fall back to SMTP (XOAUTH2) if the Graph send fails. */ + private suspend fun sendOutlook(account: Account, message: OutgoingMessage, files: List) { + runCatching { + graphSender.send(connectionFactory.graphTokenFor(account), message, files) + }.getOrElse { + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) + } + } } diff --git a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt new file mode 100644 index 0000000..a45d17b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import org.libremail.domain.model.OutgoingMessage + +/** + * Sends mail via Microsoft Graph `me/sendMail` — Microsoft's preferred send path for Outlook / + * Microsoft 365, used in place of SMTP. Authenticated with a Graph access token (Bearer). + */ +@Singleton +class GraphSender @Inject constructor() { + + suspend fun send( + accessToken: String, + message: OutgoingMessage, + attachments: List = emptyList(), + ) = withContext(Dispatchers.IO) { + val payload = buildSendMailPayload(message, attachments) + val connection = (URL(SEND_MAIL_URL).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + doOutput = true + setRequestProperty("Authorization", "Bearer $accessToken") + setRequestProperty("Content-Type", "application/json; charset=utf-8") + } + try { + connection.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) } + val code = connection.responseCode + if (code !in 200..299) { + val body = (connection.errorStream ?: connection.inputStream) + ?.bufferedReader()?.use { it.readText() }.orEmpty() + error("Graph sendMail failed (HTTP $code): ${body.take(500)}") + } + } finally { + connection.disconnect() + } + } + + private companion object { + const val SEND_MAIL_URL = "https://graph.microsoft.com/v1.0/me/sendMail" + const val TIMEOUT_MS = 15_000 + } +} + +/** Builds the Graph `sendMail` JSON body (a pure function, so it is unit-testable without a network). */ +internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List): String { + val mail = JSONObject() + .put("subject", message.subject) + .put("body", JSONObject().put("contentType", "Text").put("content", message.body)) + .put("toRecipients", recipientsJson(message.to)) + if (message.cc.isNotBlank()) { + mail.put("ccRecipients", recipientsJson(message.cc)) + } + if (attachments.isNotEmpty()) { + val items = JSONArray() + attachments.forEach { file -> + items.put( + JSONObject() + .put("@odata.type", "#microsoft.graph.fileAttachment") + .put("name", file.name) + .put("contentBytes", Base64.getEncoder().encodeToString(file.readBytes())), + ) + } + mail.put("attachments", items) + } + return JSONObject().put("message", mail).put("saveToSentItems", true).toString() +} + +/** Splits a comma/semicolon-separated address list into Graph `emailAddress` recipient objects. */ +private fun recipientsJson(addresses: String): JSONArray { + val array = JSONArray() + addresses.split(",", ";") + .map { it.trim() } + .filter { it.isNotEmpty() } + .forEach { address -> + array.put(JSONObject().put("emailAddress", JSONObject().put("address", address))) + } + return array +} diff --git a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt new file mode 100644 index 0000000..1eed584 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.json.JSONObject +import org.junit.Test +import org.libremail.domain.model.OutgoingMessage + +class GraphSenderTest { + + private fun message(to: String, cc: String = "", subject: String = "Hi", body: String = "Body") = + OutgoingMessage(accountId = "outlook:me@example.com", to = to, cc = cc, subject = subject, body = body) + + @Test + fun `payload carries subject, body and parsed recipients`() { + val json = JSONObject(buildSendMailPayload(message(to = "a@x.com, b@y.com", cc = "c@z.com"), emptyList())) + assertTrue(json.getBoolean("saveToSentItems")) + + val msg = json.getJSONObject("message") + assertEquals("Hi", msg.getString("subject")) + assertEquals("Text", msg.getJSONObject("body").getString("contentType")) + assertEquals("Body", msg.getJSONObject("body").getString("content")) + + val to = msg.getJSONArray("toRecipients") + assertEquals(2, to.length()) + assertEquals("a@x.com", to.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + assertEquals("b@y.com", to.getJSONObject(1).getJSONObject("emailAddress").getString("address")) + + val cc = msg.getJSONArray("ccRecipients") + assertEquals(1, cc.length()) + assertEquals("c@z.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + } + + @Test + fun `payload omits cc when blank and encodes attachments as base64`() { + val file = File.createTempFile("graph-att", ".txt").apply { writeText("hello") } + try { + val msg = JSONObject(buildSendMailPayload(message(to = "a@x.com"), listOf(file))).getJSONObject("message") + assertFalse(msg.has("ccRecipients")) + + val attachments = msg.getJSONArray("attachments") + assertEquals(1, attachments.length()) + val attachment = attachments.getJSONObject(0) + assertEquals("#microsoft.graph.fileAttachment", attachment.getString("@odata.type")) + assertEquals(file.name, attachment.getString("name")) + assertEquals("aGVsbG8=", attachment.getString("contentBytes")) // base64("hello") + } finally { + file.delete() + } + } +}