Add Outlook / Microsoft account support (OAuth)

Outlook signs in through the Microsoft identity platform via AppAuth (Authorization Code +
PKCE, no secret). One consent requests the outlook.office.com IMAP and SMTP scopes; because
they share a single resource, the resulting access token authenticates both IMAP receive and
SMTP send over XOAUTH2 — reusing the existing ImapClient and SmtpSender, with no Graph call or
second token. The "common" tenant covers personal and work/school accounts.

- OutlookAuthManager (mirrors GmailAuthManager) + AuthType.OAUTH_OUTLOOK + Account.outlook()
  with the unified outlook.office365.com / smtp.office365.com endpoints.
- MailConnectionFactory refreshes either OAuth provider's token; XOAUTH2 now applies to any
  non-password account. AccountRepository.addOutlookAccount verifies via IMAP, then persists.
- "Sign in with Microsoft" on the account-setup screen; the manifest registers the
  org.libremail.outlook:// redirect. The client id ships in the build, overridable via
  secrets.properties (OUTLOOK_OAUTH_CLIENT_ID); README documents the Azure app registration.
- Verified: assemble/lint/test green; on the emulator the button launches AppAuth and
  Microsoft renders its live sign-in page (client id, redirect, and scopes all accepted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 18:20:41 -05:00
co-authored by Claude Opus 4.8
parent 23afe78fe0
commit 1f63773faa
11 changed files with 247 additions and 11 deletions
+20 -2
View File
@@ -18,11 +18,12 @@ experience with power-user features tucked under an **Advanced Settings** group.
> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a
> 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). Outlook Graph send is upcoming.
> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft**
> accounts (OAuth 2.0 sign-in, IMAP receive + SMTP send over XOAUTH2).
## Features (target MVP)
- Send and receive email with **Gmail** (OAuth 2.0) and **any IMAP/SMTP** provider.
- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider.
- Material You dynamic theming, light/dark, edge-to-edge.
- Clean compose screen with phone/account contacts integration.
- Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail.
@@ -88,6 +89,23 @@ assessment for the restricted scope.
5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set
`GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`.
## 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:
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).
4. Copy the **Application (client) ID** into `secrets.properties` as
`OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default).
## Architecture
Offline-first, unidirectional, layered:
+9
View File
@@ -27,6 +27,13 @@ val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleu
"org.libremail.oauth"
}
// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via
// secrets.properties; defaults to the app's registered client id.
val outlookOAuthClientId: String = secrets.getProperty(
"OUTLOOK_OAUTH_CLIENT_ID",
"04e4aa5e-ed1f-47f9-b567-b99a0b29b3df",
)
android {
namespace = "org.libremail"
compileSdk = 37
@@ -42,6 +49,8 @@ android {
buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"")
buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"")
buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"")
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"")
// AppAuth captures the OAuth redirect via this custom scheme.
manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme
}
+16
View File
@@ -58,5 +58,21 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
(mobile/desktop) redirect in the Azure app registration. -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="merge">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="org.libremail.outlook" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
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).
*/
@Singleton
class OutlookAuthManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"),
Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/token"),
)
/** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */
val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank()
fun createAuthIntent(): Intent {
val request = AuthorizationRequest.Builder(
serviceConfig,
BuildConfig.OUTLOOK_OAUTH_CLIENT_ID,
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",
)
.build()
return AuthorizationService(context).getAuthorizationRequestIntent(request)
}
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
val response = AuthorizationResponse.fromIntent(responseIntent)
val exception = AuthorizationException.fromIntent(responseIntent)
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
val service = AuthorizationService(context)
try {
val tokenResponse = suspendCancellableCoroutine { continuation ->
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed"))
}
}
}
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")
return OAuthResult(
email = email,
accessToken = tokenResponse.accessToken.orEmpty(),
authStateJson = authState.jsonSerializeString(),
)
} 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)
val service = AuthorizationService(context)
try {
val accessToken = suspendCancellableCoroutine { continuation ->
authState.performActionWithFreshTokens(service) { token, _, error ->
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed"))
}
}
}
return FreshToken(accessToken = accessToken, authStateJson = authState.jsonSerializeString())
} finally {
service.dispose()
}
}
/** Microsoft id tokens carry the address in `email`, falling back to `preferred_username`. */
private fun emailFromIdToken(idToken: String?): String? {
if (idToken.isNullOrBlank()) return null
return runCatching {
val payload = idToken.split(".").getOrNull(1) ?: return null
val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
val claims = JSONObject(json)
claims.optString("email").ifBlank { claims.optString("preferred_username") }.ifBlank { null }
}.getOrNull()
}
}
@@ -55,6 +55,19 @@ class AccountRepositoryImpl @Inject constructor(
folders
}
override suspend fun addOutlookAccount(
email: String,
accessToken: String,
authStateJson: String,
): Result<List<String>> = runCatching {
val account = Account.outlook(email)
val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true))
accountDao.upsert(account.toEntity())
credentialStore.saveSecret(account.id, authStateJson)
syncScheduler.syncNow()
folders
}
override suspend fun deleteAccount(id: String) {
accountDao.deleteById(id)
credentialStore.delete(id)
@@ -3,7 +3,9 @@ package org.libremail.data.sync
import javax.inject.Inject
import javax.inject.Singleton
import org.libremail.auth.FreshToken
import org.libremail.auth.GmailAuthManager
import org.libremail.auth.OutlookAuthManager
import org.libremail.data.local.toImapParams
import org.libremail.data.local.toSmtpParams
import org.libremail.data.security.CredentialStore
@@ -17,25 +19,32 @@ import org.libremail.domain.model.SmtpParams
class MailConnectionFactory @Inject constructor(
private val credentialStore: CredentialStore,
private val gmailAuthManager: GmailAuthManager,
private val outlookAuthManager: OutlookAuthManager,
) {
suspend fun imapParamsFor(account: Account): ImapConnectionParams =
account.toImapParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL)
account.toImapParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP)
suspend fun smtpParamsFor(account: Account): SmtpParams =
account.toSmtpParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL)
account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP)
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 -> {
val fresh = gmailAuthManager.freshAccessToken(stored)
if (fresh.authStateJson != stored) {
credentialStore.saveSecret(account.id, fresh.authStateJson)
}
fresh.accessToken
}
AuthType.OAUTH_GMAIL -> refreshedToken(account.id, stored, gmailAuthManager::freshAccessToken)
AuthType.OAUTH_OUTLOOK -> refreshedToken(account.id, stored, outlookAuthManager::freshAccessToken)
}
}
/** Refreshes an OAuth access token, persisting the updated AuthState when it changes. */
private suspend fun refreshedToken(
accountId: String,
stored: String,
refresh: suspend (String) -> FreshToken,
): String {
val fresh = refresh(stored)
if (fresh.authStateJson != stored) credentialStore.saveSecret(accountId, fresh.authStateJson)
return fresh.accessToken
}
}
@@ -6,6 +6,9 @@ enum class AuthType {
/** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
OAUTH_GMAIL,
/** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
OAUTH_OUTLOOK,
/** Generic IMAP/SMTP with a password or app-password. */
PASSWORD_IMAP,
}
@@ -28,5 +31,15 @@ data class Account(
imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.gmail.com", 465, MailSecurity.SSL_TLS),
)
/** An Outlook/Microsoft account using the unified office365 endpoints (personal + M365). */
fun outlook(email: String, displayName: String = email): Account = Account(
id = "outlook:$email",
email = email,
displayName = displayName.ifBlank { email },
authType = AuthType.OAUTH_OUTLOOK,
imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.office365.com", 587, MailSecurity.STARTTLS),
)
}
}
@@ -18,5 +18,8 @@ interface AccountRepository {
/** Verify (via XOAUTH2), then persist, a Gmail account. Returns the folders found. */
suspend fun addGmailAccount(email: String, accessToken: String, authStateJson: String): Result<List<String>>
/** Verify (via XOAUTH2), then persist, an Outlook account. Returns the folders found. */
suspend fun addOutlookAccount(email: String, accessToken: String, authStateJson: String): Result<List<String>>
suspend fun deleteAccount(id: String)
}
@@ -60,6 +60,10 @@ fun AccountSetupScreen(
ActivityResultContracts.StartActivityForResult(),
) { result -> viewModel.onGmailResult(result.data) }
val outlookLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result -> viewModel.onOutlookResult(result.data) }
LaunchedEffect(state.status) {
if (state.status == SetupStatus.DONE) onAccountAdded()
}
@@ -118,6 +122,14 @@ fun AccountSetupScreen(
Text(stringResource(R.string.account_setup_gmail))
}
Spacer(Modifier.height(12.dp))
Button(
onClick = { outlookLauncher.launch(viewModel.outlookAuthIntent()) },
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.account_setup_outlook))
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onManualSetup,
enabled = !busy,
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.auth.GmailAuthManager
import org.libremail.auth.OutlookAuthManager
import org.libremail.domain.repository.AccountRepository
/** Stage of an account-setup attempt, shared by the Gmail and manual flows. */
@@ -25,6 +26,7 @@ data class AccountSetupUiState(
@HiltViewModel
class AccountSetupViewModel @Inject constructor(
private val authManager: GmailAuthManager,
private val outlookAuthManager: OutlookAuthManager,
private val accountRepository: AccountRepository,
) : ViewModel() {
@@ -52,5 +54,26 @@ class AccountSetupViewModel @Inject constructor(
}
}
val isOutlookConfigured: Boolean get() = outlookAuthManager.isConfigured
fun outlookAuthIntent(): Intent = outlookAuthManager.createAuthIntent()
fun onOutlookResult(data: Intent?) {
if (data == null) {
_state.update { it.copy(error = "Microsoft sign-in was cancelled") }
return
}
viewModelScope.launch {
_state.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
runCatching {
val oauth = outlookAuthManager.exchangeToken(data)
accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow()
}.fold(
onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } },
onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } },
)
}
}
fun consumeError() = _state.update { it.copy(error = null) }
}
+1
View File
@@ -68,6 +68,7 @@
<!-- Account setup -->
<string name="account_setup_gmail">Sign in with Google</string>
<string name="account_setup_outlook">Sign in with Microsoft</string>
<string name="account_setup_other">Other (IMAP/SMTP)</string>
<string name="account_setup_subtitle">Choose how you want to connect your mailbox.</string>
<string name="account_gmail_needs_client_id">Add your Google OAuth client ID first — see the README</string>