diff --git a/doc/oidc-xoauth2.md b/doc/oidc-xoauth2.md
new file mode 100644
index 0000000000..03496b6f7d
--- /dev/null
+++ b/doc/oidc-xoauth2.md
@@ -0,0 +1,438 @@
+
+
+# Provisioning with OIDC access tokens (XOAUTH2)
+
+Minimal end-to-end setup for the `oidcEnabled` provisioning option: Nextcloud Mail
+authenticates to IMAP/SMTP with the logged-in user's OIDC access token instead of a
+stored password. Written for the
+[nextcloud-docker-dev](https://github.com/nextcloud/nextcloud-docker-dev) environment
+(Authentik at `http://authentik.local`, Nextcloud at `http://nextcloud.local`), but the
+config translates 1:1 to any Authentik + Dovecot 2.4 + Postfix deployment.
+
+This flow has been verified end to end on a fully local docker-dev stack (Authentik
+2026.5.5 + Dovecot 2.4.4 + Postfix): SSO login → account auto-provisioned → IMAP sync
+and message send over XOAUTH2 → background sync after the access token expired
+(refresh-token path, no session).
+
+## How it works
+
+```
+Browser ──SSO──> Authentik ──token──> user_oidc (session)
+ │ mirrored on Mail requests,
+ │ refreshed via refresh token in cron
+ ▼
+ oc_mail_accounts (encrypted)
+ │ XOAUTH2 (user=, auth=Bearer )
+ ┌───────────┴───────────┐
+ ▼ ▼
+ Dovecot :993 Postfix :587 ──SASL──> Dovecot
+ │ (introspects the token
+ ▼ with its own client creds)
+ Authentik /introspect/
+```
+
+Requirements: the access token must carry an `email` claim, Dovecot keys mailboxes on
+that claim, and the XOAUTH2 `user=` must equal it — Mail sends the account email, so the
+provisioning email template must produce the same address the IdP puts in the token.
+
+## 1. Authentik
+
+Log in as `akadmin` and create **two OAuth2/OpenID providers**:
+
+**Provider "Nextcloud"** (drives the SSO login):
+
+- Client type: *Confidential*, Client ID: `nextcloud`
+- Redirect URIs (strict):
+ - `http://nextcloud.local/index.php/apps/user_oidc/code`
+ - `http://nextcloud.local/apps/user_oidc/code`
+ - type *logout*: `http://nextcloud.local/` (for RP-initiated logout)
+- Signing key: select the self-signed certificate (**RS256** — user_oidc rejects the
+ HS256 default)
+- Scopes: `openid`, `email`, `profile`, `offline_access` (offline_access ⇒ refresh
+ tokens ⇒ background sync keeps working after the access token expires)
+- Invalidation flow: `default-invalidation-flow` if logging out of Nextcloud should end
+ the whole SSO session
+
+**Provider "Dovecot"** (introspection credential only — no login, no redirect URIs):
+
+- Client type: *Confidential*, Client ID: `dovecot`, note the client secret
+
+**Federation** (the critical, easy-to-miss step): edit the **Nextcloud** provider →
+*Federated OIDC Providers* → add **Dovecot**. Direction matters: the *issuing* provider
+lists the *introspecting* one. Reversed, every introspection returns `{"active": false}`.
+
+> **Authentik version**: cross-provider introspection requires Authentik ≥ 2026.x —
+> verified working on 2026.5.5. On 2025.10.x the identical configuration answers every
+> federated introspection with `{"active": false}` (docker-dev pins 2025.10 by default;
+> set `AUTHENTIK_TAG=2026.5.5` in `.env`).
+
+Create an **application** for the Nextcloud provider (slug `nextcloud`) and a test user
+whose **email domain matches your mail domain**.
+
+## 2. Dovecot (2.4)
+
+```
+# dovecot.conf (fragments)
+auth_mechanisms {
+ plain = yes # optional, for non-OIDC clients
+ login = yes
+ oauthbearer = yes
+ xoauth2 = yes
+}
+
+passdb oauth2 {
+}
+oauth2 {
+ # client id/secret of the *Dovecot* provider, embedded in the URL
+ introspection_url = https://dovecot:@authentik.local/application/o/introspect/
+ introspection_mode = post
+ username_attribute = email # mailbox = the token's email claim
+ active_attribute = active
+ active_value = true
+}
+
+mail_home = /var/vmail/%{user | domain}/%{user | username}
+mail_driver = maildir
+mail_path = ~/mail
+
+# Auto-create the folders Mail needs — it refuses to send without a Sent mailbox
+namespace inbox { inbox = yes separator = / }
+mailbox Drafts { special_use = \Drafts auto = subscribe }
+mailbox Sent { special_use = \Sent auto = subscribe }
+mailbox Trash { special_use = \Trash auto = subscribe }
+mailbox Spam { special_use = \Junk auto = subscribe }
+
+# SASL service for Postfix submission
+service auth {
+ inet_listener auth { port = 12 }
+}
+```
+
+You also need a userdb (static or LDAP) resolving the email to uid/gid/home. With a
+static userdb, set `allow_all_users = yes` — otherwise LMTP delivery fails its userdb
+lookup, because the oauth2 passdb cannot answer lookups without a token:
+
+```
+userdb static {
+ allow_all_users = yes
+ fields {
+ uid = 1000
+ gid = 1000
+ home = /var/vmail/%{user | domain}/%{user | username}
+ }
+}
+```
+
+On the minimal `dovecot/dovecot` image, also set `default_login_user`,
+`default_internal_user` and `default_internal_group` to `vmail` (the image has no
+`dovenull` user), and make sure the mail volume is writable by that uid.
+
+A complete, ready-to-run local pair (Dovecot 2.4.4 + Postfix on docker compose, joining
+the docker-dev network) is given in the [appendix](#appendix-ready-to-run-local-pair).
+
+## 3. Postfix (submission :587)
+
+```
+# master.cf
+submission inet n - n - - smtpd
+ -o smtpd_tls_security_level=encrypt
+ -o smtpd_sasl_auth_enable=yes
+ -o smtpd_client_restrictions=permit_sasl_authenticated,reject
+ -o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch
+ # Authentik RS256 tokens make the XOAUTH2 SASL blob ~2.4 KB — over the 2048
+ # default line limit. Without this every AUTH is truncated ("invalid base64").
+ -o line_length_limit=8192
+```
+
+```
+# main.cf
+smtpd_sasl_type = dovecot
+smtpd_sasl_path = inet::12
+smtpd_sasl_security_options = noanonymous
+# Dovecot returns the email as SASL username; senders may only use their own address
+smtpd_sender_login_maps = regexp:/etc/postfix/sender_login_maps.regexp
+```
+
+```
+# /etc/postfix/sender_login_maps.regexp — identity map: login == allowed sender
+/^(.+)$/ ${1}
+```
+
+## 4. Nextcloud
+
+```sh
+occ app:enable user_oidc
+occ user_oidc:provider Authentik \
+ --clientid=nextcloud \
+ --clientsecret= \
+ --discoveryuri=https://authentik.local/application/o/nextcloud/.well-known/openid-configuration \
+ --scope="openid email profile offline_access" \
+ --unique-uid=0 --mapping-uid=preferred_username \
+ --send-id-token-hint=1
+# Keep the login token so Mail can mirror it (and single logout works)
+occ config:app:set user_oidc store_login_token --value=1
+
+# The first XOAUTH2 login triggers a *cold* token introspection at the IdP, which can
+# exceed Mail's 5s default IMAP timeout (symptom: spurious "denied authentication"
+# followed by a rate-limit lockout).
+occ config:system:set app.mail.imap.timeout --value=20 --type=integer
+```
+
+Then in **Admin settings → Groupware → Mail app**, add a provisioning configuration:
+
+| Setting | Value |
+|---|---|
+| Provisioning domain | your mail domain (or `*`) |
+| Email template | `%EMAIL%` |
+| IMAP user / SMTP user | `%EMAIL%` |
+| IMAP host/port | your Dovecot, `993` / SSL — **the host must match the TLS cert SAN** |
+| SMTP host/port | your Postfix, `587` / STARTTLS |
+| OpenID Connect | ✅ *Use the user's OIDC access token (XOAUTH2)* |
+
+## 5. Verify
+
+1. Log in to Nextcloud via SSO as the test user, open Mail → the account appears and
+ syncs without ever asking for a password.
+2. Send a mail to yourself → lands in INBOX, copy in Sent.
+3. Background path: force-expire the token and sync without a session —
+ ```sh
+ # UPDATE oc_mail_accounts SET oauth_token_ttl = 1 WHERE id = ;
+ occ mail:account:sync
+ ```
+ The sync must succeed and `oauth_token_ttl` move into the future (refreshed via the
+ stored refresh token against the IdP token endpoint).
+
+## Troubleshooting
+
+- **"Mail server denied authentication" on the first attempt, works later** — cold
+ introspection vs. the IMAP timeout, see `app.mail.imap.timeout` above. After 3
+ failures Mail's rate limiter blocks the bucket for up to 3 h (`mail_imap_ratelimit`
+ keys in the distributed cache).
+- **Every login fails, Dovecot logs show introspection `active: false`** — the
+ federation is missing or reversed (see step 1).
+- **SMTP `535 invalid base64`** — `line_length_limit` not raised on the submission port.
+- **SMTP `553 not owned by user`** — sender ≠ SASL login; aliases need entries in
+ `smtpd_sender_login_maps`.
+- **`occ mail:account:diagnose`** and `occ config:system:set app.mail.debug --value=true
+ --type=boolean` (protocol log in `data/mail---imap.log`) are your friends.
+
+## Appendix: ready-to-run local pair
+
+A self-contained Dovecot + Postfix pair for the docker-dev environment. It joins the
+docker-dev docker network so the containers resolve `authentik.local` / `nextcloud.local`,
+and exposes IMAP on `127.0.0.1:1143` and submission on `127.0.0.1:1587` for host-side
+testing. No TLS — this is a local test rig, not a deployment.
+
+### 0. Bump docker-dev's Authentik
+
+docker-dev pins an Authentik version that predates federated introspection. In the
+docker-dev repo root, set the tag in `.env` and recreate:
+
+```sh
+echo 'AUTHENTIK_TAG=2026.5.5' >> .env
+docker compose up -d authentik authentik-worker
+```
+
+Then create the two providers, federation, application and test user from step 1 (the
+Authentik admin UI, or its API).
+
+### 1. Create the directory
+
+All paths below are relative to `data/oidc-mail/` in the docker-dev checkout (the `data/`
+dir is bind-mounted, so anything here is reachable from the host):
+
+```sh
+mkdir -p data/oidc-mail/dovecot data/oidc-mail/postfix
+cd data/oidc-mail
+```
+
+### 2. `docker-compose.yml`
+
+```yaml
+services:
+ dovecot:
+ image: dovecot/dovecot:2.4.4
+ ports:
+ - "127.0.0.1:1143:143"
+ volumes:
+ # rendered from dovecot.conf.template (secret substituted on the host)
+ - ./dovecot/dovecot.conf:/etc/dovecot/dovecot.conf:ro
+ - vmail:/var/vmail
+ networks:
+ default:
+ aliases: [dovecot.local]
+
+ postfix:
+ build: ./postfix
+ ports:
+ - "127.0.0.1:1587:587"
+ depends_on: [dovecot]
+ networks:
+ default:
+ aliases: [postfix.local]
+
+volumes:
+ vmail:
+
+networks:
+ default:
+ name: master_default # the docker-dev network
+ external: true
+```
+
+### 3. `dovecot/dovecot.conf.template`
+
+The Dovecot image is minimal (no `sed`), so the introspection client secret is
+substituted **on the host** and the rendered file is mounted (see step 6).
+
+```
+dovecot_config_version = 2.4.0
+dovecot_storage_version = 2.4.0
+
+log_path = /dev/stderr
+auth_verbose = yes
+protocols = imap lmtp
+
+# The image has no dovenull user
+default_login_user = vmail
+default_internal_user = vmail
+default_internal_group = vmail
+
+# Local test rig: no TLS
+ssl = no
+auth_allow_cleartext = yes
+
+auth_mechanisms {
+ plain = yes
+ login = yes
+ oauthbearer = yes
+ xoauth2 = yes
+}
+
+passdb oauth2 {
+}
+oauth2 {
+ introspection_url = http://dovecot:__DOVECOT_CLIENT_SECRET__@authentik.local/application/o/introspect/
+ introspection_mode = post
+ username_attribute = email
+ active_attribute = active
+ active_value = true
+}
+
+userdb static {
+ allow_all_users = yes
+ fields {
+ uid = 1000
+ gid = 1000
+ home = /var/vmail/%{user | domain}/%{user | username}
+ }
+}
+
+mail_driver = maildir
+mail_home = /var/vmail/%{user | domain}/%{user | username}
+mail_path = ~/mail
+mailbox_list_index = yes
+
+namespace inbox {
+ inbox = yes
+ separator = /
+}
+
+mailbox Drafts { special_use = \Drafts auto = subscribe }
+mailbox Sent { special_use = \Sent auto = subscribe }
+mailbox Trash { special_use = \Trash auto = subscribe }
+mailbox Spam { special_use = \Junk auto = subscribe }
+
+service imap-login { inet_listener imap { port = 143 } }
+service lmtp { inet_listener lmtp { port = 24 } } # delivery from Postfix
+service auth { inet_listener auth { port = 12 } } # SASL for Postfix submission
+```
+
+### 4. `postfix/` files
+
+`postfix/Dockerfile`:
+
+```dockerfile
+FROM alpine:3.22
+RUN apk add --no-cache postfix
+COPY main.cf /etc/postfix/main.cf
+COPY sender_login_maps.regexp /etc/postfix/sender_login_maps.regexp
+COPY submission.cf /tmp/submission.cf
+RUN cat /tmp/submission.cf >> /etc/postfix/master.cf && rm /tmp/submission.cf
+CMD ["postfix", "start-fg"]
+```
+
+`postfix/main.cf`:
+
+```
+compatibility_level = 3.6
+maillog_file = /dev/stdout
+myhostname = postfix.local
+mydomain = example.local
+myorigin = $mydomain
+mydestination =
+
+virtual_mailbox_domains = example.local
+virtual_transport = lmtp:inet:dovecot:24
+
+smtpd_sasl_type = dovecot
+smtpd_sasl_path = inet:dovecot:12
+smtpd_sasl_security_options = noanonymous
+smtpd_sender_login_maps = regexp:/etc/postfix/sender_login_maps.regexp
+
+smtpd_relay_restrictions = permit_sasl_authenticated, reject_unauth_destination
+smtpd_recipient_restrictions = permit_sasl_authenticated, reject_unauth_destination
+```
+
+`postfix/submission.cf` (appended to `master.cf` at build time):
+
+```
+submission inet n - n - - smtpd
+ -o syslog_name=postfix/submission
+ -o smtpd_tls_security_level=none
+ -o smtpd_sasl_auth_enable=yes
+ -o smtpd_client_restrictions=permit_sasl_authenticated,reject
+ -o smtpd_sender_restrictions=reject_authenticated_sender_login_mismatch
+ -o line_length_limit=8192
+```
+
+`postfix/sender_login_maps.regexp`:
+
+```
+/^(.+)$/ ${1}
+```
+
+### 5. Render the Dovecot config with the introspection secret
+
+```sh
+# is the client secret of the "Dovecot" Authentik provider
+sed "s|__DOVECOT_CLIENT_SECRET__||" \
+ dovecot/dovecot.conf.template > dovecot/dovecot.conf
+```
+
+### 6. Prepare the mail volume and start
+
+The image's `vmail` user is uid 1000; the named volume must be writable by it:
+
+```sh
+docker compose up -d # creates the vmail volume
+docker run --rm -v oidc-mail_vmail:/var/vmail alpine:3.22 chown 1000:1000 /var/vmail
+docker compose up -d --build # (re)build postfix and start both
+```
+
+Check the logs — Dovecot should report `starting up for imap, lmtp` with no fatal
+errors:
+
+```sh
+docker compose logs -f dovecot postfix
+```
+
+### 7. Point Mail at it
+
+Use these values in step 4's `occ` command and the provisioning config: IMAP host
+`dovecot.local` port `143` SSL *none*, SMTP host `postfix.local` port `587` SSL *none*,
+provisioning domain `example.local` (matching the test user's email). Reaching the pair
+from the **host** instead (e.g. an IMAP client) uses `127.0.0.1:1143` / `127.0.0.1:1587`.
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 71b50051c4..30b02f4994 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -24,6 +24,7 @@
use OCA\Mail\Dashboard\ImportantMailWidget;
use OCA\Mail\Dashboard\UnreadMailWidget;
use OCA\Mail\Events\BeforeImapClientCreated;
+use OCA\Mail\Events\BeforeSmtpClientCreated;
use OCA\Mail\Events\DraftMessageCreatedEvent;
use OCA\Mail\Events\DraftSavedEvent;
use OCA\Mail\Events\MailboxesSynchronizedEvent;
@@ -130,6 +131,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(AddMissingIndicesEvent::class, OptionalIndicesListener::class);
$context->registerEventListener(BeforeImapClientCreated::class, OauthTokenRefreshListener::class);
+ $context->registerEventListener(BeforeSmtpClientCreated::class, OauthTokenRefreshListener::class);
$context->registerEventListener(DraftSavedEvent::class, DeleteDraftListener::class);
$context->registerEventListener(DraftMessageCreatedEvent::class, DeleteDraftListener::class);
$context->registerEventListener(OutboxMessageCreatedEvent::class, DeleteDraftListener::class);
diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php
index f3e8b900cb..4ded9d43cb 100644
--- a/lib/Db/MailAccount.php
+++ b/lib/Db/MailAccount.php
@@ -84,9 +84,9 @@
* @method int getSignatureMode()
* @method void setSignatureMode(int $signatureMode)
* @method string|null getOauthAccessToken()
- * @method void setOauthAccessToken(string $token)
+ * @method void setOauthAccessToken(?string $token)
* @method string|null getOauthRefreshToken()
- * @method void setOauthRefreshToken(string $token)
+ * @method void setOauthRefreshToken(?string $token)
* @method int|null getOauthTokenTtl()
* @method void setOauthTokenTtl(int|null $ttl)
* @method int|null getSmimeCertificateId()
diff --git a/lib/Db/Provisioning.php b/lib/Db/Provisioning.php
index 31e0a22081..62a815f582 100644
--- a/lib/Db/Provisioning.php
+++ b/lib/Db/Provisioning.php
@@ -39,6 +39,8 @@
* @method void setMasterPasswordEnabled(bool $masterPasswordEnabled)
* @method string|null getMasterPassword()
* @method void setMasterPassword(string $masterPassword)
+ * @method bool|null getOidcEnabled()
+ * @method void setOidcEnabled(bool $oidcEnabled)
* @method bool|null getSieveEnabled()
* @method void setSieveEnabled(bool $sieveEnabled)
* @method string|null getSieveHost()
@@ -72,6 +74,7 @@ class Provisioning extends Entity implements JsonSerializable {
protected $smtpSslMode;
protected $masterPasswordEnabled;
protected $masterPassword;
+ protected $oidcEnabled;
protected $sieveEnabled;
protected $sieveUser;
protected $sieveHost;
@@ -86,6 +89,7 @@ public function __construct() {
$this->addType('smtpPort', 'integer');
$this->addType('masterPasswordEnabled', 'boolean');
$this->addType('masterPassword', 'string');
+ $this->addType('oidcEnabled', 'boolean');
$this->addType('sieveEnabled', 'boolean');
$this->addType('sievePort', 'integer');
$this->addType('ldapAliasesProvisioning', 'boolean');
@@ -108,6 +112,7 @@ public function jsonSerialize() {
'smtpSslMode' => $this->getSmtpSslMode(),
'masterPasswordEnabled' => $this->getMasterPasswordEnabled(),
'masterPassword' => !empty($this->getMasterPassword()) ? self::MASTER_PASSWORD_PLACEHOLDER : null,
+ 'oidcEnabled' => $this->getOidcEnabled(),
'sieveEnabled' => $this->getSieveEnabled(),
'sieveUser' => $this->getSieveUser(),
'sieveHost' => $this->getSieveHost(),
diff --git a/lib/Db/ProvisioningMapper.php b/lib/Db/ProvisioningMapper.php
index deb46d7263..afe55473a6 100644
--- a/lib/Db/ProvisioningMapper.php
+++ b/lib/Db/ProvisioningMapper.php
@@ -113,6 +113,8 @@ public function validate(array $data): Provisioning {
$provisioning->setMasterPassword($data['masterPassword']);
}
+ $provisioning->setOidcEnabled((bool)($data['oidcEnabled'] ?? false));
+
$provisioning->setSieveEnabled((bool)$data['sieveEnabled']);
$provisioning->setSieveHost($data['sieveHost'] ?? '');
$provisioning->setSieveUser($data['sieveUser'] ?? '');
diff --git a/lib/Events/BeforeSmtpClientCreated.php b/lib/Events/BeforeSmtpClientCreated.php
new file mode 100644
index 0000000000..a6cf07ff62
--- /dev/null
+++ b/lib/Events/BeforeSmtpClientCreated.php
@@ -0,0 +1,25 @@
+account;
+ }
+}
diff --git a/lib/Http/Middleware/ProvisioningMiddleware.php b/lib/Http/Middleware/ProvisioningMiddleware.php
index 7e5591a049..578b963c16 100644
--- a/lib/Http/Middleware/ProvisioningMiddleware.php
+++ b/lib/Http/Middleware/ProvisioningMiddleware.php
@@ -45,6 +45,8 @@ public function beforeController($controller, $methodName) {
}
try {
$this->provisioningManager->provisionSingleUser($configs, $user);
+ // Keep the mirrored OIDC token fresh (no-op for password-based configs)
+ $this->provisioningManager->updateOidcToken($user, $configs);
$password = $this->credentialStore->getLoginCredentials()->getPassword();
$this->provisioningManager->updatePassword(
$user,
diff --git a/lib/Integration/OidcIntegration.php b/lib/Integration/OidcIntegration.php
new file mode 100644
index 0000000000..d29e7a6bf2
--- /dev/null
+++ b/lib/Integration/OidcIntegration.php
@@ -0,0 +1,270 @@
+ */
+ private array $tokenEndpoints = [];
+
+ public function __construct(
+ private ITimeFactory $timeFactory,
+ private ICrypto $crypto,
+ private IClientService $clientService,
+ private IEventDispatcher $eventDispatcher,
+ private IAppManager $appManager,
+ private IUserSession $userSession,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * Whether accounts can be provisioned with OIDC token authentication, i.e. the
+ * user_oidc app is installed, enabled and exposes the token API this integration
+ * builds on.
+ */
+ public function isAvailable(): bool {
+ return $this->appManager->isEnabledForAnyone('user_oidc')
+ && class_exists(\OCA\UserOIDC\Event\ExternalTokenRequestedEvent::class);
+ }
+
+ public function isOidcAccount(Account $account): bool {
+ // Only provisioned accounts use the OIDC token. Google/Microsoft xoauth2 accounts
+ // are linked by the user and never carry a provisioning id.
+ return $account->getMailAccount()->getProvisioningId() !== null
+ && $account->getMailAccount()->getAuthMethod() === 'xoauth2';
+ }
+
+ /**
+ * Mirror the current user_oidc session token into the account row if it is fresher
+ * than what is stored. No-op without an active user session holding a token.
+ */
+ public function updateFromSession(Account $account): Account {
+ if (!class_exists(\OCA\UserOIDC\Event\ExternalTokenRequestedEvent::class)) {
+ return $account;
+ }
+
+ // The session token belongs to the logged-in user. Never mirror it into someone
+ // else's account, e.g. when acting on a delegated account.
+ $sessionUser = $this->userSession->getUser();
+ if ($sessionUser === null || $sessionUser->getUID() !== $account->getMailAccount()->getUserId()) {
+ return $account;
+ }
+
+ try {
+ $event = new \OCA\UserOIDC\Event\ExternalTokenRequestedEvent();
+ $this->eventDispatcher->dispatchTyped($event);
+ $token = $event->getToken();
+ } catch (\Throwable $e) {
+ $this->logger->debug('Could not get OIDC token from user_oidc session: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return $account;
+ }
+
+ if ($token === null || $token->isExpired()) {
+ return $account;
+ }
+
+ $expiresAt = $this->timeFactory->getTime() + $token->getExpiresInFromNow();
+ $storedTtl = $account->getMailAccount()->getOauthTokenTtl();
+ if ($storedTtl !== null && $expiresAt <= $storedTtl + 60 && $account->getMailAccount()->getOauthRefreshToken() !== null) {
+ // Stored token is as fresh as the session token
+ return $account;
+ }
+
+ $account->getMailAccount()->setOauthAccessToken($this->crypto->encrypt($token->getAccessToken()));
+ $refreshToken = $token->getRefreshToken();
+ if ($refreshToken !== null) {
+ $account->getMailAccount()->setOauthRefreshToken($this->crypto->encrypt($refreshToken));
+ }
+ $account->getMailAccount()->setOauthTokenTtl($expiresAt);
+
+ $this->logger->debug('Mirrored OIDC session token to mail account {accountId}', [
+ 'accountId' => $account->getId(),
+ ]);
+ return $account;
+ }
+
+ /**
+ * Make sure the stored access token is valid, renewing it from the user session or
+ * with the stored refresh token (background jobs) if it is expired or expiring.
+ */
+ public function refresh(Account $account): Account {
+ // Only refresh if the token expires in the next minute
+ $ttl = $account->getMailAccount()->getOauthTokenTtl();
+ if ($ttl !== null && $this->timeFactory->getTime() <= ($ttl - 60)) {
+ return $account;
+ }
+
+ // Prefer the live session token on interactive requests
+ $account = $this->updateFromSession($account);
+ $ttl = $account->getMailAccount()->getOauthTokenTtl();
+ if ($ttl !== null && $this->timeFactory->getTime() <= ($ttl - 60)) {
+ return $account;
+ }
+
+ $encryptedRefreshToken = $account->getMailAccount()->getOauthRefreshToken();
+ if ($encryptedRefreshToken === null) {
+ // Account has not been seeded with a token yet
+ return $account;
+ }
+
+ $providerConfig = $this->getProviderConfig();
+ if ($providerConfig === null) {
+ return $account;
+ }
+ [$tokenEndpoint, $clientId, $clientSecret] = $providerConfig;
+
+ try {
+ $refreshToken = $this->crypto->decrypt($encryptedRefreshToken);
+ } catch (Exception $e) {
+ $this->logger->warning('Could not decrypt OIDC refresh token for account {accountId}: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'accountId' => $account->getId(),
+ ]);
+ return $account;
+ }
+
+ $httpClient = $this->clientService->newClient();
+ try {
+ $response = $httpClient->post($tokenEndpoint, [
+ 'body' => [
+ 'client_id' => $clientId,
+ 'client_secret' => $clientSecret,
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $refreshToken,
+ ],
+ ]);
+ } catch (Exception $e) {
+ $this->logger->warning('Could not refresh OIDC token for account {accountId}: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'accountId' => $account->getId(),
+ ]);
+ return $account;
+ }
+
+ $data = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ $account->getMailAccount()->setOauthAccessToken($this->crypto->encrypt($data['access_token']));
+ if (isset($data['refresh_token'])) {
+ // The IdP may rotate the refresh token
+ $account->getMailAccount()->setOauthRefreshToken($this->crypto->encrypt($data['refresh_token']));
+ }
+ $account->getMailAccount()->setOauthTokenTtl($this->timeFactory->getTime() + $data['expires_in']);
+
+ return $account;
+ }
+
+ /**
+ * Read the token endpoint and client credentials from the user_oidc provider.
+ *
+ * @return array{0: string, 1: string, 2: string}|null [token endpoint, client id, client secret]
+ */
+ private function getProviderConfig(): ?array {
+ if (!class_exists(\OCA\UserOIDC\Db\ProviderMapper::class)) {
+ $this->logger->debug('Cannot refresh OIDC mail token, user_oidc is not installed');
+ return null;
+ }
+
+ try {
+ $providerMapper = \OCP\Server::get(\OCA\UserOIDC\Db\ProviderMapper::class);
+ $providers = $providerMapper->getProviders();
+ } catch (\Throwable $e) {
+ $this->logger->warning('Could not read user_oidc providers: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return null;
+ }
+
+ if ($providers === []) {
+ $this->logger->debug('Cannot refresh OIDC mail token, no user_oidc provider is configured');
+ return null;
+ }
+ if (count($providers) > 1) {
+ $this->logger->debug('Multiple user_oidc providers configured, using the first one for mail token refresh');
+ }
+ $provider = $providers[0];
+
+ $clientSecret = $provider->getClientSecret();
+ if ($clientSecret !== '') {
+ try {
+ $clientSecret = $this->crypto->decrypt($clientSecret);
+ } catch (Exception $e) {
+ $this->logger->warning('Could not decrypt user_oidc client secret: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return null;
+ }
+ }
+
+ $tokenEndpoint = $this->getTokenEndpoint($provider);
+ if ($tokenEndpoint === null) {
+ return null;
+ }
+
+ return [$tokenEndpoint, $provider->getClientId(), $clientSecret];
+ }
+
+ private function getTokenEndpoint(\OCA\UserOIDC\Db\Provider $provider): ?string {
+ $cached = $this->tokenEndpoints[$provider->getId()] ?? null;
+ if ($cached !== null) {
+ return $cached;
+ }
+
+ $discoveryUrl = $provider->getDiscoveryEndpoint();
+ if ($discoveryUrl === null || $discoveryUrl === '') {
+ $this->logger->warning('user_oidc provider has no discovery endpoint');
+ return null;
+ }
+
+ try {
+ $response = $this->clientService->newClient()->get($discoveryUrl);
+ $discovery = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $e) {
+ $this->logger->warning('Could not fetch OIDC discovery document: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return null;
+ }
+
+ $tokenEndpoint = $discovery['token_endpoint'] ?? null;
+ if (!is_string($tokenEndpoint) || $tokenEndpoint === '') {
+ $this->logger->warning('OIDC discovery document has no token_endpoint');
+ return null;
+ }
+
+ $this->tokenEndpoints[$provider->getId()] = $tokenEndpoint;
+ return $tokenEndpoint;
+ }
+}
diff --git a/lib/Listener/OauthTokenRefreshListener.php b/lib/Listener/OauthTokenRefreshListener.php
index 7e705e9472..f542fc99e9 100644
--- a/lib/Listener/OauthTokenRefreshListener.php
+++ b/lib/Listener/OauthTokenRefreshListener.php
@@ -10,32 +10,37 @@
namespace OCA\Mail\Listener;
use OCA\Mail\Events\BeforeImapClientCreated;
+use OCA\Mail\Events\BeforeSmtpClientCreated;
use OCA\Mail\Integration\GoogleIntegration;
use OCA\Mail\Integration\MicrosoftIntegration;
+use OCA\Mail\Integration\OidcIntegration;
use OCA\Mail\Service\AccountService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/**
- * @template-implements IEventListener
+ * @template-implements IEventListener
*/
class OauthTokenRefreshListener implements IEventListener {
public function __construct(
private GoogleIntegration $googleIntegration,
private MicrosoftIntegration $microsoftIntegration,
+ private OidcIntegration $oidcIntegration,
private AccountService $accountService,
) {
}
#[\Override]
public function handle(Event $event): void {
- if (!($event instanceof BeforeImapClientCreated)) {
+ if (!($event instanceof BeforeImapClientCreated) && !($event instanceof BeforeSmtpClientCreated)) {
return;
}
if ($this->googleIntegration->isGoogleOauthAccount($event->getAccount())) {
$updated = $this->googleIntegration->refresh($event->getAccount());
} elseif ($this->microsoftIntegration->isMicrosoftOauthAccount($event->getAccount())) {
$updated = $this->microsoftIntegration->refresh($event->getAccount());
+ } elseif ($this->oidcIntegration->isOidcAccount($event->getAccount())) {
+ $updated = $this->oidcIntegration->refresh($event->getAccount());
} else {
return;
}
diff --git a/lib/Migration/Version5300Date20260716000000.php b/lib/Migration/Version5300Date20260716000000.php
new file mode 100644
index 0000000000..51a1e98273
--- /dev/null
+++ b/lib/Migration/Version5300Date20260716000000.php
@@ -0,0 +1,41 @@
+getTable('mail_provisionings');
+ if (!$provisioningTable->hasColumn('oidc_enabled')) {
+ $provisioningTable->addColumn('oidc_enabled', Types::BOOLEAN, [
+ 'notnull' => false,
+ 'default' => false,
+ ]);
+ }
+
+ return $schema;
+ }
+}
diff --git a/lib/SMTP/SmtpClientFactory.php b/lib/SMTP/SmtpClientFactory.php
index 7d8c34f2f5..ac9f41b5cc 100644
--- a/lib/SMTP/SmtpClientFactory.php
+++ b/lib/SMTP/SmtpClientFactory.php
@@ -14,8 +14,10 @@
use Horde_Mail_Transport_Smtphorde;
use Horde_Smtp_Password_Xoauth2;
use OCA\Mail\Account;
+use OCA\Mail\Events\BeforeSmtpClientCreated;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Support\HostNameFactory;
+use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
use OCP\Security\ICrypto;
@@ -30,6 +32,7 @@ public function __construct(
IConfig $config,
ICrypto $crypto,
private HostNameFactory $hostNameFactory,
+ private IEventDispatcher $eventDispatcher,
) {
$this->config = $config;
$this->crypto = $crypto;
@@ -41,6 +44,9 @@ public function __construct(
* @return Horde_Mail_Transport
*/
public function create(Account $account): Horde_Mail_Transport {
+ $this->eventDispatcher->dispatchTyped(
+ new BeforeSmtpClientCreated($account)
+ );
$mailAccount = $account->getMailAccount();
$decryptedPassword = null;
diff --git a/lib/Service/Provisioning/Manager.php b/lib/Service/Provisioning/Manager.php
index e3ff4d5fe6..9e1d7e1906 100644
--- a/lib/Service/Provisioning/Manager.php
+++ b/lib/Service/Provisioning/Manager.php
@@ -10,6 +10,7 @@
namespace OCA\Mail\Service\Provisioning;
use Horde_Mail_Rfc822_Address;
+use OCA\Mail\Account;
use OCA\Mail\AppInfo\Application;
use OCA\Mail\Db\Alias;
use OCA\Mail\Db\AliasMapper;
@@ -19,6 +20,7 @@
use OCA\Mail\Db\ProvisioningMapper;
use OCA\Mail\Db\TagMapper;
use OCA\Mail\Exception\ValidationException;
+use OCA\Mail\Integration\OidcIntegration;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\Classification\ClassificationSettingsService;
use OCP\App\IAppManager;
@@ -62,6 +64,7 @@ public function __construct(
ICacheFactory $cacheFactory,
private AccountService $accountService,
private ClassificationSettingsService $classificationSettingsService,
+ private OidcIntegration $oidcIntegration,
) {
$this->appManager = $appManager;
$this->userManager = $userManager;
@@ -256,11 +259,23 @@ public function provisionSingleUser(array $provisionings, IUser $user): bool {
return true;
}
+ /**
+ * @throws ValidationException
+ */
+ private function validateOidcAvailability(array $data): void {
+ if (($data['oidcEnabled'] ?? false) && !$this->oidcIntegration->isAvailable()) {
+ $exception = new ValidationException();
+ $exception->setField('oidcEnabled', false);
+ throw $exception;
+ }
+ }
+
/**
* @throws ValidationException
* @throws \OCP\DB\Exception
*/
public function newProvisioning(array $data): Provisioning {
+ $this->validateOidcAvailability($data);
$provisioning = $this->provisioningMapper->validate($data);
$provisioning = $this->provisioningMapper->insert($provisioning);
if ($this->cacheFactory->isAvailable()) {
@@ -275,6 +290,7 @@ public function newProvisioning(array $data): Provisioning {
* @throws \OCP\DB\Exception
*/
public function updateProvisioning(array $data): void {
+ $this->validateOidcAvailability($data);
$provisioning = $this->provisioningMapper->validate($data);
$this->provisioningMapper->update($provisioning);
if ($this->cacheFactory->isAvailable()) {
@@ -287,6 +303,20 @@ private function updateAccount(IUser $user, MailAccount $account, Provisioning $
// Set the ID to make sure it reflects when the account switches from one config to another
$account->setProvisioningId($config->getId());
+ if ($config->getOidcEnabled() === true) {
+ // Authenticate with the user's OIDC access token (XOAUTH2) instead of a password.
+ // Tokens are seeded from user_oidc at login and refreshed by OidcIntegration.
+ $account->setAuthMethod('xoauth2');
+ $account->setInboundPassword(null);
+ $account->setOutboundPassword(null);
+ } elseif ($account->getAuthMethod() === 'xoauth2') {
+ // Config switched back from OIDC to password auth
+ $account->setAuthMethod('password');
+ $account->setOauthAccessToken(null);
+ $account->setOauthRefreshToken(null);
+ $account->setOauthTokenTtl(null);
+ }
+
$account->setEmail($config->buildEmail($user));
$account->setName($this->userManager->getDisplayName($user->getUID()));
$account->setInboundUser($config->buildImapUser($user));
@@ -335,6 +365,11 @@ public function updatePassword(IUser $user, ?string $password, array $provisioni
return;
}
+ if ($provisioning->getOidcEnabled() === true) {
+ // OIDC accounts authenticate with the user's access token, never a password
+ return;
+ }
+
// FIXME: Need to check for an empty string here too?
// The password is empty (and not null) when using WebAuthn passwordless login.
// Maybe research other providers as well.
@@ -366,6 +401,33 @@ public function updatePassword(IUser $user, ?string $password, array $provisioni
}
}
+ /**
+ * Mirror the user's current OIDC session token into their provisioned account so
+ * that background jobs can keep refreshing it after the session is gone.
+ *
+ * @param Provisioning[] $provisionings
+ */
+ public function updateOidcToken(IUser $user, array $provisionings): void {
+ $provisioning = $this->findMatchingConfig($provisionings, $user);
+ if ($provisioning === null || $provisioning->getOidcEnabled() !== true) {
+ return;
+ }
+
+ try {
+ $mailAccount = $this->mailAccountMapper->findProvisionedAccount($user);
+ } catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
+ // Nothing to update
+ return;
+ }
+
+ $ttlBefore = $mailAccount->getOauthTokenTtl();
+ $this->oidcIntegration->updateFromSession(new Account($mailAccount));
+ if ($mailAccount->getOauthTokenTtl() !== $ttlBefore) {
+ $this->mailAccountMapper->update($mailAccount);
+ $this->logger->debug('OIDC token of provisioned account updated from session for ' . $user->getUID());
+ }
+ }
+
/**
* @param Provisioning[] $provisionings
*/
diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php
index d7658b1b1b..910bd62327 100644
--- a/lib/Settings/AdminSettings.php
+++ b/lib/Settings/AdminSettings.php
@@ -12,6 +12,7 @@
use OCA\Mail\AppInfo\Application;
use OCA\Mail\Integration\GoogleIntegration;
use OCA\Mail\Integration\MicrosoftIntegration;
+use OCA\Mail\Integration\OidcIntegration;
use OCA\Mail\Service\AiIntegrations\AiIntegrationsService;
use OCA\Mail\Service\AntiSpamService;
use OCA\Mail\Service\Classification\ClassificationSettingsService;
@@ -31,6 +32,7 @@ public function __construct(
private AntiSpamService $antiSpamService,
private GoogleIntegration $googleIntegration,
private MicrosoftIntegration $microsoftIntegration,
+ private OidcIntegration $oidcIntegration,
private IConfig $config,
private AiIntegrationsService $aiIntegrationsService,
private Defaults $themingDefaults,
@@ -45,6 +47,11 @@ public function getForm() {
$this->provisioningManager->getConfigs()
);
+ $this->initialStateService->provideInitialState(
+ 'oidc_available',
+ $this->oidcIntegration->isAvailable()
+ );
+
$this->initialStateService->provideInitialState(
'antispam_setting',
[
diff --git a/psalm.xml b/psalm.xml
index b2d115ea6d..2e48e5f8ed 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -26,6 +26,7 @@
+
diff --git a/src/components/settings/AdminSettings.vue b/src/components/settings/AdminSettings.vue
index 683154a279..de45f84696 100644
--- a/src/components/settings/AdminSettings.vue
+++ b/src/components/settings/AdminSettings.vue
@@ -113,6 +113,7 @@
v-if="addNew"
:key="formKey"
:setting="preview"
+ :oidc-available="oidcAvailable"
:submit="saveNewSettings"
:delete-button="false" />
@@ -310,6 +312,7 @@ const googleOauthRedirectUrl = loadState('mail', 'google_oauth_redirect_url', nu
const microsoftOauthTenantId = loadState('mail', 'microsoft_oauth_tenant_id', null) ?? undefined
const microsoftOauthClientId = loadState('mail', 'microsoft_oauth_client_id', null) ?? undefined
const microsoftOauthRedirectUrl = loadState('mail', 'microsoft_oauth_redirect_url', null)
+const oidcAvailable = loadState('mail', 'oidc_available', false)
export default {
name: 'AdminSettings',
@@ -343,6 +346,7 @@ export default {
microsoftOauthClientId,
microsoftOauthDocs: loadState('mail', 'microsoft_oauth_docs'),
microsoftOauthRedirectUrl,
+ oidcAvailable,
preview: {
provisioningDomain: '',
emailTemplate: '',
diff --git a/src/components/settings/ProvisioningSettings.vue b/src/components/settings/ProvisioningSettings.vue
index ac7be23921..fc0dd13eb7 100644
--- a/src/components/settings/ProvisioningSettings.vue
+++ b/src/components/settings/ProvisioningSettings.vue
@@ -181,6 +181,30 @@
+
+
+ {{ t('mail', 'OpenID Connect') }}
+
+
+
@@ -393,6 +418,10 @@ export default {
required: true,
},
+ oidcAvailable: {
+ type: Boolean,
+ },
+
submit: {
type: Function,
required: true,
@@ -426,6 +455,7 @@ export default {
smtpSslMode: this.setting.smtpSslMode || 'tls',
masterPasswordEnabled: this.setting.masterPasswordEnabled === true,
masterPassword: this.setting.masterPassword || '',
+ oidcEnabled: this.setting.oidcEnabled === true,
sieveEnabled: this.setting.sieveEnabled || '',
sieveHost: this.setting.sieveHost || '',
sievePort: this.setting.sievePort || '',
@@ -462,6 +492,7 @@ export default {
smtpSslMode: this.smtpSslMode,
masterPasswordEnabled: this.masterPasswordEnabled,
masterPassword: this.masterPassword,
+ oidcEnabled: this.oidcEnabled,
sieveEnabled: this.sieveEnabled,
sieveUser: this.sieveUser,
sieveHost: this.sieveHost,
@@ -497,6 +528,7 @@ export default {
smtpSslMode: this.smtpSslMode,
masterPasswordEnabled: this.masterPasswordEnabled,
masterPassword: this.masterPassword,
+ oidcEnabled: this.oidcEnabled,
sieveEnabled: this.sieveEnabled,
sieveUser: this.sieveUser,
sieveHost: this.sieveHost,
diff --git a/tests/Unit/Integration/OidcIntegrationTest.php b/tests/Unit/Integration/OidcIntegrationTest.php
new file mode 100644
index 0000000000..810708f62e
--- /dev/null
+++ b/tests/Unit/Integration/OidcIntegrationTest.php
@@ -0,0 +1,157 @@
+timeFactory = $this->createMock(ITimeFactory::class);
+ $this->crypto = $this->createMock(ICrypto::class);
+ $this->clientService = $this->createMock(IClientService::class);
+ $this->eventDispatcher = $this->createMock(IEventDispatcher::class);
+ $this->appManager = $this->createMock(IAppManager::class);
+ $this->userSession = $this->createMock(IUserSession::class);
+
+ $this->integration = new OidcIntegration(
+ $this->timeFactory,
+ $this->crypto,
+ $this->clientService,
+ $this->eventDispatcher,
+ $this->appManager,
+ $this->userSession,
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+
+ public function testIsNotAvailableWithoutUserOidc(): void {
+ $this->appManager->expects($this->once())
+ ->method('isEnabledForAnyone')
+ ->with('user_oidc')
+ ->willReturn(false);
+
+ $this->assertFalse($this->integration->isAvailable());
+ }
+
+ public function testIsOidcAccountForProvisionedXoauth2Account(): void {
+ $mailAccount = new MailAccount();
+ $mailAccount->setProvisioningId(123);
+ $mailAccount->setAuthMethod('xoauth2');
+ $account = new Account($mailAccount);
+
+ $this->assertTrue($this->integration->isOidcAccount($account));
+ }
+
+ public function testIsOidcAccountRejectsUnprovisionedXoauth2Account(): void {
+ $mailAccount = new MailAccount();
+ $mailAccount->setAuthMethod('xoauth2');
+ $account = new Account($mailAccount);
+
+ $this->assertFalse($this->integration->isOidcAccount($account));
+ }
+
+ public function testIsOidcAccountRejectsPasswordAccount(): void {
+ $mailAccount = new MailAccount();
+ $mailAccount->setProvisioningId(123);
+ $mailAccount->setAuthMethod('password');
+ $account = new Account($mailAccount);
+
+ $this->assertFalse($this->integration->isOidcAccount($account));
+ }
+
+ public function testRefreshSkipsWhenTokenIsFresh(): void {
+ $mailAccount = new MailAccount();
+ $mailAccount->setProvisioningId(123);
+ $mailAccount->setAuthMethod('xoauth2');
+ $mailAccount->setOauthAccessToken('enc-token');
+ $mailAccount->setOauthTokenTtl(10000);
+ $account = new Account($mailAccount);
+ $this->timeFactory->expects($this->once())
+ ->method('getTime')
+ ->willReturn(9000);
+ $this->clientService->expects($this->never())
+ ->method('newClient');
+
+ $actual = $this->integration->refresh($account);
+
+ $this->assertSame('enc-token', $actual->getMailAccount()->getOauthAccessToken());
+ }
+
+ public function testUpdateFromSessionIgnoresForeignAccounts(): void {
+ if (!class_exists(\OCA\UserOIDC\Event\ExternalTokenRequestedEvent::class)) {
+ $this->markTestSkipped('user_oidc is not available');
+ }
+ $mailAccount = new MailAccount();
+ $mailAccount->setUserId('bob');
+ $mailAccount->setProvisioningId(123);
+ $mailAccount->setAuthMethod('xoauth2');
+ $account = new Account($mailAccount);
+ $sessionUser = $this->createMock(IUser::class);
+ $sessionUser->method('getUID')->willReturn('alice');
+ $this->userSession->method('getUser')->willReturn($sessionUser);
+ $this->eventDispatcher->expects($this->never())
+ ->method('dispatchTyped');
+
+ $actual = $this->integration->updateFromSession($account);
+
+ $this->assertNull($actual->getMailAccount()->getOauthAccessToken());
+ }
+
+ public function testRefreshWithoutRefreshTokenIsANoop(): void {
+ $mailAccount = new MailAccount();
+ $mailAccount->setProvisioningId(123);
+ $mailAccount->setAuthMethod('xoauth2');
+ $mailAccount->setOauthTokenTtl(100);
+ $account = new Account($mailAccount);
+ $this->timeFactory->method('getTime')
+ ->willReturn(9000);
+ $this->clientService->expects($this->never())
+ ->method('newClient');
+
+ $actual = $this->integration->refresh($account);
+
+ $this->assertNull($actual->getMailAccount()->getOauthAccessToken());
+ }
+}
diff --git a/tests/Unit/SMTP/SmtpClientFactoryTest.php b/tests/Unit/SMTP/SmtpClientFactoryTest.php
index 1e9e918874..236dac0dd1 100644
--- a/tests/Unit/SMTP/SmtpClientFactoryTest.php
+++ b/tests/Unit/SMTP/SmtpClientFactoryTest.php
@@ -15,6 +15,7 @@
use OCA\Mail\Db\MailAccount;
use OCA\Mail\SMTP\SmtpClientFactory;
use OCA\Mail\Support\HostNameFactory;
+use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
use OCP\Security\ICrypto;
use PHPUnit\Framework\MockObject\MockObject;
@@ -29,6 +30,9 @@ class SmtpClientFactoryTest extends TestCase {
/** @var HostNameFactory|MockObject */
private $hostNameFactory;
+ /** @var IEventDispatcher|MockObject */
+ private $eventDispatcher;
+
/** @var SmtpClientFactory */
private $factory;
@@ -38,8 +42,9 @@ protected function setUp(): void {
$this->config = $this->createMock(IConfig::class);
$this->crypto = $this->createMock(ICrypto::class);
$this->hostNameFactory = $this->createMock(HostNameFactory::class);
+ $this->eventDispatcher = $this->createMock(IEventDispatcher::class);
- $this->factory = new SmtpClientFactory($this->config, $this->crypto, $this->hostNameFactory);
+ $this->factory = new SmtpClientFactory($this->config, $this->crypto, $this->hostNameFactory, $this->eventDispatcher);
}
public function testSmtpTransport() {
diff --git a/tests/Unit/Service/Provisioning/ManagerTest.php b/tests/Unit/Service/Provisioning/ManagerTest.php
index a1af411851..7c54bf22d0 100644
--- a/tests/Unit/Service/Provisioning/ManagerTest.php
+++ b/tests/Unit/Service/Provisioning/ManagerTest.php
@@ -11,6 +11,7 @@
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\Provisioning;
+use OCA\Mail\Exception\ValidationException;
use OCA\Mail\Service\Provisioning\Manager;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IUser;
@@ -373,4 +374,29 @@ public function testNewProvisioning(): void {
self::assertInstanceOf(Provisioning::class, $result);
}
+
+ public function testNewProvisioningWithOidcNotAvailable(): void {
+ $this->mock->getParameter('oidcIntegration')
+ ->expects($this->once())
+ ->method('isAvailable')
+ ->willReturn(false);
+ $this->mock->getParameter('provisioningMapper')
+ ->expects($this->never())
+ ->method('validate');
+ $this->expectException(ValidationException::class);
+
+ $this->manager->newProvisioning([
+ 'oidcEnabled' => true,
+ 'email' => '%USERID%@domain.com',
+ 'imapUser' => '%USERID%@domain.com',
+ 'imapHost' => 'mx.domain.com',
+ 'imapPort' => 993,
+ 'imapSslMode' => 'ssl',
+ 'smtpUser' => '%USERID%@domain.com',
+ 'smtpHost' => 'mx.domain.com',
+ 'smtpPort' => 567,
+ 'smtpSslMode' => 'tls',
+ 'sieveEnabled' => false,
+ ]);
+ }
}
diff --git a/tests/Unit/Settings/AdminSettingsTest.php b/tests/Unit/Settings/AdminSettingsTest.php
index cc95185ebb..dfb6ca4d52 100644
--- a/tests/Unit/Settings/AdminSettingsTest.php
+++ b/tests/Unit/Settings/AdminSettingsTest.php
@@ -37,13 +37,17 @@ public function testGetSection() {
}
public function testGetForm() {
- $this->serviceMock->getParameter('initialStateService')->expects($this->exactly(14))
+ $this->serviceMock->getParameter('initialStateService')->expects($this->exactly(15))
->method('provideInitialState')
->withConsecutive(
[
'provisioning_settings',
$this->anything()
],
+ [
+ 'oidc_available',
+ $this->anything()
+ ],
[
'antispam_setting',
$this->anything()
diff --git a/tests/stubs/oca_useroidc.php b/tests/stubs/oca_useroidc.php
new file mode 100644
index 0000000000..c033ff0dc8
--- /dev/null
+++ b/tests/stubs/oca_useroidc.php
@@ -0,0 +1,64 @@
+