Send Outlook mail via Microsoft Graph (SMTP fallback)

Microsoft is steadily restricting OAuth SMTP, and Graph sendMail is their first-class send
path, so Outlook now sends through Graph with SMTP/XOAUTH2 as a fallback.

Graph (graph.microsoft.com) and Exchange Online (outlook.office.com) are separate OAuth
resources, so one consent requests all scopes (Graph Mail.Send + IMAP + SMTP) and
OutlookAuthManager mints per-resource access tokens from the single refresh token on demand
(freshGraphToken / freshOutlookToken).

- GraphSender POSTs me/sendMail with a JSON message (recipients, text body, base64
  fileAttachments, saveToSentItems); a unit test covers the payload building.
- SendWorker tries Graph first for Outlook accounts and falls back to SmtpSender on failure;
  Gmail/IMAP accounts are unchanged. MailConnectionFactory.graphTokenFor supplies the token.
- Verified: assemble/lint/test green; on the emulator the two-resource consent is accepted
  (Microsoft renders its sign-in page, no AADSTS multi-resource error). The post-login token
  exchange + actual Graph send need a real Outlook account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 20:18:10 -05:00
co-authored by Claude Opus 4.8
parent 1f63773faa
commit 3d93f76fc1
7 changed files with 233 additions and 36 deletions
+9 -7
View File
@@ -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).
+2
View File
@@ -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)
@@ -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"
}
}
@@ -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)
}
}
@@ -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<File>) {
runCatching {
graphSender.send(connectionFactory.graphTokenFor(account), message, files)
}.getOrElse {
smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files)
}
}
}
@@ -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<File> = emptyList(),
) = withContext(Dispatchers.IO) {
val payload = buildSendMailPayload(message, attachments)
val connection = (URL(SEND_MAIL_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
doOutput = true
setRequestProperty("Authorization", "Bearer $accessToken")
setRequestProperty("Content-Type", "application/json; charset=utf-8")
}
try {
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<File>): String {
val mail = JSONObject()
.put("subject", message.subject)
.put("body", JSONObject().put("contentType", "Text").put("content", message.body))
.put("toRecipients", recipientsJson(message.to))
if (message.cc.isNotBlank()) {
mail.put("ccRecipients", recipientsJson(message.cc))
}
if (attachments.isNotEmpty()) {
val items = JSONArray()
attachments.forEach { file ->
items.put(
JSONObject()
.put("@odata.type", "#microsoft.graph.fileAttachment")
.put("name", file.name)
.put("contentBytes", Base64.getEncoder().encodeToString(file.readBytes())),
)
}
mail.put("attachments", items)
}
return JSONObject().put("message", mail).put("saveToSentItems", true).toString()
}
/** 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
}
@@ -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()
}
}
}