diff --git a/README.md b/README.md
index 66acc5e..fef90b7 100644
--- a/README.md
+++ b/README.md
@@ -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:
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e862077..ece7c90 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6f69c0a..958ae71 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -58,5 +58,21 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt
new file mode 100644
index 0000000..ebf8ad1
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt
index 2f50cca..48d03d4 100644
--- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt
+++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt
@@ -55,6 +55,19 @@ class AccountRepositoryImpl @Inject constructor(
folders
}
+ override suspend fun addOutlookAccount(
+ email: String,
+ accessToken: String,
+ authStateJson: String,
+ ): Result> = 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)
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 e7bd652..75e1150 100644
--- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt
+++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt
@@ -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
+ }
}
diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt
index f731bf0..e68ebde 100644
--- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt
+++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt
@@ -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),
+ )
}
}
diff --git a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt
index 847fbaf..3967431 100644
--- a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt
+++ b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt
@@ -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>
+ /** Verify (via XOAUTH2), then persist, an Outlook account. Returns the folders found. */
+ suspend fun addOutlookAccount(email: String, accessToken: String, authStateJson: String): Result>
+
suspend fun deleteAccount(id: String)
}
diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt
index 5befb0f..b060378 100644
--- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt
@@ -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,
diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt
index 8eb2b5b..0248f57 100644
--- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt
@@ -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) }
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e8cf5cf..43208d0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -68,6 +68,7 @@
Sign in with Google
+ Sign in with Microsoft
Other (IMAP/SMTP)
Choose how you want to connect your mailbox.
Add your Google OAuth client ID first — see the README