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