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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user