diff --git a/appinfo/info.xml b/appinfo/info.xml index 92f33f3427..01b372f99b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -86,6 +86,10 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud OCA\Mail\Command\ExportAccount OCA\Mail\Command\ExportAccountThreads OCA\Mail\Command\PredictImportance + OCA\Mail\Command\ProviderCreateAccount + OCA\Mail\Command\ProviderGenerateAppPassword + OCA\Mail\Command\ProviderList + OCA\Mail\Command\ProviderStatus OCA\Mail\Command\SyncAccount OCA\Mail\Command\Thread OCA\Mail\Command\TrainAccount diff --git a/appinfo/routes.php b/appinfo/routes.php index c62dc954c8..addac33608 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -121,8 +121,18 @@ 'verb' => 'POST' ], [ - 'name' => 'ionosAccounts#create', - 'url' => '/api/ionos/accounts', + 'name' => 'externalAccounts#getProviders', + 'url' => '/api/providers', + 'verb' => 'GET' + ], + [ + 'name' => 'externalAccounts#create', + 'url' => '/api/providers/{providerId}/accounts', + 'verb' => 'POST' + ], + [ + 'name' => 'externalAccounts#generatePassword', + 'url' => '/api/providers/{providerId}/password', 'verb' => 'POST' ], [ diff --git a/doc/provider-commands.md b/doc/provider-commands.md new file mode 100644 index 0000000000..8dce5e3125 --- /dev/null +++ b/doc/provider-commands.md @@ -0,0 +1,246 @@ +# Provider OCC Commands + +This document describes the new OCC commands for managing external mail account providers (e.g., IONOS Mail). + +## Available Commands + +### 1. `mail:provider:list` + +List all registered mail account providers and their capabilities. + +**Usage:** +```bash +php occ mail:provider:list +``` + +**Example Output:** +``` +Registered Mail Account Providers: + ++--------+------------+---------+-------------------+--------------+----------------+--------------+ +| ID | Name | Enabled | Multiple Accounts | App Passwords| Password Reset | Email Domain | ++--------+------------+---------+-------------------+--------------+----------------+--------------+ +| ionos | IONOS Mail | Yes | No | Yes | Yes | example.com | ++--------+------------+---------+-------------------+--------------+----------------+--------------+ + +IONOS Mail (ionos): + Configuration Parameters: + - ionos_mailconfig_api_base_url (string, required): Base URL for the IONOS Mail Configuration API + - ionos_mailconfig_api_auth_user (string, required): Basic auth username for IONOS API + - ionos_mailconfig_api_auth_pass (string, required): Basic auth password for IONOS API + - ionos_mailconfig_api_allow_insecure (boolean): Allow insecure connections (for development) + - ncw.ext_ref (string, required): External reference ID (system config) + - ncw.customerDomain (string, required): Customer domain for email addresses (system config) + Account Creation Parameters: + - accountName (string, required): Name + - emailUser (string, required): User +``` + +--- + +### 2. `mail:provider:status` + +Check the status and availability of a mail account provider. + +**Usage:** +```bash +php occ mail:provider:status [] [--verbose|-v] +``` + +**Arguments:** +- `provider-id` (required): Provider ID (e.g., "ionos") +- `user-id` (optional): User ID to check provider availability for specific user + +**Options:** +- `-v, --verbose`: Show detailed information including capabilities + +**Examples:** + +Check provider status: +```bash +php occ mail:provider:status ionos +``` + +Check if provider is available for a specific user: +```bash +php occ mail:provider:status ionos alice +``` + +With verbose output: +```bash +php occ mail:provider:status ionos alice -v +``` + +**Example Output:** +``` +Provider: IONOS Mail (ionos) + +Enabled: Yes + +User: alice +Available for User: Yes +``` + +--- + +### 3. `mail:provider:create-account` + +Create a mail account via an external provider. + +**Usage:** +```bash +php occ mail:provider:create-account -p = ... +``` + +**Arguments:** +- `provider-id` (required): Provider ID (e.g., "ionos") +- `user-id` (required): User ID to create the account for + +**Options:** +- `-p, --param`: Parameters in key=value format (can be used multiple times) + +**Example:** +```bash +php occ mail:provider:create-account ionos alice \ + -p emailUser=alice \ + -p accountName="Alice Smith" +``` + +**Example Output:** +``` +Creating account for user "alice" via provider "ionos"... + +Account created successfully! + +Account ID: 42 +Email: alice@example.com +Name: Alice Smith +IMAP Host: imap.example.com:993 +SMTP Host: smtp.example.com:587 +``` + +--- + +### 4. `mail:provider:generate-app-password` + +Generate a new app password for a provider-managed mail account. + +**Usage:** +```bash +php occ mail:provider:generate-app-password +``` + +**Arguments:** +- `provider-id` (required): Provider ID (e.g., "ionos") +- `user-id` (required): User ID to generate app password for + +**Example:** +```bash +php occ mail:provider:generate-app-password ionos alice +``` + +**Example Output:** +``` +Generating app password for user "alice" (email: alice@example.com)... + +App password generated successfully! + +New App Password: AbCd1234EfGh5678IjKl + +IMPORTANT: This password will only be shown once. Make sure to save it securely. +The mail account in Nextcloud has been automatically updated with the new password. +``` + +--- + +## Common Workflows + +### Initial Setup + +1. **List available providers:** + ```bash + php occ mail:provider:list + ``` + +2. **Check provider configuration:** + ```bash + php occ mail:provider:status ionos -v + ``` + +### Account Management + +1. **Create a new account:** + ```bash + php occ mail:provider:create-account ionos alice \ + -p emailUser=alice \ + -p accountName="Alice Smith" + ``` + +2. **Reset password (generate app password):** + ```bash + php occ mail:provider:generate-app-password ionos alice + ``` + +### Troubleshooting + +1. **Check if provider is available for user:** + ```bash + php occ mail:provider:status ionos alice + ``` + +2. **Verify provider configuration:** + ```bash + php occ mail:provider:list + ``` + +--- + +## Error Handling + +The commands provide clear error messages: + +- **Provider not found:** Lists available providers +- **User does not exist:** Validates user existence +- **Provider not enabled:** Suggests checking configuration +- **Provider not available for user:** Explains why (e.g., account already exists) +- **Missing required parameters:** Shows what parameters are needed with descriptions + +--- + +## Testing + +All commands include comprehensive unit tests: +- `tests/Unit/Command/ProviderListTest.php` +- `tests/Unit/Command/ProviderStatusTest.php` +- `tests/Unit/Command/ProviderCreateAccountTest.php` +- `tests/Unit/Command/ProviderGenerateAppPasswordTest.php` + +Run tests: +```bash +vendor/bin/phpunit -c tests/phpunit.unit.xml tests/Unit/Command/Provider* +``` + +--- + +## Implementation Details + +### Architecture + +All provider commands follow the same pattern: +1. Validate inputs (provider ID, user ID, parameters) +2. Check provider status and availability +3. Execute the operation via `ProviderRegistryService` +4. Provide detailed feedback to the user + +### Dependencies + +Commands use: +- `ProviderRegistryService`: Access to registered providers +- `IUserManager`: User validation +- Symfony Console components for CLI interaction + +### Code Location + +- Commands: `lib/Command/Provider*.php` +- Tests: `tests/Unit/Command/Provider*Test.php` +- Registration: `appinfo/info.xml` diff --git a/l10n/de.js b/l10n/de.js index fba80005e4..c49ade96e6 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -883,6 +883,11 @@ OC.L10N.register( "Manage S/MIME certificates" : "S/MIME Zertifikate verwalten", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope ist eine Browsererweiterung, die eine einfache OpenPGP-Ver- und -Entschlüsselung für E-Mails ermöglicht.", "Step 1: Install Mailvelope browser extension" : "Schritt 1: Mailvelope-Browsererweiterung installieren", - "Step 2: Enable Mailvelope for the current domain" : "Schritt 2: Mailvelope für die aktuelle Domäne aktivieren" + "Step 2: Enable Mailvelope for the current domain" : "Schritt 2: Mailvelope für die aktuelle Domäne aktivieren", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Um über IMAP auf dein Postfach zuzugreifen, kannst du ein app-spezifisches Passwort generieren. Mit diesem Passwort können sich IMAP-Clients mit deinem Konto verbinden.", + "IMAP access / password" : "IMAP-Zugang/Passwort", + "Generate password" : "Passwort generieren", + "Copy password" : "Passwort kopieren", + "Please save this password now. For security reasons, it will not be shown again." : "Bitte speichere dieses Passwort jetzt. Aus Sicherheitsgründen wird es nicht erneut angezeigt." }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/de.json b/l10n/de.json index 2c5ff98312..c0f3b95565 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -881,6 +881,11 @@ "Manage S/MIME certificates" : "S/MIME Zertifikate verwalten", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope ist eine Browsererweiterung, die eine einfache OpenPGP-Ver- und -Entschlüsselung für E-Mails ermöglicht.", "Step 1: Install Mailvelope browser extension" : "Schritt 1: Mailvelope-Browsererweiterung installieren", - "Step 2: Enable Mailvelope for the current domain" : "Schritt 2: Mailvelope für die aktuelle Domäne aktivieren" + "Step 2: Enable Mailvelope for the current domain" : "Schritt 2: Mailvelope für die aktuelle Domäne aktivieren", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Um über IMAP auf dein Postfach zuzugreifen, kannst du ein app-spezifisches Passwort generieren. Mit diesem Passwort können sich IMAP-Clients mit deinem Konto verbinden.", + "IMAP access / password" : "IMAP-Zugang/Passwort", + "Generate password" : "Passwort generieren", + "Copy password" : "Passwort kopieren", + "Please save this password now. For security reasons, it will not be shown again." : "Bitte speichere dieses Passwort jetzt. Aus Sicherheitsgründen wird es nicht erneut angezeigt." },"pluralForm" :"nplurals=2; plural=(n != 1);" -} \ No newline at end of file +} diff --git a/l10n/de_DE.js b/l10n/de_DE.js index dc29543845..6d7668f91e 100644 --- a/l10n/de_DE.js +++ b/l10n/de_DE.js @@ -883,6 +883,11 @@ OC.L10N.register( "Manage S/MIME certificates" : "S/MIME-Zertifikate verwalten", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope ist eine Browsererweiterung, die eine einfache OpenPGP-Ver- und -Entschlüsselung für E-Mails ermöglicht.", "Step 1: Install Mailvelope browser extension" : "Schritt 1: Mailvelope-Browsererweiterung installieren", - "Step 2: Enable Mailvelope for the current domain" : "Schritt 2: Mailvelope für die aktuelle Domäne aktivieren" + "Step 2: Enable Mailvelope for the current domain" : "Schritt 2: Mailvelope für die aktuelle Domäne aktivieren", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Um über IMAP auf Ihr Postfach zuzugreifen, können Sie ein app-spezifisches Passwort generieren. Mit diesem Passwort können sich IMAP-Clients mit Ihrem Konto verbinden.", + "IMAP access / password" : "IMAP-Zugang/Passwort", + "Generate password" : "Passwort generieren", + "Copy password" : "Passwort kopieren", + "Please save this password now. For security reasons, it will not be shown again." : "Bitte speichern Sie dieses Passwort jetzt. Aus Sicherheitsgründen wird es nicht erneut angezeigt.", }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/de_DE.json b/l10n/de_DE.json index d5a771d55e..ca84c065a4 100644 --- a/l10n/de_DE.json +++ b/l10n/de_DE.json @@ -881,6 +881,11 @@ "Manage S/MIME certificates" : "S/MIME-Zertifikate verwalten", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope ist eine Browsererweiterung, die eine einfache OpenPGP-Ver- und -Entschlüsselung für E-Mails ermöglicht.", "Step 1: Install Mailvelope browser extension" : "Schritt 1: Mailvelope-Browsererweiterung installieren", - "Step 2: Enable Mailvelope for the current domain" : "Schritt 2: Mailvelope für die aktuelle Domäne aktivieren" + "Step 2: Enable Mailvelope for the current domain" : "Schritt 2: Mailvelope für die aktuelle Domäne aktivieren", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Um über IMAP auf Ihr Postfach zuzugreifen, können Sie ein app-spezifisches Passwort generieren. Mit diesem Passwort können sich IMAP-Clients mit Ihrem Konto verbinden.", + "IMAP access / password" : "IMAP-Zugang/Passwort", + "Generate password" : "Passwort generieren", + "Copy password" : "Passwort kopieren", + "Please save this password now. For security reasons, it will not be shown again." : "Bitte speichern Sie dieses Passwort jetzt. Aus Sicherheitsgründen wird es nicht erneut angezeigt.", },"pluralForm" :"nplurals=2; plural=(n != 1);" -} \ No newline at end of file +} diff --git a/l10n/en_GB.js b/l10n/en_GB.js index df1a4d26d2..de3d65764c 100644 --- a/l10n/en_GB.js +++ b/l10n/en_GB.js @@ -873,6 +873,11 @@ OC.L10N.register( "Manage S/MIME certificates" : "Manage S/MIME certificates", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails.", "Step 1: Install Mailvelope browser extension" : "Step 1: Install Mailvelope browser extension", - "Step 2: Enable Mailvelope for the current domain" : "Step 2: Enable Mailvelope for the current domain" + "Step 2: Enable Mailvelope for the current domain" : "Step 2: Enable Mailvelope for the current domain", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account.", + "IMAP access / password" : "IMAP access / password", + "Generate password" : "Generate password", + "Copy password" : "Copy password", + "Please save this password now. For security reasons, it will not be shown again." : "Please save this password now. For security reasons, it will not be shown again." }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/en_GB.json b/l10n/en_GB.json index d835b8d80b..db9f7302af 100644 --- a/l10n/en_GB.json +++ b/l10n/en_GB.json @@ -871,6 +871,11 @@ "Manage S/MIME certificates" : "Manage S/MIME certificates", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails.", "Step 1: Install Mailvelope browser extension" : "Step 1: Install Mailvelope browser extension", - "Step 2: Enable Mailvelope for the current domain" : "Step 2: Enable Mailvelope for the current domain" + "Step 2: Enable Mailvelope for the current domain" : "Step 2: Enable Mailvelope for the current domain", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." + "IMAP access / password" : "IMAP access / password", + "Generate password" : "Generate password", + "Copy password" : "Copy password", + "Please save this password now. For security reasons, it will not be shown again." : "Please save this password now. For security reasons, it will not be shown again." },"pluralForm" :"nplurals=2; plural=(n != 1);" -} \ No newline at end of file +} diff --git a/l10n/es.js b/l10n/es.js index b6ad5df31c..00350454e2 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -881,6 +881,11 @@ OC.L10N.register( "Manage S/MIME certificates" : "Administrar certificados S/MIME", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope es una extensión para el navegador que permite habilitar fácilmente cifrado y descifrado OpenPGP para los correos electrónicos.", "Step 1: Install Mailvelope browser extension" : "Paso 1: Instale la extensión del navegador Mailvelope", - "Step 2: Enable Mailvelope for the current domain" : "Paso 2: Habilite Mailvelope para el dominio actual" + "Step 2: Enable Mailvelope for the current domain" : "Paso 2: Habilite Mailvelope para el dominio actual", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Para acceder a tu buzón de correo a través de IMAP, puedes generar una contraseña específica para la aplicación. Esta contraseña permite a los clientes IMAP conectarse a tu cuenta.", + "IMAP access / password" : "Acceso IMAP / contraseña", + "Generate password" : "Generar contraseña", + "Copy password" : "Copiar contraseña", + "Please save this password now. For security reasons, it will not be shown again." : "Guarde esta contraseña ahora. Por motivos de seguridad, no se volverá a mostrar.", }, "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"); diff --git a/l10n/es.json b/l10n/es.json index 33e45407d3..30d6e0403c 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -879,6 +879,11 @@ "Manage S/MIME certificates" : "Administrar certificados S/MIME", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope es una extensión para el navegador que permite habilitar fácilmente cifrado y descifrado OpenPGP para los correos electrónicos.", "Step 1: Install Mailvelope browser extension" : "Paso 1: Instale la extensión del navegador Mailvelope", - "Step 2: Enable Mailvelope for the current domain" : "Paso 2: Habilite Mailvelope para el dominio actual" -},"pluralForm" :"nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" -} \ No newline at end of file + "Step 2: Enable Mailvelope for the current domain" : "Paso 2: Habilite Mailvelope para el dominio actual", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Para acceder a tu buzón de correo a través de IMAP, puedes generar una contraseña específica para la aplicación. Esta contraseña permite a los clientes IMAP conectarse a tu cuenta.", + "IMAP access / password" : "Acceso IMAP / contraseña", + "Generate password" : "Generar contraseña", + "Copy password" : "Copiar contraseña", + "Please save this password now. For security reasons, it will not be shown again." : "Guarde esta contraseña ahora. Por motivos de seguridad, no se volverá a mostrar.", +,"pluralForm" :"nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" +} diff --git a/l10n/fr.js b/l10n/fr.js index da14a31ac7..16d2d2fff9 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -885,6 +885,11 @@ OC.L10N.register( "Manage S/MIME certificates" : "Gérer les certificats S/MIME", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope est une extension de navigateur qui permet de chiffrer et déchiffrer facilement les e-mails à l'aide du protocole OpenPGP.", "Step 1: Install Mailvelope browser extension" : "Étape 1 : Installer l'extension de navigateur Mailvelope", - "Step 2: Enable Mailvelope for the current domain" : "Étape 2 : Activer Mailvelope pour le domaine actuel" + "Step 2: Enable Mailvelope for the current domain" : "Étape 2 : Activer Mailvelope pour le domaine actuel", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Pour accéder à votre boîte mail via IMAP, vous pouvez générer un mot de passe spécifique à l'application. Ce mot de passe permet aux clients IMAP de se connecter à votre compte.", + "IMAP access / password" : "Accès IMAP / mot de passe", + "Generate password" : "générer un mot de passe", + "Copy password" : "copier le mot de passe", + "Please save this password now. For security reasons, it will not be shown again." : "Veuillez enregistrer ce mot de passe dès maintenant. Pour des raisons de sécurité, il ne sera plus affiché.", }, "nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"); diff --git a/l10n/fr.json b/l10n/fr.json index 32a436da91..203424ea73 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -883,6 +883,11 @@ "Manage S/MIME certificates" : "Gérer les certificats S/MIME", "Mailvelope is a browser extension that enables easy OpenPGP encryption and decryption for emails." : "Mailvelope est une extension de navigateur qui permet de chiffrer et déchiffrer facilement les e-mails à l'aide du protocole OpenPGP.", "Step 1: Install Mailvelope browser extension" : "Étape 1 : Installer l'extension de navigateur Mailvelope", - "Step 2: Enable Mailvelope for the current domain" : "Étape 2 : Activer Mailvelope pour le domaine actuel" + "Step 2: Enable Mailvelope for the current domain" : "Étape 2 : Activer Mailvelope pour le domaine actuel", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Pour accéder à votre boîte mail via IMAP, vous pouvez générer un mot de passe spécifique à l'application. Ce mot de passe permet aux clients IMAP de se connecter à votre compte.", + "IMAP access / password" : "Accès IMAP / mot de passe", + "Generate password" : "générer un mot de passe", + "Copy password" : "copier le mot de passe", + "Please save this password now. For security reasons, it will not be shown again." : "Veuillez enregistrer ce mot de passe dès maintenant. Pour des raisons de sécurité, il ne sera plus affiché.", },"pluralForm" :"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" -} \ No newline at end of file +} diff --git a/l10n/it.js b/l10n/it.js index d13a5dc26d..4f279a0c8d 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -573,6 +573,11 @@ OC.L10N.register( "Register as application for mail links" : "Registra come applicazione per i collegamenti di posta", "Allow the app to collect data about your interactions. Based on this data, the app will adapt to your preferences. The data will only be stored locally." : "Consenti all'applicazione di raccogliere dati sulle tue interazioni. Sulla base di questi dati, l'applicazione si adatterà alle tue preferenze. I dati saranno archiviati solo localmente.", "Trusted senders" : "Mittenti affidabili", - "Manage S/MIME certificates" : "Gestisci i certificati S/MIME" + "Manage S/MIME certificates" : "Gestisci i certificati S/MIME", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Per accedere alla tua casella di posta tramite IMAP, puoi generare una password specifica per l'app. Questa password consente ai client IMAP di connettersi al tuo account.", + "IMAP access / password" : "Accesso IMAP / password", + "Generate password" : "generare password", + "Copy password" : "copia password", + "Please save this password now. For security reasons, it will not be shown again." : "Salva questa password adesso. Per motivi di sicurezza, non verrà più visualizzata.", }, "nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"); diff --git a/l10n/it.json b/l10n/it.json index 866e44c2e4..6cb5a7456c 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -571,6 +571,12 @@ "Register as application for mail links" : "Registra come applicazione per i collegamenti di posta", "Allow the app to collect data about your interactions. Based on this data, the app will adapt to your preferences. The data will only be stored locally." : "Consenti all'applicazione di raccogliere dati sulle tue interazioni. Sulla base di questi dati, l'applicazione si adatterà alle tue preferenze. I dati saranno archiviati solo localmente.", "Trusted senders" : "Mittenti affidabili", - "Manage S/MIME certificates" : "Gestisci i certificati S/MIME" + + "Manage S/MIME certificates" : "Gestisci i certificati S/MIME", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Per accedere alla tua casella di posta tramite IMAP, puoi generare una password specifica per l'app. Questa password consente ai client IMAP di connettersi al tuo account.", + "IMAP access / password" : "Accesso IMAP / password", + "Generate password" : "generare password", + "Copy password" : "copia password", + "Please save this password now. For security reasons, it will not be shown again." : "Salva questa password adesso. Per motivi di sicurezza, non verrà più visualizzata.", },"pluralForm" :"nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" -} \ No newline at end of file +} diff --git a/l10n/nl.js b/l10n/nl.js index d6cdf0fd1c..61b687f334 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -461,6 +461,11 @@ OC.L10N.register( "Use Gravatar and favicon avatars" : "Gebruik Gravatar en favicon avatars", "Register as application for mail links" : "Registreer als applicatie voor e-maillinks", "Allow the app to collect data about your interactions. Based on this data, the app will adapt to your preferences. The data will only be stored locally." : "Laat de app gegevens verzamelen over jouw interacties. Op basis van deze gegevens zal de app zich aanpassen aan je voorkeuren. De gegevens worden alleen lokaal opgeslagen.", - "Trusted senders" : "Vertrouwde afzenders" + "Trusted senders" : "Vertrouwde afzenders", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Om via IMAP toegang te krijgen tot uw mailbox, kunt u een app-specifiek wachtwoord genereren. Met dit wachtwoord kunnen IMAP-clients verbinding maken met uw account.", + "IMAP access / password" : "IMAP-toegang / wachtwoord", + "Generate password" : "wachtwoord genereren", + "Copy password" : "wachtwoord kopiëren", + "Please save this password now. For security reasons, it will not be shown again." : "Sla dit wachtwoord nu op. Om veiligheidsredenen wordt het niet opnieuw weergegeven.", }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/nl.json b/l10n/nl.json index c694fff1ea..257a94d030 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -459,6 +459,11 @@ "Use Gravatar and favicon avatars" : "Gebruik Gravatar en favicon avatars", "Register as application for mail links" : "Registreer als applicatie voor e-maillinks", "Allow the app to collect data about your interactions. Based on this data, the app will adapt to your preferences. The data will only be stored locally." : "Laat de app gegevens verzamelen over jouw interacties. Op basis van deze gegevens zal de app zich aanpassen aan je voorkeuren. De gegevens worden alleen lokaal opgeslagen.", - "Trusted senders" : "Vertrouwde afzenders" + "Trusted senders" : "Vertrouwde afzenders", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "Om via IMAP toegang te krijgen tot uw mailbox, kunt u een app-specifiek wachtwoord genereren. Met dit wachtwoord kunnen IMAP-clients verbinding maken met uw account.", + "IMAP access / password" : "IMAP-toegang / wachtwoord", + "Generate password" : "wachtwoord genereren", + "Copy password" : "wachtwoord kopiëren", + "Please save this password now. For security reasons, it will not be shown again." : "Sla dit wachtwoord nu op. Om veiligheidsredenen wordt het niet opnieuw weergegeven.", },"pluralForm" :"nplurals=2; plural=(n != 1);" -} \ No newline at end of file +} diff --git a/l10n/sv.js b/l10n/sv.js index f95ae9f2a3..0e0acd8c82 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -391,6 +391,11 @@ OC.L10N.register( "Use Gravatar and favicon avatars" : "Använd Gravatar och favicon som profilbild", "Register as application for mail links" : "Registrera som app för e-postlänkar", "Allow the app to collect data about your interactions. Based on this data, the app will adapt to your preferences. The data will only be stored locally." : "Låt appen samla in data om dina interaktioner. Baserat på denna information kommer appen att anpassas till dina preferenser. Uppgifterna lagras endast lokalt.", - "Trusted senders" : "Betrodda avsändare" + "Trusted senders" : "Betrodda avsändare", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "För att komma åt din e-post via IMAP kan du skapa ett appspecifikt lösenord. Med detta lösenord kan IMAP-klienter ansluta till ditt konto.", + "IMAP access / password" : "IMAP-åtkomst / lösenord", + "Generate password" : "generera lösenord", + "Copy password" : "kopiera lösenord", + "Please save this password now. For security reasons, it will not be shown again." : "Spara detta lösenord nu. Av säkerhetsskäl kommer det inte att visas igen.", }, "nplurals=2; plural=(n != 1);"); diff --git a/l10n/sv.json b/l10n/sv.json index 92c24d474e..481aef7735 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -389,6 +389,11 @@ "Use Gravatar and favicon avatars" : "Använd Gravatar och favicon som profilbild", "Register as application for mail links" : "Registrera som app för e-postlänkar", "Allow the app to collect data about your interactions. Based on this data, the app will adapt to your preferences. The data will only be stored locally." : "Låt appen samla in data om dina interaktioner. Baserat på denna information kommer appen att anpassas till dina preferenser. Uppgifterna lagras endast lokalt.", - "Trusted senders" : "Betrodda avsändare" + "Trusted senders" : "Betrodda avsändare", + "To access your mailbox via IMAP, you can generate an app-specific password. This password allows IMAP clients to connect to your account." : "För att komma åt din e-post via IMAP kan du skapa ett appspecifikt lösenord. Med detta lösenord kan IMAP-klienter ansluta till ditt konto.", + "IMAP access / password" : "IMAP-åtkomst / lösenord", + "Generate password" : "generera lösenord", + "Copy password" : "kopiera lösenord", + "Please save this password now. For security reasons, it will not be shown again." : "Spara detta lösenord nu. Av säkerhetsskäl kommer det inte att visas igen.", },"pluralForm" :"nplurals=2; plural=(n != 1);" -} \ No newline at end of file +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3004b98093..325c5d5b37 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -54,6 +54,8 @@ use OCA\Mail\Listener\TaskProcessingListener; use OCA\Mail\Listener\UserDeletedListener; use OCA\Mail\Notification\Notifier; +use OCA\Mail\Provider\MailAccountProvider\Implementations\IonosProvider; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Provider\MailProvider; use OCA\Mail\Search\FilteringProvider; use OCA\Mail\Service\Attachment\AttachmentService; @@ -172,5 +174,19 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { + $container = $context->getServerContainer(); + + // Register mail account providers + try { + $providerRegistry = $container->get(ProviderRegistryService::class); + $ionosProvider = $container->get(IonosProvider::class); + $providerRegistry->registerProvider($ionosProvider); + } catch (\Exception $e) { + // Log but don't fail - provider registration is optional + $logger = $container->get(\Psr\Log\LoggerInterface::class); + $logger->error('Failed to register mail account providers', [ + 'exception' => $e, + ]); + } } } diff --git a/lib/Command/ProviderCommandBase.php b/lib/Command/ProviderCommandBase.php new file mode 100644 index 0000000000..1779197fd8 --- /dev/null +++ b/lib/Command/ProviderCommandBase.php @@ -0,0 +1,164 @@ +addOption( + 'output', + null, + InputOption::VALUE_REQUIRED, + 'Output format: plain, json, json_pretty', + self::OUTPUT_FORMAT_PLAIN + ); + } + + /** + * Get the output format from input + */ + protected function getOutputFormat(InputInterface $input, OutputInterface $output): string { + $format = $input->getOption('output') ?? self::OUTPUT_FORMAT_PLAIN; + if (!in_array($format, [self::OUTPUT_FORMAT_PLAIN, self::OUTPUT_FORMAT_JSON, self::OUTPUT_FORMAT_JSON_PRETTY])) { + $output->writeln(sprintf('Invalid output format: %s', $format)); + $output->writeln(sprintf('Valid formats: %s, %s, %s', self::OUTPUT_FORMAT_PLAIN, self::OUTPUT_FORMAT_JSON, self::OUTPUT_FORMAT_JSON_PRETTY)); + return self::OUTPUT_FORMAT_PLAIN; + } + return $format; + } + + /** + * Output data in the requested format + * + * @param array $data + */ + protected function outputData(array $data, string $format, OutputInterface $output): void { + if ($format === self::OUTPUT_FORMAT_JSON) { + $output->writeln(json_encode($data)); + } elseif ($format === self::OUTPUT_FORMAT_JSON_PRETTY) { + $output->writeln(json_encode($data, JSON_PRETTY_PRINT)); + } + // For plain format, commands handle their own output + } + + /** + * Get a provider by ID or display error and return null + */ + protected function getProviderOrFail(string $providerId, OutputInterface $output): ?IMailAccountProvider { + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + $output->writeln(sprintf('Provider "%s" not found.', $providerId)); + $output->writeln(''); + $output->writeln('Available providers:'); + foreach ($this->providerRegistry->getAllProviders() as $p) { + $output->writeln(sprintf(' - %s (%s)', $p->getId(), $p->getName())); + } + } + return $provider; + } + + /** + * Validate that a user exists or display error + * + * @return bool true if user exists, false otherwise + */ + protected function validateUserExists(string $userId, OutputInterface $output): bool { + if (!$this->userManager->userExists($userId)) { + $output->writeln(sprintf('User "%s" does not exist.', $userId)); + return false; + } + return true; + } + + /** + * Check if provider is enabled or display error + * + * @return bool true if provider is enabled, false otherwise + */ + protected function checkProviderEnabled(IMailAccountProvider $provider, OutputInterface $output): bool { + if (!$provider->isEnabled()) { + $output->writeln(sprintf('Provider "%s" is not enabled.', $provider->getId())); + $output->writeln('Check configuration and ensure provider is properly configured.'); + return false; + } + return true; + } + + /** + * Check if user can create a new provider account + * + * User can create account if no existing Nextcloud account blocks creation. + * This aligns with frontend mail-providers-available logic. + * + * @return bool true if user can create account, false otherwise + */ + protected function checkCanCreateAccount( + IMailAccountProvider $provider, + string $userId, + OutputInterface $output, + ): bool { + $existingEmail = $provider->getExistingAccountEmail($userId); + if ($existingEmail !== null) { + $output->writeln(sprintf( + 'Cannot create account for user "%s".', + $userId + )); + $output->writeln(''); + $output->writeln(sprintf('User already has a Nextcloud account: %s', $existingEmail)); + $output->writeln('To create a provider account, delete the existing account first.'); + return false; + } + return true; + } + + /** + * Get provisioned email for user or display error and return null + */ + protected function getProvisionedEmailOrFail( + IMailAccountProvider $provider, + string $userId, + OutputInterface $output, + ): ?string { + $provisionedEmail = $provider->getProvisionedEmail($userId); + if ($provisionedEmail === null) { + $output->writeln(sprintf( + 'User "%s" does not have a provisioned account with provider "%s".', + $userId, + $provider->getId() + )); + } + return $provisionedEmail; + } +} diff --git a/lib/Command/ProviderCreateAccount.php b/lib/Command/ProviderCreateAccount.php new file mode 100644 index 0000000000..45f0322bf5 --- /dev/null +++ b/lib/Command/ProviderCreateAccount.php @@ -0,0 +1,250 @@ +setName('mail:provider:create-account'); + $this->setDescription('Create a mail account via an external provider'); + $this->addArgument( + self::ARGUMENT_PROVIDER_ID, + InputArgument::REQUIRED, + 'Provider ID (e.g., "ionos")' + ); + $this->addArgument( + self::ARGUMENT_USER_ID, + InputArgument::REQUIRED, + 'User ID to create the account for' + ); + $this->addOption( + self::OPTION_PARAM, + 'p', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Parameters in key=value format (e.g., -p emailUser=john -p accountName="John Doe")' + ); + $this->addOutputOption(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $providerId = $input->getArgument(self::ARGUMENT_PROVIDER_ID); + $userId = $input->getArgument(self::ARGUMENT_USER_ID); + $paramStrings = $input->getOption(self::OPTION_PARAM); + $outputFormat = $this->getOutputFormat($input, $output); + + $provider = $this->getProviderOrFail($providerId, $output); + if ($provider === null) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('Provider "%s" not found', $providerId)], $outputFormat, $output); + } + return 1; + } + + if (!$this->validateUserExists($userId, $output)) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('User "%s" not found', $userId)], $outputFormat, $output); + } + return 1; + } + + if (!$this->checkProviderEnabled($provider, $output)) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('Provider "%s" is not enabled', $providerId)], $outputFormat, $output); + } + return 1; + } + + if (!$this->checkCanCreateAccount($provider, $userId, $output)) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('Cannot create account for user "%s"', $userId)], $outputFormat, $output); + } + return 1; + } + + $parameters = $this->parseParameters($paramStrings, $output, $outputFormat); + if ($parameters === null) { + return 1; + } + + if (!$this->validateRequiredParameters($provider, $parameters, $output, $outputFormat)) { + return 1; + } + + return $this->createAccount($provider, $userId, $parameters, $output, $outputFormat); + } + + /** + * Parse parameter strings into key-value array + * + * @param string[] $paramStrings + * @return array|null Array of parameters or null on error + */ + protected function parseParameters(array $paramStrings, OutputInterface $output, string $outputFormat): ?array { + $parameters = []; + foreach ($paramStrings as $paramString) { + $parts = explode('=', $paramString, 2); + if (count($parts) !== 2) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('Invalid parameter format: %s', $paramString)], $outputFormat, $output); + } else { + $output->writeln(sprintf('Invalid parameter format: %s', $paramString)); + $output->writeln('Use format: -p key=value'); + } + return null; + } + $parameters[trim($parts[0])] = trim($parts[1]); + } + return $parameters; + } + + /** + * Validate that all required parameters are present + * + * @param array $parameters + */ + protected function validateRequiredParameters( + IMailAccountProvider $provider, + array $parameters, + OutputInterface $output, + string $outputFormat, + ): bool { + $capabilities = $provider->getCapabilities(); + $creationSchema = $capabilities->getCreationParameterSchema(); + + $missingParams = []; + foreach ($creationSchema as $key => $schema) { + $required = $schema['required'] ?? false; + if ($required && !isset($parameters[$key])) { + $missingParams[] = $key; + } + } + + if (!empty($missingParams)) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $missingDetails = []; + foreach ($missingParams as $param) { + $missingDetails[] = [ + 'parameter' => $param, + 'description' => $creationSchema[$param]['description'] ?? '', + ]; + } + $this->outputData([ + 'error' => 'Missing required parameters', + 'missingParameters' => $missingDetails, + ], $outputFormat, $output); + } else { + $output->writeln('Missing required parameters:'); + foreach ($missingParams as $param) { + $description = $creationSchema[$param]['description'] ?? ''; + $output->writeln(sprintf(' - %s: %s', $param, $description)); + } + $output->writeln(''); + $output->writeln('Usage example:'); + foreach ($creationSchema as $key => $schema) { + $required = $schema['required'] ?? false; + if ($required) { + $output->writeln(sprintf(' -p %s=', $key)); + } + } + } + return false; + } + + return true; + } + + /** + * Create the account and display result + * + * @param array $parameters + */ + protected function createAccount( + IMailAccountProvider $provider, + string $userId, + array $parameters, + OutputInterface $output, + string $outputFormat, + ): int { + if ($outputFormat === self::OUTPUT_FORMAT_PLAIN) { + $output->writeln(sprintf( + 'Creating account for user "%s" via provider "%s"...', + $userId, + $provider->getId() + )); + $output->writeln(''); + } + + try { + $account = $provider->createAccount($userId, $parameters); + + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData([ + 'success' => true, + 'account' => [ + 'id' => $account->getId(), + 'email' => $account->getEmail(), + 'name' => $account->getName(), + 'imapHost' => $account->getMailAccount()->getInboundHost(), + 'imapPort' => $account->getMailAccount()->getInboundPort(), + 'smtpHost' => $account->getMailAccount()->getOutboundHost(), + 'smtpPort' => $account->getMailAccount()->getOutboundPort(), + ], + ], $outputFormat, $output); + } else { + $output->writeln('Account created successfully!'); + $output->writeln(''); + $output->writeln(sprintf('Account ID: %d', $account->getId())); + $output->writeln(sprintf('Email: %s', $account->getEmail())); + $output->writeln(sprintf('Name: %s', $account->getName())); + $output->writeln(sprintf( + 'IMAP Host: %s:%d', + $account->getMailAccount()->getInboundHost(), + $account->getMailAccount()->getInboundPort() + )); + $output->writeln(sprintf( + 'SMTP Host: %s:%d', + $account->getMailAccount()->getOutboundHost(), + $account->getMailAccount()->getOutboundPort() + )); + $output->writeln(''); + } + return 0; + } catch (\Exception $e) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => $e->getMessage()], $outputFormat, $output); + } else { + $output->writeln('Failed to create account:'); + $output->writeln(sprintf('%s', $e->getMessage())); + } + return 1; + } + } +} diff --git a/lib/Command/ProviderGenerateAppPassword.php b/lib/Command/ProviderGenerateAppPassword.php new file mode 100644 index 0000000000..fa6320f2e5 --- /dev/null +++ b/lib/Command/ProviderGenerateAppPassword.php @@ -0,0 +1,163 @@ +setName('mail:provider:generate-app-password'); + $this->setDescription('Generate a new app password for a provider-managed mail account'); + $this->addArgument( + self::ARGUMENT_PROVIDER_ID, + InputArgument::REQUIRED, + 'Provider ID (e.g., "ionos")' + ); + $this->addArgument( + self::ARGUMENT_USER_ID, + InputArgument::REQUIRED, + 'User ID to generate app password for' + ); + $this->addOutputOption(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $providerId = $input->getArgument(self::ARGUMENT_PROVIDER_ID); + $userId = $input->getArgument(self::ARGUMENT_USER_ID); + $outputFormat = $this->getOutputFormat($input, $output); + + $provider = $this->getProviderOrFail($providerId, $output); + if ($provider === null) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('Provider "%s" not found', $providerId)], $outputFormat, $output); + } + return 1; + } + + if (!$this->validateUserExists($userId, $output)) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('User "%s" not found', $userId)], $outputFormat, $output); + } + return 1; + } + + if (!$this->checkAppPasswordSupport($provider, $output, $outputFormat)) { + return 1; + } + + if (!$this->checkProviderEnabled($provider, $output)) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('Provider "%s" is not enabled', $providerId)], $outputFormat, $output); + } + return 1; + } + + $provisionedEmail = $this->getProvisionedEmailOrFail($provider, $userId, $output); + if ($provisionedEmail === null) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => 'No provisioned account found for this user'], $outputFormat, $output); + } else { + $output->writeln('Create an account first using mail:provider:create-account'); + } + return 1; + } + + return $this->generateAndDisplayAppPassword($provider, $userId, $provisionedEmail, $output, $outputFormat); + } + + /** + * Check if provider supports app password generation + */ + protected function checkAppPasswordSupport(IMailAccountProvider $provider, OutputInterface $output, string $outputFormat): bool { + $capabilities = $provider->getCapabilities(); + if (!$capabilities->supportsAppPasswords()) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData([ + 'error' => sprintf('Provider "%s" does not support app password generation', $provider->getId()), + ], $outputFormat, $output); + } else { + $output->writeln(sprintf( + 'Provider "%s" does not support app password generation.', + $provider->getId() + )); + } + return false; + } + return true; + } + + /** + * Generate app password and display result + */ + protected function generateAndDisplayAppPassword( + IMailAccountProvider $provider, + string $userId, + string $provisionedEmail, + OutputInterface $output, + string $outputFormat, + ): int { + if ($outputFormat === self::OUTPUT_FORMAT_PLAIN) { + $output->writeln(sprintf( + 'Generating app password for user "%s" (email: %s)...', + $userId, + $provisionedEmail + )); + $output->writeln(''); + } + + try { + $appPassword = $provider->generateAppPassword($userId); + + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData([ + 'success' => true, + 'appPassword' => $appPassword, + 'userId' => $userId, + 'email' => $provisionedEmail, + ], $outputFormat, $output); + } else { + $output->writeln('App password generated successfully!'); + $output->writeln(''); + $output->writeln(sprintf('New App Password: %s', $appPassword)); + $output->writeln(''); + $output->writeln('IMPORTANT: This password will only be shown once. Make sure to save it securely.'); + $output->writeln('The mail account in Nextcloud has been automatically updated with the new password.'); + $output->writeln(''); + } + return 0; + } catch (\Exception $e) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => $e->getMessage()], $outputFormat, $output); + } else { + $output->writeln('Failed to generate app password:'); + $output->writeln(sprintf('%s', $e->getMessage())); + } + return 1; + } + } +} diff --git a/lib/Command/ProviderList.php b/lib/Command/ProviderList.php new file mode 100644 index 0000000000..506689ef9a --- /dev/null +++ b/lib/Command/ProviderList.php @@ -0,0 +1,167 @@ +setName('mail:provider:list'); + $this->setDescription('List all registered mail account providers and their capabilities'); + $this->addOutputOption(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $providers = $this->providerRegistry->getAllProviders(); + $outputFormat = $this->getOutputFormat($input, $output); + + if (empty($providers)) { + if ($outputFormat === self::OUTPUT_FORMAT_PLAIN) { + $output->writeln('No mail account providers are registered.'); + } else { + $this->outputData(['providers' => []], $outputFormat, $output); + } + return 0; + } + + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $data = $this->formatProvidersData($providers); + $this->outputData($data, $outputFormat, $output); + return 0; + } + + $output->writeln('Registered Mail Account Providers:'); + $output->writeln(''); + + $this->renderProvidersTable($providers, $output); + $output->writeln(''); + + $this->displayProviderSchemas($providers, $output); + + return 0; + } + + /** + * Format providers data for JSON output + * + * @param IMailAccountProvider[] $providers + * @return array + */ + protected function formatProvidersData(array $providers): array { + $data = []; + foreach ($providers as $provider) { + $capabilities = $provider->getCapabilities(); + $data[] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'enabled' => $provider->isEnabled(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ], + 'configSchema' => $capabilities->getConfigSchema(), + 'creationSchema' => $capabilities->getCreationParameterSchema(), + ]; + } + return ['providers' => $data]; + } + + /** + * Render a table displaying providers and their capabilities + * + * @param IMailAccountProvider[] $providers + */ + protected function renderProvidersTable(array $providers, OutputInterface $output): void { + $table = new Table($output); + $table->setHeaders(['ID', 'Name', 'Enabled', 'Multiple Accounts', 'App Passwords', 'Password Reset', 'Email Domain']); + + foreach ($providers as $provider) { + $capabilities = $provider->getCapabilities(); + $table->addRow([ + $provider->getId(), + $provider->getName(), + $provider->isEnabled() ? 'Yes' : 'No', + $capabilities->allowsMultipleAccounts() ? 'Yes' : 'No', + $capabilities->supportsAppPasswords() ? 'Yes' : 'No', + $capabilities->supportsPasswordReset() ? 'Yes' : 'No', + $capabilities->getEmailDomain() ?? 'N/A', + ]); + } + + $table->render(); + } + + /** + * Display configuration and creation parameter schemas for each provider + * + * @param IMailAccountProvider[] $providers + */ + protected function displayProviderSchemas(array $providers, OutputInterface $output): void { + foreach ($providers as $provider) { + $capabilities = $provider->getCapabilities(); + $configSchema = $capabilities->getConfigSchema(); + $creationSchema = $capabilities->getCreationParameterSchema(); + + if (empty($configSchema) && empty($creationSchema)) { + continue; + } + + $output->writeln(sprintf('%s (%s):', $provider->getName(), $provider->getId())); + + if (!empty($configSchema)) { + $output->writeln(' Configuration Parameters:'); + $this->displaySchema($configSchema, $output); + } + + if (!empty($creationSchema)) { + $output->writeln(' Account Creation Parameters:'); + $this->displaySchema($creationSchema, $output); + } + $output->writeln(''); + } + } + + /** + * Display a parameter schema + * + * @param array $schema + */ + protected function displaySchema(array $schema, OutputInterface $output): void { + foreach ($schema as $key => $schemaItem) { + $required = $schemaItem['required'] ?? false; + $type = $schemaItem['type'] ?? 'string'; + $description = $schemaItem['description'] ?? ''; + $output->writeln(sprintf( + ' - %s (%s%s): %s', + $key, + $type, + $required ? ', required' : '', + $description + )); + } + } +} diff --git a/lib/Command/ProviderStatus.php b/lib/Command/ProviderStatus.php new file mode 100644 index 0000000000..9f6607692e --- /dev/null +++ b/lib/Command/ProviderStatus.php @@ -0,0 +1,237 @@ +setName('mail:provider:status'); + $this->setDescription('Check the status and availability of a mail account provider (use -v for detailed information)'); + $this->addArgument( + self::ARGUMENT_PROVIDER_ID, + InputArgument::REQUIRED, + 'Provider ID (e.g., "ionos")' + ); + $this->addArgument( + self::ARGUMENT_USER_ID, + InputArgument::OPTIONAL, + 'User ID to check provider availability for specific user' + ); + $this->addOutputOption(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $providerId = $input->getArgument(self::ARGUMENT_PROVIDER_ID); + $userId = $input->getArgument(self::ARGUMENT_USER_ID); + $verbose = $output->isVerbose(); + $outputFormat = $this->getOutputFormat($input, $output); + + $provider = $this->getProviderOrFail($providerId, $output); + if ($provider === null) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('Provider "%s" not found', $providerId)], $outputFormat, $output); + } + return 1; + } + + if ($userId !== null && !$this->validateUserExists($userId, $output)) { + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $this->outputData(['error' => sprintf('User "%s" not found', $userId)], $outputFormat, $output); + } + return 1; + } + + // JSON output + if ($outputFormat !== self::OUTPUT_FORMAT_PLAIN) { + $data = $this->formatStatusData($provider, $userId, $verbose); + $this->outputData($data, $outputFormat, $output); + return 0; + } + + // Plain text output + $this->displayProviderBasicInfo($provider, $output); + + if ($verbose) { + $this->displayProviderCapabilities($provider, $output); + } + + if ($userId !== null) { + $this->displayUserAvailability($provider, $userId, $output); + } + + $output->writeln(''); + return 0; + } + + /** + * Display basic provider information + */ + protected function displayProviderBasicInfo(IMailAccountProvider $provider, OutputInterface $output): void { + $output->writeln(sprintf('Provider: %s (%s)', $provider->getName(), $provider->getId())); + $output->writeln(''); + + $isEnabled = $provider->isEnabled(); + $output->writeln(sprintf( + 'Enabled: %s', + $isEnabled ? 'Yes' : 'No' + )); + + if (!$isEnabled) { + $output->writeln('Provider is not enabled. Check configuration.'); + } + } + + /** + * Display provider capabilities + */ + protected function displayProviderCapabilities(IMailAccountProvider $provider, OutputInterface $output): void { + $capabilities = $provider->getCapabilities(); + $output->writeln(''); + $output->writeln('Capabilities:'); + $output->writeln(sprintf(' Multiple Accounts: %s', $capabilities->allowsMultipleAccounts() ? 'Yes' : 'No')); + $output->writeln(sprintf(' App Passwords: %s', $capabilities->supportsAppPasswords() ? 'Yes' : 'No')); + $output->writeln(sprintf(' Password Reset: %s', $capabilities->supportsPasswordReset() ? 'Yes' : 'No')); + $output->writeln(sprintf(' Email Domain: %s', $capabilities->getEmailDomain() ?? 'N/A')); + } + + /** + * Display provider availability for a specific user + */ + protected function displayUserAvailability( + IMailAccountProvider $provider, + string $userId, + OutputInterface $output, + ): void { + $output->writeln(''); + $output->writeln(sprintf('User: %s', $userId)); + + $existingEmail = $provider->getExistingAccountEmail($userId); + $provisionedEmail = $provider->getProvisionedEmail($userId); + $accountId = $this->getExistingAccountId($userId, $existingEmail); + + // User is available if no existing account is blocking them + $canCreateAccount = $existingEmail === null; + + $output->writeln(sprintf( + 'Can Create Account: %s', + $canCreateAccount ? 'Yes' : 'No' + )); + + $output->writeln(sprintf( + 'Existing Nextcloud Account: %s', + $existingEmail !== null ? sprintf('%s (ID: %d)', $existingEmail, $accountId) : 'None' + )); + + $output->writeln(sprintf( + 'Provisioned Provider Account: %s', + $provisionedEmail !== null ? sprintf('%s', $provisionedEmail) : 'None' + )); + + if ($existingEmail !== null) { + $output->writeln(''); + $output->writeln('Note: User already has an account configured in Nextcloud.'); + $output->writeln('To create a new provider account, delete the existing account first.'); + } elseif ($provisionedEmail !== null) { + $output->writeln(''); + $output->writeln('Note: Account is already provisioned with the provider.'); + } + } + + /** + * Format provider status data for JSON output + */ + protected function formatStatusData( + IMailAccountProvider $provider, + ?string $userId, + bool $verbose, + ): array { + $capabilities = $provider->getCapabilities(); + + $data = [ + 'provider' => [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'enabled' => $provider->isEnabled(), + ], + ]; + + if ($verbose) { + $data['capabilities'] = [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ]; + } + + if ($userId !== null) { + $existingEmail = $provider->getExistingAccountEmail($userId); + $provisionedEmail = $provider->getProvisionedEmail($userId); + $accountId = $this->getExistingAccountId($userId, $existingEmail); + $canCreateAccount = $existingEmail === null; + + $data['user'] = [ + 'id' => $userId, + 'canCreateAccount' => $canCreateAccount, + 'existingNextcloudAccount' => $existingEmail, + 'existingNextcloudAccountId' => $accountId, + 'provisionedProviderAccount' => $provisionedEmail, + ]; + } + + return $data; + } + + /** + * Get the account ID for an existing account + * + * @param string $userId The user ID + * @param string|null $email The email address + * @return int|null The account ID or null if no account exists + */ + private function getExistingAccountId(string $userId, ?string $email): ?int { + if ($email === null) { + return null; + } + + try { + $accounts = $this->accountService->findByUserIdAndAddress($userId, $email); + if (count($accounts) > 0) { + return $accounts[0]->getId(); + } + } catch (\Exception $e) { + // If we can't find the account, just return null + return null; + } + + return null; + } +} diff --git a/lib/Controller/ExternalAccountsController.php b/lib/Controller/ExternalAccountsController.php new file mode 100644 index 0000000000..b2e968ee50 --- /dev/null +++ b/lib/Controller/ExternalAccountsController.php @@ -0,0 +1,286 @@ +getUserIdOrFail(); + + // Get parameters from request body + $parameters = $this->request->getParams(); + + // Remove Nextcloud-specific parameters + unset($parameters['providerId']); + unset($parameters['_route']); + + $this->logger->info('Starting external mail account creation', [ + 'userId' => $userId, + 'providerId' => $providerId, + 'parameters' => array_keys($parameters), + ]); + + // Get the provider + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found: ' . $providerId, + ], Http::STATUS_NOT_FOUND); + } + + // Check if provider is enabled and available for this user + if (!$provider->isEnabled()) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_AVAILABLE, + 'message' => 'Provider is not enabled: ' . $providerId, + ], Http::STATUS_BAD_REQUEST); + } + + if (!$provider->isAvailableForUser($userId)) { + // Try to get existing email for a better error message + $existingEmail = $provider->getExistingAccountEmail($userId); + + $errorData = [ + 'error' => self::ERR_PROVIDER_NOT_AVAILABLE, + 'message' => 'Provider is not available for this user', + ]; + + if ($existingEmail !== null) { + $errorData['existingEmail'] = $existingEmail; + } + + return MailJsonResponse::fail($errorData, Http::STATUS_BAD_REQUEST); + } + + // Create the account + $account = $provider->createAccount($userId, $parameters); + + $this->logger->info('External account creation completed successfully', [ + 'emailAddress' => $account->getEmail(), + 'accountId' => $account->getId(), + 'userId' => $userId, + 'providerId' => $providerId, + ]); + + $json = $account->jsonSerialize(); + $json = $this->accountProviderService->addProviderMetadata($json, $userId, $account->getEmail()); + + return MailJsonResponse::success($json, Http::STATUS_CREATED); + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, $providerId); + } catch (\InvalidArgumentException $e) { + $this->logger->error('Invalid parameters for account creation', [ + 'providerId' => $providerId, + 'exception' => $e, + ]); + return MailJsonResponse::fail([ + 'error' => self::ERR_INVALID_PARAMETERS, + 'message' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } catch (\Exception $e) { + $this->logger->error('Unexpected error during external account creation', [ + 'providerId' => $providerId, + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not create account'); + } + } + + /** + * Get information about available providers + * + * @NoAdminRequired + * + * @return JSONResponse + */ + #[TrapError] + public function getProviders(): JSONResponse { + try { + $userId = $this->getUserIdOrFail(); + $availableProviders = $this->providerRegistry->getAvailableProvidersForUser($userId); + + $providersInfo = []; + foreach ($availableProviders as $provider) { + $capabilities = $provider->getCapabilities(); + $providersInfo[] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ], + 'parameterSchema' => $capabilities->getCreationParameterSchema(), + ]; + } + + return MailJsonResponse::success([ + 'providers' => $providersInfo, + ]); + } catch (\Exception $e) { + $this->logger->error('Error getting available providers', [ + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not get providers'); + } + } + + /** + * Generate an app password for a provider-managed account + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $providerId The provider ID + * @return JSONResponse + */ + #[TrapError] + public function generatePassword(string $providerId): JSONResponse { + // Get accountId from request body + $accountId = $this->request->getParam('accountId'); + + if ($accountId === null) { + return MailJsonResponse::fail(['error' => 'Account ID is required']); + } + + try { + $userId = $this->getUserIdOrFail(); + + $this->logger->info('Generating app password', [ + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found', + ], Http::STATUS_NOT_FOUND); + } + + // Check if provider supports app passwords + if (!$provider->getCapabilities()->supportsAppPasswords()) { + return MailJsonResponse::fail([ + 'error' => 'NOT_SUPPORTED', + 'message' => 'Provider does not support app passwords', + ], Http::STATUS_BAD_REQUEST); + } + + // Use the provider interface method for generating app passwords + $password = $provider->generateAppPassword($userId); + + $this->logger->info('App password generated successfully', [ + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + + return MailJsonResponse::success(['password' => $password]); + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, $providerId); + } catch (\InvalidArgumentException $e) { + $this->logger->error('Invalid arguments for app password generation', [ + 'exception' => $e, + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + return MailJsonResponse::fail([ + 'error' => self::ERR_INVALID_PARAMETERS, + 'message' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } catch (\Exception $e) { + $this->logger->error('Unexpected error generating app password', [ + 'exception' => $e, + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + return MailJsonResponse::error('Could not generate app password'); + } + } + + /** + * Get the current user ID + * + * @return string User ID string + * @throws ServiceException + */ + private function getUserIdOrFail(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ServiceException('No user session found', 401); + } + return $user->getUID(); + } + + /** + * Build service error response + */ + private function buildServiceErrorResponse(ServiceException $e, string $providerId): JSONResponse { + $data = [ + 'error' => self::ERR_SERVICE_ERROR, + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + ]; + + // If it's a ProviderServiceException, merge in the additional data + if ($e instanceof ProviderServiceException) { + $data = array_merge($data, $e->getData()); + } + + $this->logger->error('Service error during provider operation', array_merge($data, [ + 'providerId' => $providerId, + ])); + + return MailJsonResponse::fail($data); + } +} diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php deleted file mode 100644 index 761d6fd5c0..0000000000 --- a/lib/Controller/IonosAccountsController.php +++ /dev/null @@ -1,119 +0,0 @@ - false, 'message' => self::ERR_ALL_FIELDS_REQUIRED, 'error' => self::ERR_IONOS_API_ERROR], 400); - } - return null; - } - - /** - * @NoAdminRequired - */ - #[TrapError] - public function create(string $accountName, string $emailUser): JSONResponse { - if ($error = $this->validateInput($accountName, $emailUser)) { - return $error; - } - - try { - $userId = $this->getUserIdOrFail(); - - $this->logger->info('Starting IONOS email account creation from web', [ - 'userId' => $userId, - 'emailAddress' => $emailUser, - 'accountName' => $accountName, - ]); - - $account = $this->accountCreationService->createOrUpdateAccount($userId, $emailUser, $accountName); - - $this->logger->info('Account creation completed successfully', [ - 'emailAddress' => $account->getEmail(), - 'accountName' => $accountName, - 'accountId' => $account->getId(), - 'userId' => $userId, - ]); - - return MailJsonResponse::success($account, Http::STATUS_CREATED); - } catch (ServiceException $e) { - return $this->buildServiceErrorResponse($e, 'account creation'); - } catch (\Exception $e) { - $this->logger->error('Unexpected error during account creation: ' . $e->getMessage(), [ - 'exception' => $e, - ]); - return MailJsonResponse::error('Could not create account'); - } - } - - /** - * Get the current user ID - * - * @return string User ID string - * @throws ServiceException - */ - private function getUserIdOrFail(): string { - $user = $this->userSession->getUser(); - if ($user === null) { - throw new ServiceException('No user session found during account creation', 401); - } - return $user->getUID(); - } - - /** - * Build service error response - */ - private function buildServiceErrorResponse(ServiceException $e, string $context): JSONResponse { - $data = [ - 'error' => self::ERR_IONOS_API_ERROR, - 'statusCode' => $e->getCode(), - 'message' => $e->getMessage(), - ]; - - // If it's an IonosServiceException, merge in the additional data - if ($e instanceof IonosServiceException) { - $data = array_merge($data, $e->getData()); - } - - $this->logger->error('IONOS service error during ' . $context . ': ' . $e->getMessage(), $data); - return MailJsonResponse::fail($data); - } -} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 6166e15a02..948266db00 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -16,13 +16,12 @@ use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Db\SmimeCertificate; use OCA\Mail\Db\TagMapper; +use OCA\Mail\Service\AccountProviderService; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\InternalAddressService; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailConfigService; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; use OCA\Mail\Service\SmimeService; @@ -100,8 +99,7 @@ public function __construct( InternalAddressService $internalAddressService, IAvailabilityCoordinator $availabilityCoordinator, QuickActionsService $quickActionsService, - private IonosConfigService $ionosConfigService, - private IonosMailConfigService $ionosMailConfigService, + private AccountProviderService $accountProviderService, ) { parent::__construct($appName, $request); @@ -153,6 +151,7 @@ public function index(): TemplateResponse { $accountsJson = []; foreach ($mailAccounts as $mailAccount) { $json = $mailAccount->jsonSerialize(); + $json = $this->accountProviderService->addProviderMetadata($json, $this->currentUserId, $mailAccount->getEmail()); $json['aliases'] = $this->aliasesService->findAll($mailAccount->getId(), $this->currentUserId); try { @@ -216,8 +215,7 @@ public function index(): TemplateResponse { $this->initialStateService->provideInitialState('preferences', [ 'attachment-size-limit' => $this->config->getSystemValue('app.mail.attachment-size-limit', 0), - 'ionos-mailconfig-enabled' => $this->ionosMailConfigService->isMailConfigAvailable(), - 'ionos-mailconfig-domain' => $this->ionosConfigService->getMailDomain(), + 'mail-providers-available' => !empty($this->accountProviderService->getAvailableProvidersForUser($this->currentUserId)), 'app-version' => $this->config->getAppValue('mail', 'installed_version'), 'external-avatars' => $this->preferences->getPreference($this->currentUserId, 'external-avatars', 'true'), 'layout-mode' => $this->preferences->getPreference($this->currentUserId, 'layout-mode', 'vertical-split'), diff --git a/lib/Exception/IonosServiceException.php b/lib/Exception/IonosServiceException.php deleted file mode 100644 index c3dfbdffb3..0000000000 --- a/lib/Exception/IonosServiceException.php +++ /dev/null @@ -1,38 +0,0 @@ - $data [optional] Additional data to pass with the exception. - */ - public function __construct( - $message = '', - $code = 0, - ?Throwable $previous = null, - private readonly array $data = [], - ) { - parent::__construct($message, $code, $previous); - } - - /** - * Get additional data associated with the exception - * - * @return array - */ - public function getData(): array { - return $this->data; - } -} diff --git a/lib/Exception/ProviderServiceException.php b/lib/Exception/ProviderServiceException.php new file mode 100644 index 0000000000..05cc69c9ef --- /dev/null +++ b/lib/Exception/ProviderServiceException.php @@ -0,0 +1,43 @@ + $data Additional structured error data + * @param \Throwable|null $previous Previous exception + */ + public function __construct( + string $message, + int $code = 0, + private array $data = [], + ?\Throwable $previous = null, + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Get additional structured error data + * + * @return array Error data (e.g., validation errors, provider-specific codes) + */ + public function getData(): array { + return $this->data; + } +} diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index 30e06ee6a8..326f7192c2 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -10,8 +10,8 @@ namespace OCA\Mail\Listener; use OCA\Mail\Exception\ClientException; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\IonosMailService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\User\Events\UserDeletedEvent; @@ -30,7 +30,7 @@ class UserDeletedListener implements IEventListener { public function __construct( AccountService $accountService, LoggerInterface $logger, - private readonly IonosMailService $ionosMailService, + private readonly ProviderRegistryService $providerRegistry, ) { $this->accountService = $accountService; $this->logger = $logger; @@ -46,11 +46,14 @@ public function handle(Event $event): void { $user = $event->getUser(); $userId = $user->getUID(); - // Delete IONOS mailbox if IONOS integration is enabled - $this->ionosMailService->tryDeleteEmailAccount($userId); + $accounts = $this->accountService->findByUserId($userId); + + // Delete provider-managed accounts (generic system) + // This works with any registered provider (IONOS, Office365, etc.) + $this->providerRegistry->deleteProviderManagedAccounts($userId, $accounts); // Delete all mail accounts in Nextcloud - foreach ($this->accountService->findByUserId($userId) as $account) { + foreach ($accounts as $account) { try { $this->accountService->delete( $userId, diff --git a/lib/Service/IONOS/Dto/MailAccountConfig.php b/lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php similarity index 95% rename from lib/Service/IONOS/Dto/MailAccountConfig.php rename to lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php index 3ac69a446f..0521c0d9ba 100644 --- a/lib/Service/IONOS/Dto/MailAccountConfig.php +++ b/lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS\Dto; +namespace OCA\Mail\Provider\MailAccountProvider\Common\Dto; /** * Data transfer object for complete mail account configuration diff --git a/lib/Service/IONOS/Dto/MailServerConfig.php b/lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php similarity index 96% rename from lib/Service/IONOS/Dto/MailServerConfig.php rename to lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php index 27b5cb7aed..ce3f169db9 100644 --- a/lib/Service/IONOS/Dto/MailServerConfig.php +++ b/lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS\Dto; +namespace OCA\Mail\Provider\MailAccountProvider\Common\Dto; /** * Data transfer object for mail server configuration (IMAP/SMTP) diff --git a/lib/Provider/MailAccountProvider/IMailAccountProvider.php b/lib/Provider/MailAccountProvider/IMailAccountProvider.php new file mode 100644 index 0000000000..a913acba2e --- /dev/null +++ b/lib/Provider/MailAccountProvider/IMailAccountProvider.php @@ -0,0 +1,131 @@ + $parameters Provider-specific parameters (e.g., email username, domain) + * @return Account The created Nextcloud mail account + * @throws \OCA\Mail\Exception\ServiceException If account creation fails + */ + public function createAccount(string $userId, array $parameters): Account; + + /** + * Update an existing mail account (e.g., reset password) + * + * @param string $userId The Nextcloud user ID + * @param int $accountId The Nextcloud mail account ID + * @param array $parameters Provider-specific parameters + * @return Account The updated account + * @throws \OCA\Mail\Exception\ServiceException If update fails + */ + public function updateAccount(string $userId, int $accountId, array $parameters): Account; + + /** + * Delete a mail account from the external provider + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to delete + * @return bool True if deletion was successful + */ + public function deleteAccount(string $userId, string $email): bool; + + /** + * Check if the given email address is managed by this provider + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to check + * @return bool True if this provider manages this email + */ + public function managesEmail(string $userId, string $email): bool; + + /** + * Get the email address managed by this provider for the given user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getProvisionedEmail(string $userId): ?string; + + /** + * Get the email address of an existing account for the given user + * + * This method is used to provide better error messages when a user + * tries to create an account but already has one configured. + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address if account exists, null otherwise + */ + public function getExistingAccountEmail(string $userId): ?string; + + /** + * Generate a new app password for the user's account + * + * This method generates a new application-specific password that can be used + * for IMAP/SMTP authentication. Only available if the provider supports + * app passwords (check getCapabilities()->supportsAppPasswords()). + * + * @param string $userId The Nextcloud user ID + * @return string The generated password + * @throws \OCA\Mail\Exception\ProviderServiceException If password generation fails + * @throws \InvalidArgumentException If provider doesn't support app passwords + */ + public function generateAppPassword(string $userId): string; +} diff --git a/lib/Provider/MailAccountProvider/IProviderCapabilities.php b/lib/Provider/MailAccountProvider/IProviderCapabilities.php new file mode 100644 index 0000000000..c36247aa37 --- /dev/null +++ b/lib/Provider/MailAccountProvider/IProviderCapabilities.php @@ -0,0 +1,68 @@ + Config schema + */ + public function getConfigSchema(): array; + + /** + * Get the parameter schema for account creation + * + * Returns an array describing what parameters are needed + * when creating an account (e.g., username, domain). + * + * @return array Parameter schema + */ + public function getCreationParameterSchema(): array; + + /** + * Get the email domain for this provider (if applicable) + * + * Returns the domain suffix used for email addresses created by this provider. + * For example, "example.com" for accounts like "user@example.com" + * + * @return string|null The email domain or null if not applicable + */ + public function getEmailDomain(): ?string; +} diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php new file mode 100644 index 0000000000..56023407a8 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php @@ -0,0 +1,225 @@ +configService->isIonosIntegrationEnabled(); + } catch (\Exception $e) { + $this->logger->debug('IONOS provider is not enabled', [ + 'exception' => $e, + ]); + return false; + } + } + + /** + * Check if IONOS account provisioning is available for a user + * + * The configuration is available only if: + * 1. The IONOS integration is enabled and properly configured + * 2. The user does NOT already have an IONOS mail account configured remotely + * 3. OR the user has a remote IONOS account but it's NOT configured locally in the mail app + * + * @param string $userId The Nextcloud user ID + * @return bool True if provisioning should be shown + */ + public function isAvailableForUser(string $userId): bool { + try { + return $this->mailConfigService->isMailConfigAvailable($userId); + } catch (\Exception $e) { + $this->logger->error('Error checking IONOS availability for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return false; + } + } + + /** + * Get existing IONOS mail account email for a user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address if account exists, null otherwise + */ + public function getExistingAccountEmail(string $userId): ?string { + try { + $accountResponse = $this->queryService->getMailAccountResponse($userId); + if ($accountResponse !== null) { + return $accountResponse->getEmail(); + } + return null; + } catch (\Exception $e) { + $this->logger->error('Error getting existing IONOS account email', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return null; + } + } + + /** + * Create or update an IONOS mail account + * + * @param string $userId The Nextcloud user ID + * @param string $emailUser The email username (local part before @) + * @param string $accountName The display name for the account + * @return Account The created or updated mail account + * @throws ServiceException If account creation fails + */ + public function createAccount(string $userId, string $emailUser, string $accountName): Account { + $this->logger->info('Creating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + return $this->creationService->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + /** + * Update an existing IONOS mail account + * + * Currently uses the same logic as creation (which handles updates) + * + * @param string $userId The Nextcloud user ID + * @param string $emailUser The email username (local part before @) + * @param string $accountName The display name for the account + * @return Account The updated account + * @throws ServiceException If update fails + */ + public function updateAccount(string $userId, string $emailUser, string $accountName): Account { + $this->logger->info('Updating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + // Currently, creation service handles both create and update + return $this->creationService->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + /** + * Delete an IONOS mail account + * + * @param string $userId The Nextcloud user ID + * @return bool True if deletion was successful + */ + public function deleteAccount(string $userId): bool { + $this->logger->info('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + try { + $this->mutationService->tryDeleteEmailAccount($userId); + return true; + } catch (\Exception $e) { + $this->logger->error('Error deleting IONOS account via facade', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return false; + } + } + + /** + * Get the provisioned email address for a user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getProvisionedEmail(string $userId): ?string { + try { + return $this->queryService->getIonosEmailForUser($userId); + } catch (\Exception $e) { + $this->logger->debug('Error getting IONOS provisioned email', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return null; + } + } + + /** + * Check if a specific email address is managed by IONOS for a user + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to check + * @return bool True if this email is managed by IONOS + */ + public function managesEmail(string $userId, string $email): bool { + $ionosEmail = $this->getProvisionedEmail($userId); + if ($ionosEmail === null) { + return false; + } + return strcasecmp($email, $ionosEmail) === 0; + } + + /** + * Get the email domain used by IONOS + * + * @return string|null The email domain or null if not configured + */ + public function getEmailDomain(): ?string { + try { + return $this->configService->getMailDomain(); + } catch (\Exception $e) { + $this->logger->debug('Could not get IONOS email domain', [ + 'exception' => $e, + ]); + return null; + } + } + + /** + * Generate an app password for the IONOS account + * + * @param string $userId The Nextcloud user ID + * @return string The generated app password + * @throws \Exception If password generation fails + */ + public function generateAppPassword(string $userId): string { + $this->logger->info('Generating IONOS app password via facade', [ + 'userId' => $userId, + ]); + + return $this->mutationService->resetAppPassword($userId, IonosConfigService::APP_PASSWORD_NAME_USER); + } +} diff --git a/lib/Service/IONOS/ApiMailConfigClientService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/ApiMailConfigClientService.php similarity index 92% rename from lib/Service/IONOS/ApiMailConfigClientService.php rename to lib/Provider/MailAccountProvider/Implementations/Ionos/Service/ApiMailConfigClientService.php index 5376dee727..6067543d73 100644 --- a/lib/Service/IONOS/ApiMailConfigClientService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/ApiMailConfigClientService.php @@ -6,7 +6,7 @@ * SPDX-FileCopyrightText: 2025 STRATO GmbH * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS; +namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service; use GuzzleHttp\ClientInterface; use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; diff --git a/lib/Service/IONOS/ConflictResolutionResult.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/ConflictResolutionResult.php similarity index 92% rename from lib/Service/IONOS/ConflictResolutionResult.php rename to lib/Provider/MailAccountProvider/Implementations/Ionos/Service/ConflictResolutionResult.php index f1a402c10e..c69cb38918 100644 --- a/lib/Service/IONOS/ConflictResolutionResult.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/ConflictResolutionResult.php @@ -6,9 +6,9 @@ * SPDX-FileCopyrightText: 2025 STRATO GmbH * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS; +namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; /** * Result of conflict resolution when IONOS account creation fails diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountMutationService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountMutationService.php new file mode 100644 index 0000000000..475efca764 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountMutationService.php @@ -0,0 +1,388 @@ +getCurrentUserId(); + return $this->createEmailAccountForUser($userId, $userName); + } + + /** + * Create an IONOS email account via API for a specific user + * + * This method allows creating email accounts without relying on the user session, + * making it suitable for use in OCC commands or admin operations. + * + * @param string $userId The Nextcloud user ID + * @param string $userName The local part of the email address (before @domain) + * @return MailAccountConfig Mail account configuration + * @throws ServiceException + */ + public function createEmailAccountForUser(string $userId, string $userName): MailAccountConfig { + $domain = $this->configService->getMailDomain(); + + $this->logger->debug('Sending request to mailconfig service', [ + 'extRef' => $this->configService->getExternalReference(), + 'userName' => $userName, + 'domain' => $domain, + 'apiBaseUrl' => $this->configService->getApiBaseUrl(), + 'userId' => $userId + ]); + + $apiInstance = $this->createApiInstance(); + + $mailCreateData = new MailCreateData(); + $mailCreateData->setNextcloudUserId($userId); + $mailCreateData->setLocalPart($userName); + + if (!$mailCreateData->valid()) { + $this->logger->error('Validate message to mailconfig service', [ + 'data' => $mailCreateData->listInvalidProperties(), + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Invalid mail configuration', self::HTTP_INTERNAL_SERVER_ERROR); + } + + try { + $this->logger->debug('Send message to mailconfig service', ['data' => $mailCreateData]); + $result = $apiInstance->createMailbox(self::BRAND, $this->configService->getExternalReference(), $mailCreateData); + + if ($result instanceof MailAddonErrorMessage) { + $this->logger->error('Failed to create ionos mail', [ + 'status code' => $result->getStatus(), + 'message' => $result->getMessage(), + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', $result->getStatus()); + } + if ($result instanceof MailAccountCreatedResponse) { + $this->logger->info('Successfully created IONOS mail account', [ + 'email' => $result->getEmail(), + 'userId' => $userId, + 'userName' => $userName + ]); + return $this->buildSuccessResponse($result); + } + + $this->logger->error('Failed to create ionos mail: Unknown response type', [ + 'data' => $result, + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', self::HTTP_INTERNAL_SERVER_ERROR); + } catch (ServiceException $e) { + // Re-throw ServiceException without additional logging + throw $e; + } catch (ApiException $e) { + $this->logger->error('API Exception when calling MailConfigurationAPIApi->createMailbox', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody() + ]); + throw new ServiceException('Failed to create ionos mail: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->createMailbox', [ + 'exception' => $e, + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Delete an IONOS email account via API + * + * @param string $userId The Nextcloud user ID + * @return bool true if deletion was successful + * @throws ServiceException + */ + public function deleteEmailAccount(string $userId): bool { + $this->logger->info('Attempting to delete IONOS email account', [ + 'userId' => $userId, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + + $apiInstance->deleteMailbox(self::BRAND, $this->configService->getExternalReference(), $userId); + + $this->logger->info('Successfully deleted IONOS email account', [ + 'userId' => $userId + ]); + + return true; + } catch (ApiException $e) { + // 404 means the mailbox doesn't exist - treat as success + if ($e->getCode() === self::HTTP_NOT_FOUND) { + $this->logger->debug('IONOS mailbox does not exist (already deleted or never created)', [ + 'userId' => $userId, + 'statusCode' => $e->getCode() + ]); + return true; + } + + $this->logger->error('API Exception when calling MailConfigurationAPIApi->deleteMailbox', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId + ]); + + throw new ServiceException('Failed to delete IONOS mail: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->deleteMailbox', [ + 'exception' => $e, + 'userId' => $userId + ]); + + throw new ServiceException('Failed to delete IONOS mail', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Delete an IONOS email account without throwing exceptions (fire and forget) + * + * This method checks if IONOS integration is enabled and attempts to delete + * the email account. All errors are logged but not thrown, making it safe + * to call in event listeners or other contexts where exceptions should not + * interrupt the flow. + * + * @param string $userId The Nextcloud user ID + * @return void + */ + public function tryDeleteEmailAccount(string $userId): void { + // Check if IONOS integration is enabled + if (!$this->configService->isIonosIntegrationEnabled()) { + $this->logger->debug('IONOS integration is not enabled, skipping email account deletion', [ + 'userId' => $userId + ]); + return; + } + + try { + $this->deleteEmailAccount($userId); + // Success is already logged by deleteEmailAccount + } catch (ServiceException $e) { + $this->logger->error('Failed to delete IONOS mailbox for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + // Don't throw - this is a fire and forget operation + } + } + + /** + * Reset app password for the IONOS mail account (generates a new password) + * + * @param string $userId The Nextcloud user ID + * @param string $appName The application name for the password + * @return string The new password + * @throws ServiceException + */ + public function resetAppPassword(string $userId, string $appName): string { + $this->logger->debug('Resetting IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->setAppPassword( + self::BRAND, + $this->configService->getExternalReference(), + $userId, + $appName + ); + + if (is_string($result)) { + $this->logger->info('Successfully reset IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName + ]); + return $result; + } + + $this->logger->error('Failed to reset IONOS app password: Unexpected response type', [ + 'userId' => $userId, + 'appName' => $appName, + 'result' => $result + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR); + } catch (ServiceException $e) { + // Re-throw ServiceException without additional logging + throw $e; + } catch (ApiException $e) { + $this->logger->error('API Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'exception' => $e, + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Get the current user ID from the session + * + * @return string The user ID + * @throws ServiceException If no user is logged in + */ + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + $this->logger->error('No user session found when attempting to create IONOS mail account'); + throw new ServiceException('No user session found'); + } + return $user->getUID(); + } + + /** + * Create and configure API instance with authentication + * + * @return \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi + */ + private function createApiInstance(): \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi { + $client = $this->apiClientService->newClient([ + 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], + 'verify' => !$this->configService->getAllowInsecure(), + ]); + + return $this->apiClientService->newMailConfigurationAPIApi($client, $this->configService->getApiBaseUrl()); + } + + /** + * Normalize SSL mode from API response to expected format + * + * Maps API SSL mode values (e.g., "TLS", "SSL") to standard values ("tls", "ssl", "none") + * + * @param string $apiSslMode SSL mode from API response + * @return string Normalized SSL mode: "tls", "ssl", or "none" + */ + private function normalizeSslMode(string $apiSslMode): string { + $normalized = strtolower($apiSslMode); + + if (str_contains($normalized, 'tls') || str_contains($normalized, 'starttls')) { + $result = 'tls'; + } elseif (str_contains($normalized, 'ssl')) { + $result = 'ssl'; + } else { + $result = 'none'; + } + + $this->logger->debug('Normalized SSL mode', [ + 'input' => $apiSslMode, + 'output' => $result + ]); + + return $result; + } + + /** + * Build success response with mail configuration from MailAccountCreatedResponse (newly created account) + * + * @param MailAccountCreatedResponse $response The account response from createFunctionalAccount + * @return MailAccountConfig The mail account configuration with password + */ + private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + $response->getPassword() + ); + } + + /** + * Build mail account configuration from IMAP/SMTP server details + * + * Creates a complete MailAccountConfig object by combining IMAP and SMTP server + * configurations with email credentials. SSL modes are normalized to standard format. + * + * @param Imap $imapServer IMAP server configuration object + * @param Smtp $smtpServer SMTP server configuration object + * @param string $email Email address + * @param string $password Account password + * @return MailAccountConfig Complete mail account configuration + */ + private function buildMailAccountConfig(Imap $imapServer, Smtp $smtpServer, string $email, string $password): MailAccountConfig { + $imapConfig = new MailServerConfig( + host: $imapServer->getHost(), + port: $imapServer->getPort(), + security: $this->normalizeSslMode($imapServer->getSslMode()), + username: $email, + password: $password, + ); + + $smtpConfig = new MailServerConfig( + host: $smtpServer->getHost(), + port: $smtpServer->getPort(), + security: $this->normalizeSslMode($smtpServer->getSslMode()), + username: $email, + password: $password, + ); + + return new MailAccountConfig( + email: $email, + imap: $imapConfig, + smtp: $smtpConfig, + ); + } +} diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryService.php new file mode 100644 index 0000000000..219f27f818 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryService.php @@ -0,0 +1,245 @@ +getCurrentUserId(); + return $this->mailAccountExistsForUserId($userId); + } + + /** + * Check if a specific user has an IONOS email account + * + * @param string $userId The user ID to check + * @return bool true if account exists, false otherwise + */ + public function mailAccountExistsForUserId(string $userId): bool { + $response = $this->getMailAccountResponse($userId); + + if ($response !== null) { + $this->logger->debug('User has existing IONOS mail account', [ + 'email' => $response->getEmail(), + 'userId' => $userId + ]); + return true; + } + + return false; + } + + /** + * Get the IONOS mail account response for a specific user + * + * @param string $userId The Nextcloud user ID + * @return MailAccountResponse|null The account response if it exists, null otherwise + */ + public function getMailAccountResponse(string $userId): ?MailAccountResponse { + try { + $this->logger->debug('Getting IONOS mail account for user', [ + 'userId' => $userId, + 'extRef' => $this->configService->getExternalReference(), + ]); + + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->getFunctionalAccount( + self::BRAND, + $this->configService->getExternalReference(), + $userId + ); + + if ($result instanceof MailAccountResponse) { + return $result; + } + + return null; + } catch (ApiException $e) { + // 404 - no account exists + if ($e->getCode() === self::HTTP_NOT_FOUND) { + $this->logger->debug('No IONOS mail account found for user', [ + 'userId' => $userId, + 'statusCode' => $e->getCode(), + ]); + return null; + } + + // Other errors + $this->logger->error('Error checking IONOS mail account', [ + 'userId' => $userId, + 'statusCode' => $e->getCode(), + 'error' => $e->getMessage(), + ]); + return null; + } catch (\Exception $e) { + $this->logger->error('Unexpected error checking IONOS mail account', [ + 'userId' => $userId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get account configuration for a specific user + * + * @param string $userId The Nextcloud user ID + * @return MailAccountConfig|null Account configuration or null if not found + */ + public function getAccountConfigForUser(string $userId): ?MailAccountConfig { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + return null; + } + + return $this->mapResponseToAccountConfig($response); + } + + /** + * Get account configuration for the current logged-in user + * + * @return MailAccountConfig|null Account configuration or null if not found + */ + public function getAccountConfigForCurrentUser(): ?MailAccountConfig { + $userId = $this->getCurrentUserId(); + return $this->getAccountConfigForUser($userId); + } + + /** + * Get the IONOS email address for a specific user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getIonosEmailForUser(string $userId): ?string { + try { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + $this->logger->debug('No IONOS email found for user', [ + 'userId' => $userId, + ]); + return null; + } + + $email = $response->getEmail(); + $this->logger->debug('Retrieved IONOS email for user', [ + 'userId' => $userId, + 'email' => $email, + ]); + + return $email; + } catch (\Exception $e) { + $this->logger->error('Error getting IONOS email for user', [ + 'userId' => $userId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get the configured mail domain + * + * @return string The mail domain + */ + public function getMailDomain(): string { + return $this->configService->getMailDomain(); + } + + /** + * Get the current user ID from the session + * + * @return string The user ID + * @throws \RuntimeException If no user is logged in + */ + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new \RuntimeException('No user logged in'); + } + return $user->getUID(); + } + + /** + * Create and configure API instance with authentication + * + * @return \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi + */ + private function createApiInstance(): \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi { + $client = $this->apiClientService->newClient([ + 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], + 'verify' => !$this->configService->getAllowInsecure(), + ]); + + return $this->apiClientService->newMailConfigurationAPIApi($client, $this->configService->getApiBaseUrl()); + } + + /** + * Map API response to MailAccountConfig + * + * @param MailAccountResponse $response The API response + * @return MailAccountConfig The mapped configuration + */ + private function mapResponseToAccountConfig(MailAccountResponse $response): MailAccountConfig { + $imapServer = $response->getImap(); + $smtpServer = $response->getSmtp(); + + $imap = new MailServerConfig( + host: $imapServer->getHost(), + port: $imapServer->getPort(), + security: 'tls', // Default, should be normalized from API response + username: $response->getEmail(), + password: $imapServer->getPassword() + ); + + $smtp = new MailServerConfig( + host: $smtpServer->getHost(), + port: $smtpServer->getPort(), + security: 'tls', // Default, should be normalized from API response + username: $response->getEmail(), + password: $smtpServer->getPassword() + ); + + return new MailAccountConfig( + email: $response->getEmail(), + imap: $imap, + smtp: $smtp + ); + } +} diff --git a/lib/Service/IONOS/IonosAccountConflictResolver.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountConflictResolver.php similarity index 96% rename from lib/Service/IONOS/IonosAccountConflictResolver.php rename to lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountConflictResolver.php index 9b77f3913a..32f7002b17 100644 --- a/lib/Service/IONOS/IonosAccountConflictResolver.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountConflictResolver.php @@ -6,7 +6,7 @@ * SPDX-FileCopyrightText: 2025 STRATO GmbH * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS; +namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service; use OCA\Mail\Exception\ServiceException; use Psr\Log\LoggerInterface; diff --git a/lib/Service/IONOS/IonosAccountCreationService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountCreationService.php similarity index 94% rename from lib/Service/IONOS/IonosAccountCreationService.php rename to lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountCreationService.php index 482ebc0db0..85a8062bca 100644 --- a/lib/Service/IONOS/IonosAccountCreationService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountCreationService.php @@ -7,14 +7,14 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS; +namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service; use OCA\Mail\Account; use OCA\Mail\Db\MailAccount; -use OCA\Mail\Exception\IonosServiceException; +use OCA\Mail\Exception\ProviderServiceException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; use OCP\Security\ICrypto; use Psr\Log\LoggerInterface; @@ -47,7 +47,7 @@ public function __construct( * @param string $accountName The display name for the account * @return Account The created or updated mail account * @throws ServiceException If account creation fails - * @throws IonosServiceException If IONOS account creation fails + * @throws ProviderServiceException If IONOS account creation fails */ public function createOrUpdateAccount(string $userId, string $emailUser, string $accountName): Account { $expectedEmail = $this->buildEmailAddress($emailUser); @@ -78,10 +78,9 @@ private function handleExistingAccount(string $userId, string $emailUser, string if (!$resolutionResult->canRetry()) { if ($resolutionResult->hasEmailMismatch()) { - throw new IonosServiceException( + throw new ProviderServiceException( 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), IonosMailService::STATUS__409_CONFLICT, - null, [ 'expectedEmail' => $resolutionResult->getExpectedEmail(), 'existingEmail' => $resolutionResult->getExistingEmail(), @@ -93,8 +92,8 @@ private function handleExistingAccount(string $userId, string $emailUser, string $mailConfig = $resolutionResult->getAccountConfig(); return $this->updateAccount($existingAccount->getMailAccount(), $accountName, $mailConfig); - } catch (IonosServiceException $e) { - // Re-throw IonosServiceException as-is + } catch (ProviderServiceException $e) { + // Re-throw ProviderServiceException as-is throw $e; } catch (ServiceException $e) { throw new ServiceException('Failed to reset IONOS account credentials: ' . $e->getMessage(), $e->getCode(), $e); @@ -131,14 +130,14 @@ private function handleNewAccount(string $userId, string $emailUser, string $acc if (!$resolutionResult->canRetry()) { if ($resolutionResult->hasEmailMismatch()) { - throw new IonosServiceException( + throw new ProviderServiceException( 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), IonosMailService::STATUS__409_CONFLICT, - $e, [ 'expectedEmail' => $resolutionResult->getExpectedEmail(), 'existingEmail' => $resolutionResult->getExistingEmail(), - ] + ], + $e ); } // No existing IONOS account found - re-throw original error diff --git a/lib/Service/IONOS/IonosAccountDeletionService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountDeletionService.php similarity index 98% rename from lib/Service/IONOS/IonosAccountDeletionService.php rename to lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountDeletionService.php index d81c209ce4..6b82a8ddeb 100644 --- a/lib/Service/IONOS/IonosAccountDeletionService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountDeletionService.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS; +namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service; use OCA\Mail\Db\MailAccount; use Psr\Log\LoggerInterface; diff --git a/lib/Service/IONOS/IonosConfigService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosConfigService.php similarity index 93% rename from lib/Service/IONOS/IonosConfigService.php rename to lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosConfigService.php index d72f57f99d..1203ccad9c 100644 --- a/lib/Service/IONOS/IonosConfigService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosConfigService.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS; +namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service; use OCA\Mail\AppInfo\Application; use OCP\Exceptions\AppConfigException; @@ -27,6 +27,11 @@ class IonosConfigService { */ public const APP_NAME = 'NEXTCLOUD_WORKSPACE'; + /** + * App password name suffix for user-level passwords + */ + public const APP_PASSWORD_NAME_USER = 'NEXTCLOUD_WORKSPACE_USER'; + public function __construct( private readonly IConfig $config, private readonly IAppConfig $appConfig, @@ -201,7 +206,8 @@ private function extractMailDomain(string $customerDomain): string { } try { - $publicSuffixList = Rules::fromPath(__DIR__ . '/../../../resources/public_suffix_list.dat'); + $publicSuffixListPath = dirname(__DIR__, 6) . '/resources/public_suffix_list.dat'; + $publicSuffixList = Rules::fromPath($publicSuffixListPath); $domain = Domain::fromIDNA2008($customerDomain); $result = $publicSuffixList->resolve($domain); return $result->registrableDomain()->toString(); diff --git a/lib/Service/IONOS/IonosMailConfigService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailConfigService.php similarity index 83% rename from lib/Service/IONOS/IonosMailConfigService.php rename to lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailConfigService.php index 9e4b7a6706..6497a3da46 100644 --- a/lib/Service/IONOS/IonosMailConfigService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailConfigService.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS; +namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service; use OCA\Mail\Service\AccountService; use OCP\IUserSession; @@ -34,22 +34,25 @@ public function __construct( * 2. The user does NOT already have an IONOS mail account configured remotely * 3. OR the user has a remote IONOS account but it's NOT configured locally in the mail app * + * @param string|null $userId Optional user ID. If not provided, uses current session user * @return bool True if mail configuration should be shown, false otherwise */ - public function isMailConfigAvailable(): bool { + public function isMailConfigAvailable(?string $userId = null): bool { try { // Check if IONOS integration is enabled and configured if (!$this->ionosConfigService->isIonosIntegrationEnabled()) { return false; } - // Get current user - $user = $this->userSession->getUser(); - if ($user === null) { - $this->logger->debug('IONOS mail config not available - no user session'); - return false; + // Get user ID - either from parameter or from session + if ($userId === null) { + $user = $this->userSession->getUser(); + if ($user === null) { + $this->logger->debug('IONOS mail config not available - no user session'); + return false; + } + $userId = $user->getUID(); } - $userId = $user->getUID(); // Check if user already has a remote IONOS account $userHasRemoteAccount = $this->ionosMailService->mailAccountExistsForCurrentUser(); diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailService.php similarity index 98% rename from lib/Service/IONOS/IonosMailService.php rename to lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailService.php index 84c9027271..69f31cd278 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailService.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Service\IONOS; +namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service; use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\ApiException; @@ -18,8 +18,8 @@ use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; use OCP\Exceptions\AppConfigException; use OCP\IUserSession; use Psr\Log\LoggerInterface; diff --git a/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php new file mode 100644 index 0000000000..46e2b6f6b2 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php @@ -0,0 +1,159 @@ +capabilities === null) { + // Get email domain via facade + $emailDomain = $this->facade->getEmailDomain(); + + $this->capabilities = new ProviderCapabilities( + multipleAccounts: false, // IONOS allows only one account per user + appPasswords: true, // IONOS supports app password generation + passwordReset: true, // IONOS supports password reset + configSchema: [ + 'ionos_mailconfig_api_base_url' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Base URL for the IONOS Nextcloud Workspace Mail Configuration API', + ], + 'ionos_mailconfig_api_auth_user' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Basic auth username for IONOS API', + ], + 'ionos_mailconfig_api_auth_pass' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Basic auth password for IONOS API', + ], + 'ionos_mailconfig_api_allow_insecure' => [ + 'type' => 'boolean', + 'required' => false, + 'description' => 'Allow insecure connections (for development)', + ], + 'ncw.ext_ref' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'External reference ID (system config)', + ], + 'ncw.customerDomain' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Customer domain for email addresses (system config)', + ], + ], + creationParameterSchema: [ + 'accountName' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Name', + ], + 'emailUser' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'User', + ], + ], + emailDomain: $emailDomain, + ); + } + return $this->capabilities; + } + + public function isEnabled(): bool { + return $this->facade->isEnabled(); + } + + public function isAvailableForUser(string $userId): bool { + return $this->facade->isAvailableForUser($userId); + } + + public function getExistingAccountEmail(string $userId): ?string { + return $this->facade->getExistingAccountEmail($userId); + } + + public function createAccount(string $userId, array $parameters): Account { + $emailUser = $parameters['emailUser'] ?? ''; + $accountName = $parameters['accountName'] ?? ''; + + if (empty($emailUser) || empty($accountName)) { + throw new \InvalidArgumentException('emailUser and accountName are required'); + } + + return $this->facade->createAccount($userId, $emailUser, $accountName); + } + + public function updateAccount(string $userId, int $accountId, array $parameters): Account { + // For now, use same creation logic which handles updates + return $this->createAccount($userId, $parameters); + } + + public function deleteAccount(string $userId, string $email): bool { + return $this->facade->deleteAccount($userId); + } + + public function managesEmail(string $userId, string $email): bool { + return $this->facade->managesEmail($userId, $email); + } + + public function getProvisionedEmail(string $userId): ?string { + return $this->facade->getProvisionedEmail($userId); + } + + public function generateAppPassword(string $userId): string { + // Check if provider supports app passwords + if (!$this->getCapabilities()->supportsAppPasswords()) { + throw new \InvalidArgumentException('IONOS provider does not support app password generation'); + } + + try { + return $this->facade->generateAppPassword($userId); + } catch (\Exception $e) { + throw new \OCA\Mail\Exception\ProviderServiceException( + 'Failed to generate app password: ' . $e->getMessage(), + 0, + [], + $e + ); + } + } +} diff --git a/lib/Provider/MailAccountProvider/ProviderCapabilities.php b/lib/Provider/MailAccountProvider/ProviderCapabilities.php new file mode 100644 index 0000000000..4668079bfb --- /dev/null +++ b/lib/Provider/MailAccountProvider/ProviderCapabilities.php @@ -0,0 +1,51 @@ +multipleAccounts; + } + + public function supportsAppPasswords(): bool { + return $this->appPasswords; + } + + public function supportsPasswordReset(): bool { + return $this->passwordReset; + } + + public function getConfigSchema(): array { + return $this->configSchema; + } + + public function getCreationParameterSchema(): array { + return $this->creationParameterSchema; + } + + public function getEmailDomain(): ?string { + return $this->emailDomain; + } +} diff --git a/lib/Provider/MailAccountProvider/ProviderRegistryService.php b/lib/Provider/MailAccountProvider/ProviderRegistryService.php new file mode 100644 index 0000000000..d74814fa0e --- /dev/null +++ b/lib/Provider/MailAccountProvider/ProviderRegistryService.php @@ -0,0 +1,171 @@ + */ + private array $providers = []; + + public function __construct( + private LoggerInterface $logger, + ) { + } + + /** + * Register a provider + * + * @param IMailAccountProvider $provider The provider to register + */ + public function registerProvider(IMailAccountProvider $provider): void { + $id = $provider->getId(); + + if (isset($this->providers[$id])) { + $this->logger->warning('Provider already registered, overwriting', [ + 'providerId' => $id, + ]); + } + + $this->providers[$id] = $provider; + $this->logger->debug('Registered mail account provider', [ + 'providerId' => $id, + 'providerName' => $provider->getName(), + ]); + } + + /** + * Get a provider by ID + * + * @param string $providerId The provider ID + * @return IMailAccountProvider|null The provider or null if not found + */ + public function getProvider(string $providerId): ?IMailAccountProvider { + return $this->providers[$providerId] ?? null; + } + + /** + * Get all registered providers + * + * @return array Array of providers indexed by ID + */ + public function getAllProviders(): array { + return $this->providers; + } + + /** + * Get all enabled providers + * + * @return array Array of enabled providers indexed by ID + */ + public function getEnabledProviders(): array { + return array_filter($this->providers, fn (IMailAccountProvider $provider) => $provider->isEnabled()); + } + + /** + * Get all providers available for a specific user + * + * @param string $userId The Nextcloud user ID + * @return array Array of available providers indexed by ID + */ + public function getAvailableProvidersForUser(string $userId): array { + return array_filter( + $this->getEnabledProviders(), + fn (IMailAccountProvider $provider) => $provider->isAvailableForUser($userId) + ); + } + + /** + * Find which provider manages a specific email address + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address + * @return IMailAccountProvider|null The managing provider or null + */ + public function findProviderForEmail(string $userId, string $email): ?IMailAccountProvider { + foreach ($this->getEnabledProviders() as $provider) { + if ($provider->managesEmail($userId, $email)) { + return $provider; + } + } + return null; + } + + /** + * Get provider information for API responses + * + * @return array + */ + public function getProviderInfo(): array { + $info = []; + foreach ($this->providers as $id => $provider) { + $capabilities = $provider->getCapabilities(); + $info[$id] = [ + 'id' => $id, + 'name' => $provider->getName(), + 'enabled' => $provider->isEnabled(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + ], + ]; + } + return $info; + } + + /** + * Delete all provider-managed accounts for a specific user + * + * This method iterates through the user's accounts and deletes those + * that are managed by registered providers. + * + * @param string $userId The Nextcloud user ID + * @param array $accounts List of user's mail accounts + */ + public function deleteProviderManagedAccounts(string $userId, array $accounts): void { + foreach ($accounts as $account) { + $email = $account->getEmail(); + + // Check if this account is managed by a provider + $provider = $this->findProviderForEmail($userId, $email); + if ($provider !== null) { + try { + $this->logger->info('Deleting provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + ]); + + $provider->deleteAccount($userId, $email); + + $this->logger->info('Successfully deleted provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to delete provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + 'exception' => $e, + ]); + // Continue with other accounts even if one fails + } + } + } + } +} diff --git a/lib/Provider/MailAccountProvider/README.md b/lib/Provider/MailAccountProvider/README.md new file mode 100644 index 0000000000..537e90ec12 --- /dev/null +++ b/lib/Provider/MailAccountProvider/README.md @@ -0,0 +1,254 @@ +# Mail Account Provider System + +This directory contains the pluggable mail account provider system for Nextcloud Mail. + +## Overview + +The provider system allows external mail services (like IONOS, Office365, Google Workspace, etc.) to provision mail accounts through their APIs and integrate seamlessly with Nextcloud Mail. + +## Architecture + +``` +lib/Provider/MailAccountProvider/ +├── IMailAccountProvider.php # Main provider interface +├── IProviderCapabilities.php # Capabilities interface +├── ProviderCapabilities.php # Base capabilities implementation +├── ProviderRegistryService.php # Central provider registry +└── Implementations/ + ├── IonosProvider.php # IONOS implementation + └── [Other providers...] +``` + +## Key Interfaces + +### IMailAccountProvider + +Main interface that all providers must implement: + +- `getId()`: Unique provider identifier (e.g., 'ionos', 'office365') +- `getName()`: Human-readable name +- `getCapabilities()`: What features the provider supports +- `isEnabled()`: Is the provider configured and ready to use? +- `isAvailableForUser()`: Can this user create accounts with this provider? +- `createAccount()`: Provision a new mail account +- `updateAccount()`: Update existing account (e.g., reset password) +- `deleteAccount()`: Delete account from provider +- `managesEmail()`: Does this provider manage a specific email address? +- `getProvisionedEmail()`: What email did this provider provision for a user? + +### IProviderCapabilities + +Declares what features a provider supports: + +- `allowsMultipleAccounts()`: Can a user have multiple accounts? +- `supportsAppPasswords()`: Can generate app-specific passwords? +- `supportsPasswordReset()`: Can reset account passwords? +- `getConfigSchema()`: What configuration fields are needed? +- `getCreationParameterSchema()`: What parameters are needed to create an account? + +## Creating a New Provider + +### 1. Create Provider Class + +```php + [ + 'type' => 'string', + 'required' => true, + 'description' => 'API endpoint URL', + ], + 'myprovider_api_key' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'API authentication key', + ], + ], + creationParameterSchema: [ + 'username' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Email username', + ], + 'displayName' => [ + 'type' => 'string', + 'required' => false, + 'description' => 'User display name', + 'default' => '', + ], + ], + ); + } + + public function isEnabled(): bool { + // Check if configuration is valid + try { + $apiUrl = $this->config->getAppValue('mail', 'myprovider_api_url'); + $apiKey = $this->config->getAppValue('mail', 'myprovider_api_key'); + return !empty($apiUrl) && !empty($apiKey); + } catch (\Exception $e) { + return false; + } + } + + public function isAvailableForUser(string $userId): bool { + // Determine if user can create accounts + // E.g., check if they already have one (if multipleAccounts=false) + return true; + } + + public function createAccount(string $userId, array $parameters): Account { + // 1. Validate parameters + $username = $parameters['username'] ?? ''; + if (empty($username)) { + throw new \InvalidArgumentException('username is required'); + } + + // 2. Call external API to provision mailbox + $mailConfig = $this->callProviderAPI($userId, $username); + + // 3. Create Nextcloud Mail account + $account = new MailAccount(); + $account->setUserId($userId); + $account->setEmail($mailConfig['email']); + $account->setInboundHost($mailConfig['imap_host']); + // ... set other properties ... + + return new Account($this->accountService->save($account)); + } + + // Implement other methods... +} +``` + +### 2. Register Provider + +In `lib/AppInfo/Application.php`: + +```php +public function boot(IBootContext $context): void { + $container = $context->getServerContainer(); + $providerRegistry = $container->get(ProviderRegistryService::class); + + // Register your provider + $myProvider = $container->get(MyProvider::class); + $providerRegistry->registerProvider($myProvider); +} +``` + +### 3. Configure Provider + +```bash +# Via occ command +occ config:app:set mail myprovider_api_url --value="https://api.example.com" +occ config:app:set mail myprovider_api_key --value="secret-key" + +# Or via Admin UI (future enhancement) +``` + +### 4. Use Provider + +Your provider will automatically: +- Appear in `GET /api/providers` if enabled +- Be usable via `POST /api/providers/myprovider/accounts` +- Work with generic CLI commands (when implemented) +- Show in UI provider selection (when implemented) + +## API Endpoints + +### Get Available Providers +```http +GET /apps/mail/api/providers +``` + +Returns list of providers available to current user with capabilities and parameter schemas. + +### Create Account +```http +POST /apps/mail/api/providers/{providerId}/accounts +Content-Type: application/json + +{ + "param1": "value1", + "param2": "value2" +} +``` + +Creates a mail account using the specified provider with given parameters. + +### Generate App Password +```http +POST /apps/mail/api/providers/{providerId}/password +Content-Type: application/json + +{ + "accountId": 123 +} +``` + +Generates an app-specific password (if provider supports it). + +## Configuration Storage + +Providers store configuration using standard Nextcloud mechanisms: + +**App Config** (per-app settings): +```php +$this->appConfig->getValueString('mail', 'myprovider_setting'); +$this->appConfig->setValueString('mail', 'myprovider_setting', $value); +``` + +**System Config** (global settings): +```php +$this->config->getSystemValue('myprovider.global_setting'); +$this->config->setSystemValue('myprovider.global_setting', $value); +``` + +## Design Principles + +1. **No Database Changes**: Account metadata derived at runtime +2. **Plug-and-Play**: Providers are self-contained +3. **Declarative**: Capabilities and schemas describe behavior +4. **Safe Defaults**: Errors don't break the app +5. **Backward Compatible**: Existing accounts unaffected + +## Testing + +When creating a provider, test: + +1. **Configuration validation**: isEnabled() works correctly +2. **User availability**: isAvailableForUser() logic +3. **Account creation**: Full flow including API calls +4. **Account deletion**: Cleanup on provider side +5. **Error handling**: API failures, invalid parameters +6. **Email management**: managesEmail() correctly identifies accounts + +## Examples + +See `Implementations/IonosProvider.php` for a complete, production-ready example. + +## Further Reading + +- `PROVIDER_REFACTORING_GUIDE.md`: Architecture and implementation details +- `IMPLEMENTATION_SUMMARY.md`: Current status and next steps +- Core interfaces in this directory for full API documentation diff --git a/lib/Service/AccountProviderService.php b/lib/Service/AccountProviderService.php new file mode 100644 index 0000000000..240edf733d --- /dev/null +++ b/lib/Service/AccountProviderService.php @@ -0,0 +1,100 @@ + $accountJson The account JSON to enhance + * @param string $userId The user ID + * @param string $email The account email address + * @return array The enhanced account JSON + */ + public function addProviderMetadata(array $accountJson, string $userId, string $email): array { + try { + $provider = $this->providerRegistry->findProviderForEmail($userId, $email); + + if ($provider !== null) { + $capabilities = $provider->getCapabilities(); + + $accountJson['managedByProvider'] = $provider->getId(); + $accountJson['providerCapabilities'] = [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ]; + } else { + $accountJson['managedByProvider'] = null; + $accountJson['providerCapabilities'] = null; + } + } catch (\Exception $e) { + $this->logger->debug('Error determining account provider', [ + 'userId' => $userId, + 'email' => $email, + 'exception' => $e, + ]); + + // Safe defaults on error + $accountJson['managedByProvider'] = null; + $accountJson['providerCapabilities'] = null; + } + + return $accountJson; + } + + /** + * Get all providers available for a user + * + * @param string $userId The user ID + * @return array + */ + public function getAvailableProvidersForUser(string $userId): array { + $providers = $this->providerRegistry->getAvailableProvidersForUser($userId); + $result = []; + + foreach ($providers as $provider) { + $capabilities = $provider->getCapabilities(); + $result[$provider->getId()] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ], + 'parameterSchema' => $capabilities->getCreationParameterSchema(), + ]; + } + + return $result; + } +} diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index cac4ef0559..b8b2a0c6e6 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -22,7 +22,7 @@ use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\IMAP\IMAPClientFactory; -use OCA\Mail\Service\IONOS\IonosAccountDeletionService; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; @@ -57,7 +57,7 @@ public function __construct( IMAPClientFactory $imapClientFactory, private readonly IConfig $config, private readonly ITimeFactory $timeFactory, - private readonly IonosAccountDeletionService $ionosAccountDeletionService, + private readonly ProviderRegistryService $providerRegistry, ) { $this->mapper = $mapper; $this->aliasesService = $aliasesService; @@ -153,7 +153,11 @@ public function delete(string $currentUserId, int $accountId): void { } catch (DoesNotExistException $e) { throw new ClientException("Account $accountId does not exist", 0, $e); } - $this->ionosAccountDeletionService->handleMailAccountDeletion($mailAccount); + + // Delete provider-managed accounts + // This works with any registered provider (IONOS, Office365, etc.) + $this->providerRegistry->deleteProviderManagedAccounts($currentUserId, [ $mailAccount ]); + $this->aliasesService->deleteAll($accountId); $this->mapper->delete($mailAccount); } @@ -169,7 +173,11 @@ public function deleteByAccountId(int $accountId): void { } catch (DoesNotExistException $e) { throw new ClientException("Account $accountId does not exist", 0, $e); } - $this->ionosAccountDeletionService->handleMailAccountDeletion($mailAccount); + + // Delete provider-managed accounts + // This works with any registered provider (IONOS, Office365, etc.) + $this->providerRegistry->deleteProviderManagedAccounts($mailAccount->getUserId(), [ $mailAccount ]); + $this->aliasesService->deleteAll($accountId); $this->mapper->delete($mailAccount); } diff --git a/src/components/AccountForm.vue b/src/components/AccountForm.vue index 72ec99fa53..a5852cd802 100644 --- a/src/components/AccountForm.vue +++ b/src/components/AccountForm.vue @@ -199,11 +199,11 @@ required @change="clearFeedback" /> - - @@ -261,7 +261,7 @@ import { import { CONSENT_ABORTED, getUserConsent } from '../integration/oauth.js' import useMainStore from '../store/mainStore.js' import { mapStores, mapState } from 'pinia' -import NewEmailAddressTab from './ionos/NewEmailAddressTab.vue' +import ExternalProviderTab from './ExternalProviderTab.vue' export default { name: 'AccountForm', @@ -274,7 +274,7 @@ export default { ButtonVue, IconLoading, IconCheck, - NewEmailAddressTab, + ExternalProviderTab, }, props: { displayName: { @@ -332,8 +332,8 @@ export default { 'microsoftOauthUrl', ]), - useIonosMailconfig() { - return this.mainStore.getPreference('ionos-mailconfig-enabled', null) + useProviderMailconfig() { + return this.mainStore.getPreference('mail-providers-available', false) }, settingsPage() { diff --git a/src/components/AccountSettings.vue b/src/components/AccountSettings.vue index cd5c4dc443..ba14e109ed 100644 --- a/src/components/AccountSettings.vue +++ b/src/components/AccountSettings.vue @@ -10,6 +10,11 @@ :additional-trap-elements="trapElements" :name="t('mail', 'Account settings')" @update:open="updateOpen"> + + + @@ -112,6 +117,7 @@ import EditorSettings from '../components/EditorSettings.vue' import AccountDefaultsSettings from '../components/AccountDefaultsSettings.vue' import SignatureSettings from '../components/SignatureSettings.vue' import AliasSettings from '../components/AliasSettings.vue' +import ProviderAppPassword from '../components/ProviderAppPassword.vue' import Settings from './quickActions/Settings.vue' import { NcButton, NcAppSettingsDialog as AppSettingsDialog, NcAppSettingsSection as AppSettingsSection } from '@nextcloud/vue' import SieveAccountForm from './SieveAccountForm.vue' @@ -132,6 +138,7 @@ export default { SieveFilterForm, AccountForm, AliasSettings, + ProviderAppPassword, EditorSettings, SignatureSettings, AppSettingsDialog, @@ -169,6 +176,13 @@ export default { email() { return this.account.emailAddress }, + showProviderAppPassword() { + // Show the password reset section if: + // 1. Account is managed by a provider (managedByProvider is set) + // 2. Provider supports app passwords + return this.account.managedByProvider + && this.account.providerCapabilities?.appPasswords === true + }, }, watch: { open(newState, oldState) { diff --git a/src/components/ExternalProviderTab.vue b/src/components/ExternalProviderTab.vue new file mode 100644 index 0000000000..edc7cb8e3a --- /dev/null +++ b/src/components/ExternalProviderTab.vue @@ -0,0 +1,504 @@ + + + + + + diff --git a/src/components/ProviderAppPassword.vue b/src/components/ProviderAppPassword.vue new file mode 100644 index 0000000000..5061bc80ea --- /dev/null +++ b/src/components/ProviderAppPassword.vue @@ -0,0 +1,295 @@ + + + + + + + diff --git a/src/components/ionos/NewEmailAddressTab.vue b/src/components/ionos/NewEmailAddressTab.vue deleted file mode 100644 index 5c65716ab3..0000000000 --- a/src/components/ionos/NewEmailAddressTab.vue +++ /dev/null @@ -1,221 +0,0 @@ - - - - - - - - diff --git a/src/init.js b/src/init.js index bc9529ad6a..a9a851251e 100644 --- a/src/init.js +++ b/src/init.js @@ -38,12 +38,8 @@ export default function initAfterAppCreation() { value: preferences['config-installed-version'], }) mainStore.savePreferenceMutation({ - key: 'ionos-mailconfig-enabled', - value: preferences['ionos-mailconfig-enabled'], - }) - mainStore.savePreferenceMutation({ - key: 'ionos-mailconfig-domain', - value: preferences['ionos-mailconfig-domain'], + key: 'mail-providers-available', + value: preferences['mail-providers-available'], }) mainStore.savePreferenceMutation({ key: 'external-avatars', diff --git a/src/service/ProviderPasswordService.js b/src/service/ProviderPasswordService.js new file mode 100644 index 0000000000..406996321d --- /dev/null +++ b/src/service/ProviderPasswordService.js @@ -0,0 +1,24 @@ +/** + * SPDX-FileCopyrightText: 2025 STRATO GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' + +/** + * @typedef AppPasswordResponse + * @property {string} password the generated app password + */ + +/** + * Generate an app password for a provider-managed account + * + * @param {string} providerId provider identifier (e.g., 'ionos') + * @param {number} accountId id of account + * @return {Promise} + */ +export const generateAppPassword = async (providerId, accountId) => { + const url = generateUrl('/apps/mail/api/providers/{providerId}/password', { providerId }) + + return axios.post(url, { accountId }).then(resp => resp.data) +} diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php index 8f23796b37..7a2bf89ee8 100644 --- a/tests/Unit/AppInfo/ApplicationTest.php +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -1,5 +1,7 @@ application = new Application(); + } + + public function testConstructor(): void { // Not really a test – it's just about code coverage new Application(); $this->addToAssertionCount(1); } + + public function testBootRegistersIonosProvider(): void { + $bootContext = $this->createMock(IBootContext::class); + $serverContainer = $this->createMock(IServerContainer::class); + $providerRegistry = $this->createMock(ProviderRegistryService::class); + $ionosProvider = $this->createMock(IonosProvider::class); + + $bootContext->expects($this->once()) + ->method('getServerContainer') + ->willReturn($serverContainer); + + $serverContainer->expects($this->exactly(2)) + ->method('get') + ->willReturnMap([ + [ProviderRegistryService::class, $providerRegistry], + [IonosProvider::class, $ionosProvider], + ]); + + $providerRegistry->expects($this->once()) + ->method('registerProvider') + ->with($ionosProvider); + + $this->application->boot($bootContext); + } + + public function testBootHandlesExceptionGracefully(): void { + $bootContext = $this->createMock(IBootContext::class); + $serverContainer = $this->createMock(IServerContainer::class); + $logger = $this->createMock(LoggerInterface::class); + + $bootContext->expects($this->once()) + ->method('getServerContainer') + ->willReturn($serverContainer); + + $exception = new \Exception('Provider registration failed'); + + $serverContainer->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(function ($class) use ($exception, $logger) { + if ($class === ProviderRegistryService::class) { + throw $exception; + } + if ($class === LoggerInterface::class) { + return $logger; + } + throw new \Exception('Unexpected class: ' . $class); + }); + + $logger->expects($this->once()) + ->method('error') + ->with('Failed to register mail account providers', [ + 'exception' => $exception, + ]); + + // Should not throw - exception should be caught and logged + $this->application->boot($bootContext); + } } diff --git a/tests/Unit/Command/ProviderCreateAccountTest.php b/tests/Unit/Command/ProviderCreateAccountTest.php new file mode 100644 index 0000000000..edb53c3784 --- /dev/null +++ b/tests/Unit/Command/ProviderCreateAccountTest.php @@ -0,0 +1,271 @@ +providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->command = new ProviderCreateAccount($this->providerRegistry, $this->userManager); + } + + public function testName(): void { + $this->assertSame('mail:provider:create-account', $this->command->getName()); + } + + public function testDescription(): void { + $this->assertSame('Create a mail account via an external provider', $this->command->getDescription()); + } + + public function testExecuteWithInvalidProvider(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'nonexistent'], + ['user-id', 'testuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['param', []], + ]); + + $output = $this->createMock(OutputInterface::class); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $this->providerRegistry->method('getAllProviders') + ->willReturn([]); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } + + public function testExecuteWithInvalidUser(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'nonexistentuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['param', []], + ]); + + $output = $this->createMock(OutputInterface::class); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('nonexistentuser') + ->willReturn(false); + + $output->expects($this->atLeastOnce()) + ->method('writeln') + ->with($this->stringContains('does not exist')); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } + + public function testExecuteWithDisabledProvider(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['param', []], + ]); + + $output = $this->createMock(OutputInterface::class); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('isEnabled')->willReturn(false); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } + + public function testExecuteWithProviderNotAvailableForUser(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['param', []], + ]); + + $output = $this->createMock(OutputInterface::class); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('isAvailableForUser')->with('testuser')->willReturn(false); + $provider->method('getExistingAccountEmail')->with('testuser')->willReturn('test@example.com'); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } + + public function testExecuteWithMissingRequiredParameters(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['param', []], + ]); + + $output = $this->createMock(OutputInterface::class); + + $capabilities = $this->createMock(IProviderCapabilities::class); + $capabilities->method('getCreationParameterSchema') + ->willReturn([ + 'emailUser' => ['required' => true, 'type' => 'string', 'description' => 'Email user'], + 'accountName' => ['required' => true, 'type' => 'string', 'description' => 'Account name'], + ]); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('isAvailableForUser')->with('testuser')->willReturn(true); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } + + public function testExecuteSuccessfully(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['param', ['emailUser=john', 'accountName=John Doe']], + ]); + + $output = $this->createMock(OutputInterface::class); + + $capabilities = $this->createMock(IProviderCapabilities::class); + $capabilities->method('getCreationParameterSchema') + ->willReturn([ + 'emailUser' => ['required' => true, 'type' => 'string', 'description' => 'Email user'], + 'accountName' => ['required' => true, 'type' => 'string', 'description' => 'Account name'], + ]); + + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setEmail('john@example.com'); + $mailAccount->setName('John Doe'); + $mailAccount->setInboundHost('imap.example.com'); + $mailAccount->setInboundPort(993); + $mailAccount->setOutboundHost('smtp.example.com'); + $mailAccount->setOutboundPort(587); + + $account = new Account($mailAccount); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('isAvailableForUser')->with('testuser')->willReturn(true); + $provider->method('getCapabilities')->willReturn($capabilities); + $provider->method('createAccount') + ->with('testuser', ['emailUser' => 'john', 'accountName' => 'John Doe']) + ->willReturn($account); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(0, $result); + } +} diff --git a/tests/Unit/Command/ProviderGenerateAppPasswordTest.php b/tests/Unit/Command/ProviderGenerateAppPasswordTest.php new file mode 100644 index 0000000000..61ceb6c454 --- /dev/null +++ b/tests/Unit/Command/ProviderGenerateAppPasswordTest.php @@ -0,0 +1,251 @@ +providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->command = new ProviderGenerateAppPassword($this->providerRegistry, $this->userManager); + } + + public function testName(): void { + $this->assertSame('mail:provider:generate-app-password', $this->command->getName()); + } + + public function testDescription(): void { + $this->assertSame('Generate a new app password for a provider-managed mail account', $this->command->getDescription()); + } + + public function testExecuteWithInvalidProvider(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'nonexistent'], + ['user-id', 'testuser'], + ]); + + $output = $this->createMock(OutputInterface::class); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $this->providerRegistry->method('getAllProviders') + ->willReturn([]); + + $foundErrorMessage = false; + $output->expects($this->atLeastOnce()) + ->method('writeln') + ->willReturnCallback(function ($message) use (&$foundErrorMessage) { + if (is_string($message) && strpos($message, 'not found') !== false) { + $foundErrorMessage = true; + } + }); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + $this->assertTrue($foundErrorMessage, 'Expected error message containing "not found" was not found'); + } + + public function testExecuteWithInvalidUser(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'nonexistentuser'], + ]); + + $output = $this->createMock(OutputInterface::class); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('nonexistentuser') + ->willReturn(false); + + $output->expects($this->atLeastOnce()) + ->method('writeln') + ->with($this->stringContains('does not exist')); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } + + public function testExecuteWithProviderNotSupportingAppPasswords(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + + $output = $this->createMock(OutputInterface::class); + + $capabilities = $this->createMock(IProviderCapabilities::class); + $capabilities->method('supportsAppPasswords')->willReturn(false); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + $output->expects($this->atLeastOnce()) + ->method('writeln') + ->with($this->stringContains('does not support app password')); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + } + + public function testExecuteWithDisabledProvider(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + + $output = $this->createMock(OutputInterface::class); + + $capabilities = $this->createMock(IProviderCapabilities::class); + $capabilities->method('supportsAppPasswords')->willReturn(true); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getCapabilities')->willReturn($capabilities); + $provider->method('isEnabled')->willReturn(false); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + $foundErrorMessage = false; + $output->expects($this->atLeastOnce()) + ->method('writeln') + ->willReturnCallback(function ($message) use (&$foundErrorMessage) { + if (is_string($message) && strpos($message, 'not enabled') !== false) { + $foundErrorMessage = true; + } + }); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + $this->assertTrue($foundErrorMessage, 'Expected error message containing "not enabled" was not found'); + } + + public function testExecuteWithNoProvisionedAccount(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + + $output = $this->createMock(OutputInterface::class); + + $capabilities = $this->createMock(IProviderCapabilities::class); + $capabilities->method('supportsAppPasswords')->willReturn(true); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getCapabilities')->willReturn($capabilities); + $provider->method('isEnabled')->willReturn(true); + $provider->method('getProvisionedEmail')->with('testuser')->willReturn(null); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + $foundErrorMessage = false; + $output->expects($this->atLeastOnce()) + ->method('writeln') + ->willReturnCallback(function ($message) use (&$foundErrorMessage) { + if (is_string($message) && strpos($message, 'does not have a provisioned account') !== false) { + $foundErrorMessage = true; + } + }); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + $this->assertTrue($foundErrorMessage, 'Expected error message containing "does not have a provisioned account" was not found'); + } + + public function testExecuteSuccessfully(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + + $output = $this->createMock(OutputInterface::class); + + $capabilities = $this->createMock(IProviderCapabilities::class); + $capabilities->method('supportsAppPasswords')->willReturn(true); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getCapabilities')->willReturn($capabilities); + $provider->method('isEnabled')->willReturn(true); + $provider->method('getProvisionedEmail')->with('testuser')->willReturn('test@example.com'); + $provider->method('generateAppPassword')->with('testuser')->willReturn('new-app-password-123'); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(0, $result); + } +} diff --git a/tests/Unit/Command/ProviderListTest.php b/tests/Unit/Command/ProviderListTest.php new file mode 100644 index 0000000000..e6e6d7b83d --- /dev/null +++ b/tests/Unit/Command/ProviderListTest.php @@ -0,0 +1,86 @@ +providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->command = new ProviderList($this->providerRegistry, $this->userManager); + } + + public function testName(): void { + $this->assertSame('mail:provider:list', $this->command->getName()); + } + + public function testDescription(): void { + $this->assertSame('List all registered mail account providers and their capabilities', $this->command->getDescription()); + } + + public function testExecuteWithNoProviders(): void { + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $this->providerRegistry->method('getAllProviders') + ->willReturn([]); + + $output->expects($this->once()) + ->method('writeln') + ->with('No mail account providers are registered.'); + + $result = $this->command->run($input, $output); + $this->assertSame(0, $result); + } + + public function testExecuteWithProviders(): void { + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + // Create mock provider + $capabilities = $this->createMock(IProviderCapabilities::class); + $capabilities->method('allowsMultipleAccounts')->willReturn(false); + $capabilities->method('supportsAppPasswords')->willReturn(true); + $capabilities->method('supportsPasswordReset')->willReturn(true); + $capabilities->method('getEmailDomain')->willReturn('example.com'); + $capabilities->method('getConfigSchema')->willReturn([]); + $capabilities->method('getCreationParameterSchema')->willReturn([]); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getName')->willReturn('IONOS Nextcloud Workspace Mail'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->providerRegistry->method('getAllProviders') + ->willReturn([$provider]); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(0, $result); + } +} diff --git a/tests/Unit/Command/ProviderStatusTest.php b/tests/Unit/Command/ProviderStatusTest.php new file mode 100644 index 0000000000..7d027dac33 --- /dev/null +++ b/tests/Unit/Command/ProviderStatusTest.php @@ -0,0 +1,240 @@ +providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->accountService = $this->createMock(AccountService::class); + $this->command = new ProviderStatus($this->providerRegistry, $this->userManager, $this->accountService); + } + + public function testName(): void { + $this->assertSame('mail:provider:status', $this->command->getName()); + } + + public function testDescription(): void { + $this->assertSame('Check the status and availability of a mail account provider (use -v for detailed information)', $this->command->getDescription()); + } + + public function testExecuteWithInvalidProvider(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'nonexistent'], + ['user-id', null], + ]); + $input->method('getOption') + ->willReturnMap([ + ['verbose', false], + ]); + + $output = $this->createMock(OutputInterface::class); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $this->providerRegistry->method('getAllProviders') + ->willReturn([]); + + $foundErrorMessage = false; + $output->expects($this->atLeastOnce()) + ->method('writeln') + ->willReturnCallback(function ($message) use (&$foundErrorMessage) { + if (is_string($message) && strpos($message, 'not found') !== false) { + $foundErrorMessage = true; + } + }); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + $this->assertTrue($foundErrorMessage, 'Expected error message containing "not found" was not found'); + } + + public function testExecuteWithValidProvider(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', null], + ]); + $input->method('getOption') + ->willReturnMap([ + ['verbose', false], + ]); + + $output = $this->createMock(OutputInterface::class); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getName')->willReturn('IONOS Nextcloud Workspace Mail'); + $provider->method('isEnabled')->willReturn(true); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(0, $result); + } + + public function testExecuteWithUserIdButUserDoesNotExist(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'nonexistentuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['verbose', false], + ]); + + $output = $this->createMock(OutputInterface::class); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getName')->willReturn('IONOS Nextcloud Workspace Mail'); + $provider->method('isEnabled')->willReturn(true); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('nonexistentuser') + ->willReturn(false); + + $foundErrorMessage = false; + $output->expects($this->atLeastOnce()) + ->method('writeln') + ->willReturnCallback(function ($message) use (&$foundErrorMessage) { + if (is_string($message) && strpos($message, 'does not exist') !== false) { + $foundErrorMessage = true; + } + }); + + $result = $this->command->run($input, $output); + $this->assertSame(1, $result); + $this->assertTrue($foundErrorMessage, 'Expected error message containing "does not exist" was not found'); + } + + public function testExecuteWithUserIdAndProviderAvailable(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['verbose', false], + ]); + + $output = $this->createMock(OutputInterface::class); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getName')->willReturn('IONOS Nextcloud Workspace Mail'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('isAvailableForUser')->with('testuser')->willReturn(true); + $provider->method('getExistingAccountEmail')->with('testuser')->willReturn(null); + $provider->method('getProvisionedEmail')->with('testuser')->willReturn(null); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + // No existing account, so findByUserIdAndAddress won't be called + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(0, $result); + } + + public function testExecuteWithUserIdAndExistingAccount(): void { + $input = $this->createMock(InputInterface::class); + $input->method('getArgument') + ->willReturnMap([ + ['provider-id', 'ionos'], + ['user-id', 'testuser'], + ]); + $input->method('getOption') + ->willReturnMap([ + ['verbose', false], + ['output', 'plain'], + ]); + + $output = $this->createMock(OutputInterface::class); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('ionos'); + $provider->method('getName')->willReturn('IONOS Nextcloud Workspace Mail'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('isAvailableForUser')->with('testuser')->willReturn(false); + $provider->method('getExistingAccountEmail')->with('testuser')->willReturn('testuser@example.com'); + $provider->method('getProvisionedEmail')->with('testuser')->willReturn('testuser@example.com'); + + $this->providerRegistry->method('getProvider') + ->with('ionos') + ->willReturn($provider); + + $this->userManager->method('userExists') + ->with('testuser') + ->willReturn(true); + + // Mock existing account + $mailAccount = new MailAccount(); + $mailAccount->setId(42); + $mailAccount->setEmail('testuser@example.com'); + $account = new Account($mailAccount); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with('testuser', 'testuser@example.com') + ->willReturn([$account]); + + $output->expects($this->atLeastOnce()) + ->method('writeln'); + + $result = $this->command->run($input, $output); + $this->assertSame(0, $result); + } +} diff --git a/tests/Unit/Controller/ExternalAccountsControllerTest.php b/tests/Unit/Controller/ExternalAccountsControllerTest.php new file mode 100644 index 0000000000..3c29745ba4 --- /dev/null +++ b/tests/Unit/Controller/ExternalAccountsControllerTest.php @@ -0,0 +1,602 @@ +request = $this->createMock(IRequest::class); + $this->providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->accountProviderService = $this->createMock(AccountProviderService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->controller = new ExternalAccountsController( + $this->appName, + $this->request, + $this->providerRegistry, + $this->accountProviderService, + $this->userSession, + $this->logger, + ); + } + + public function testCreateWithNoUserSession(): void { + $this->userSession->method('getUser') + ->willReturn(null); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + } + + public function testCreateWithProviderNotFound(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->create('nonexistent'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_FOUND', $data['data']['error']); + } + + public function testCreateWithDisabledProvider(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(false); + + $this->providerRegistry->method('getProvider') + ->with('disabled-provider') + ->willReturn($provider); + + $response = $this->controller->create('disabled-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_AVAILABLE', $data['data']['error']); + } + + public function testCreateWithProviderNotAvailableForUser(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->with('testuser') + ->willReturn(false); + $provider->method('getExistingAccountEmail') + ->willReturn(null); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_AVAILABLE', $data['data']['error']); + $this->assertStringContainsString('not available for this user', $data['data']['message']); + $this->assertArrayNotHasKey('existingEmail', $data['data']); + } + + public function testCreateWithProviderNotAvailableForUserWithExistingEmail(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + // Create an anonymous class that extends the provider interface with getExistingAccountEmail + $provider = new class implements IMailAccountProvider { + public function getId(): string { + return 'test-provider'; + } + + public function getName(): string { + return 'Test Provider'; + } + + public function getCapabilities(): \OCA\Mail\Provider\MailAccountProvider\IProviderCapabilities { + return new ProviderCapabilities(false, false); + } + + public function isEnabled(): bool { + return true; + } + + public function isAvailableForUser(string $userId): bool { + return false; + } + + public function createAccount(string $userId, array $parameters): Account { + throw new \RuntimeException('Should not be called'); + } + + public function updateAccount(string $userId, int $accountId, array $parameters): Account { + throw new \RuntimeException('Should not be called'); + } + + public function deleteAccount(string $userId, string $email): bool { + throw new \RuntimeException('Should not be called'); + } + + public function managesEmail(string $userId, string $email): bool { + return false; + } + + public function getProvisionedEmail(string $userId): ?string { + return null; + } + + public function generateAppPassword(string $userId): string { + throw new \RuntimeException('Should not be called'); + } + + public function getExistingAccountEmail(string $userId): ?string { + return 'existing@example.com'; + } + }; + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_AVAILABLE', $data['data']['error']); + $this->assertStringContainsString('not available for this user', $data['data']['message']); + $this->assertEquals('existing@example.com', $data['data']['existingEmail']); + } + + public function testCreateSuccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn([ + 'providerId' => 'test-provider', + '_route' => 'some-route', + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]); + + $mailAccount = new MailAccount(); + $mailAccount->setId(123); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->with('testuser') + ->willReturn(true); + $provider->method('createAccount') + ->with('testuser', [ + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]) + ->willReturn($account); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $accountJson = $account->jsonSerialize(); + $enhancedJson = array_merge($accountJson, [ + 'managedByProvider' => 'test-provider', + 'providerCapabilities' => [ + 'multipleAccounts' => true, + 'appPasswords' => true, + 'passwordReset' => false, + 'emailDomain' => 'example.com', + ], + ]); + + $this->accountProviderService->expects($this->once()) + ->method('addProviderMetadata') + ->with($accountJson, 'testuser', 'user@example.com') + ->willReturn($enhancedJson); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('managedByProvider', $data['data']); + $this->assertEquals('test-provider', $data['data']['managedByProvider']); + $this->assertArrayHasKey('providerCapabilities', $data['data']); + } + + public function testCreateWithInvalidArgumentException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new \InvalidArgumentException('Missing required parameter')); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('INVALID_PARAMETERS', $data['data']['error']); + } + + public function testCreateWithServiceException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new ServiceException('Service error', 500)); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('SERVICE_ERROR', $data['data']['error']); + } + + public function testCreateWithProviderServiceException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new ProviderServiceException('IONOS error', 503, ['detail' => 'API unavailable'])); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('SERVICE_ERROR', $data['data']['error']); + $this->assertEquals('API unavailable', $data['data']['detail']); + } + + public function testGetProviders(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + creationParameterSchema: [ + 'param1' => ['type' => 'string', 'required' => true], + ], + emailDomain: 'example.com', + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('test-provider'); + $provider->method('getName')->willReturn('Test Provider'); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn(['test-provider' => $provider]); + + $response = $this->controller->getProviders(); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('providers', $data['data']); + $this->assertCount(1, $data['data']['providers']); + + $providerInfo = $data['data']['providers'][0]; + $this->assertEquals('test-provider', $providerInfo['id']); + $this->assertEquals('Test Provider', $providerInfo['name']); + $this->assertTrue($providerInfo['capabilities']['multipleAccounts']); + $this->assertTrue($providerInfo['capabilities']['appPasswords']); + $this->assertFalse($providerInfo['capabilities']['passwordReset']); + $this->assertEquals('example.com', $providerInfo['capabilities']['emailDomain']); + } + + public function testGetProvidersWithNoUserSession(): void { + $this->userSession->method('getUser') + ->willReturn(null); + + $response = $this->controller->getProviders(); + + $data = $response->getData(); + $this->assertEquals('error', $data['status']); + } + + public function testGetProvidersWithException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willThrowException(new \Exception('Registry error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error getting available providers', $this->anything()); + + $response = $this->controller->getProviders(); + + $data = $response->getData(); + $this->assertEquals('error', $data['status']); + } + + + public function testGeneratePasswordWithNoAccountId(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(null); + + $response = $this->controller->generatePassword('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + } + + public function testGeneratePasswordWithNoUserSession(): void { + $this->userSession->method('getUser') + ->willReturn(null); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $response = $this->controller->generatePassword('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + } + + + public function testGeneratePasswordWithProviderNotFound(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->generatePassword('nonexistent'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_FOUND', $data['data']['error']); + } + + public function testGeneratePasswordWithProviderNotSupporting(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $capabilities = new ProviderCapabilities( + appPasswords: false, + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getCapabilities') + ->willReturn($capabilities); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->generatePassword('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('NOT_SUPPORTED', $data['data']['error']); + } + + public function testGeneratePasswordSuccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $capabilities = new ProviderCapabilities( + appPasswords: true, + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getCapabilities') + ->willReturn($capabilities); + $provider->method('generateAppPassword') + ->with('testuser') + ->willReturn('generated-app-password-123'); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->generatePassword('test-provider'); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + $this->assertEquals('generated-app-password-123', $data['data']['password']); + } + + public function testGeneratePasswordWithServiceException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $capabilities = new ProviderCapabilities( + appPasswords: true, + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getCapabilities') + ->willReturn($capabilities); + $provider->method('generateAppPassword') + ->willThrowException(new ServiceException('Service error', 500)); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->generatePassword('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('SERVICE_ERROR', $data['data']['error']); + } +} diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php deleted file mode 100644 index 3a0186c66b..0000000000 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ /dev/null @@ -1,327 +0,0 @@ -appName = 'mail'; - $this->request = $this->createMock(IRequest::class); - $this->accountCreationService = $this->createMock(IonosAccountCreationService::class); - $this->userSession = $this->createMock(IUserSession::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->controller = new IonosAccountsController( - $this->appName, - $this->request, - $this->accountCreationService, - $this->userSession, - $this->logger, - ); - } - - /** - * Helper method to setup user session mock - */ - private function setupUserSession(string $userId): void { - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn($userId); - $this->userSession->method('getUser')->willReturn($user); - } - - public function testCreateWithMissingFields(): void { - // Test with empty account name - $response = $this->controller->create('', 'testuser'); - $this->assertEquals(400, $response->getStatus()); - $data = $response->getData(); - $this->assertFalse($data['success']); - $this->assertEquals('All fields are required', $data['message']); - $this->assertEquals('IONOS_API_ERROR', $data['error']); - - // Test with empty email user - $response = $this->controller->create('Test Account', ''); - $this->assertEquals(400, $response->getStatus()); - $data = $response->getData(); - $this->assertFalse($data['success']); - $this->assertEquals('All fields are required', $data['message']); - $this->assertEquals('IONOS_API_ERROR', $data['error']); - } - - public function testCreateSuccess(): void { - $accountName = 'Test Account'; - $emailUser = 'test'; - $emailAddress = 'test@example.com'; - $userId = 'test-user-123'; - - // Setup user session - $this->setupUserSession($userId); - - // Create a real MailAccount instance and wrap it in Account - $mailAccount = new MailAccount(); - $mailAccount->setId(1); - $mailAccount->setUserId($userId); - $mailAccount->setName($accountName); - $mailAccount->setEmail($emailAddress); - - $account = new Account($mailAccount); - - // Verify response matches the expected MailJsonResponse::success() format - $accountResponse = \OCA\Mail\Http\JsonResponse::success($account, 201); - - // Mock account creation service to return a successful account - $this->accountCreationService->expects($this->once()) - ->method('createOrUpdateAccount') - ->with($userId, $emailUser, $accountName) - ->willReturn($account); - - // Verify logging calls - $this->logger - ->expects($this->exactly(2)) - ->method('info') - ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress, $userId) { - static $callCount = 0; - $callCount++; - - if ($callCount === 1) { - $this->assertEquals('Starting IONOS email account creation from web', $message); - $this->assertEquals([ - 'userId' => $userId, - 'emailAddress' => $emailUser, - 'accountName' => $accountName, - ], $context); - } elseif ($callCount === 2) { - $this->assertEquals('Account creation completed successfully', $message); - $this->assertEquals([ - 'emailAddress' => $emailAddress, - 'accountName' => $accountName, - 'accountId' => 1, - 'userId' => $userId, - ], $context); - } - }); - - $response = $this->controller->create($accountName, $emailUser); - - $this->assertEquals($accountResponse, $response); - } - - public function testCreateWithServiceException(): void { - $accountName = 'Test Account'; - $emailUser = 'test'; - $userId = 'test-user-123'; - - // Setup user session - $this->setupUserSession($userId); - - // Mock account creation service to throw ServiceException - $this->accountCreationService->expects($this->once()) - ->method('createOrUpdateAccount') - ->with($userId, $emailUser, $accountName) - ->willThrowException(new ServiceException('Failed to create email account')); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'IONOS service error during account creation: Failed to create email account', - [ - 'error' => 'IONOS_API_ERROR', - 'statusCode' => 0, - 'message' => 'Failed to create email account', - ] - ); - - $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ - 'error' => 'IONOS_API_ERROR', - 'statusCode' => 0, - 'message' => 'Failed to create email account', - ]); - $response = $this->controller->create($accountName, $emailUser); - - self::assertEquals($expectedResponse, $response); - } - - public function testCreateWithServiceExceptionWithStatusCode(): void { - $accountName = 'Test Account'; - $emailUser = 'test'; - $userId = 'test-user-123'; - - // Setup user session - $this->setupUserSession($userId); - - // Mock account creation service to throw ServiceException with status code - $this->accountCreationService->expects($this->once()) - ->method('createOrUpdateAccount') - ->with($userId, $emailUser, $accountName) - ->willThrowException(new ServiceException('Duplicate email account', 409)); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'IONOS service error during account creation: Duplicate email account', - [ - 'error' => 'IONOS_API_ERROR', - 'statusCode' => 409, - 'message' => 'Duplicate email account', - ] - ); - - $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ - 'error' => 'IONOS_API_ERROR', - 'statusCode' => 409, - 'message' => 'Duplicate email account', - ]); - $response = $this->controller->create($accountName, $emailUser); - - self::assertEquals($expectedResponse, $response); - } - - public function testCreateWithIonosServiceExceptionWithAdditionalData(): void { - $accountName = 'Test Account'; - $emailUser = 'test'; - $userId = 'test-user-123'; - - // Setup user session - $this->setupUserSession($userId); - - // Create IonosServiceException with additional data - $additionalData = [ - 'errorCode' => 'DUPLICATE_EMAIL', - 'existingEmail' => 'test@example.com', - 'suggestedAlternative' => 'test2@example.com', - ]; - - // Mock account creation service to throw IonosServiceException with additional data - $this->accountCreationService->expects($this->once()) - ->method('createOrUpdateAccount') - ->with($userId, $emailUser, $accountName) - ->willThrowException(new IonosServiceException('Email already exists', 409, null, $additionalData)); - - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'IONOS service error during account creation: Email already exists', - [ - 'error' => 'IONOS_API_ERROR', - 'statusCode' => 409, - 'message' => 'Email already exists', - 'errorCode' => 'DUPLICATE_EMAIL', - 'existingEmail' => 'test@example.com', - 'suggestedAlternative' => 'test2@example.com', - ] - ); - - $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ - 'error' => 'IONOS_API_ERROR', - 'statusCode' => 409, - 'message' => 'Email already exists', - 'errorCode' => 'DUPLICATE_EMAIL', - 'existingEmail' => 'test@example.com', - 'suggestedAlternative' => 'test2@example.com', - ]); - $response = $this->controller->create($accountName, $emailUser); - - self::assertEquals($expectedResponse, $response); - } - - public function testCreateWithGenericException(): void { - $accountName = 'Test Account'; - $emailUser = 'test'; - $userId = 'test-user-123'; - - // Setup user session - $this->setupUserSession($userId); - - // Mock account creation service to throw a generic exception - $exception = new \Exception('Generic error'); - $this->accountCreationService->expects($this->once()) - ->method('createOrUpdateAccount') - ->with($userId, $emailUser, $accountName) - ->willThrowException($exception); - - // Verify error logging for unexpected exceptions - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'Unexpected error during account creation: Generic error', - [ - 'exception' => $exception, - ] - ); - - $expectedResponse = \OCA\Mail\Http\JsonResponse::error('Could not create account', - 500, - [], - 0 - ); - $response = $this->controller->create($accountName, $emailUser); - - self::assertEquals($expectedResponse, $response); - } - - public function testCreateWithNoUserSession(): void { - $accountName = 'Test Account'; - $emailUser = 'test'; - - // Mock user session to return null (no user logged in) - $this->userSession->method('getUser')->willReturn(null); - - // Should catch the ServiceException thrown by getUserIdOrFail - $this->logger - ->expects($this->once()) - ->method('error') - ->with( - 'IONOS service error during account creation: No user session found during account creation', - [ - 'error' => 'IONOS_API_ERROR', - 'statusCode' => 401, - 'message' => 'No user session found during account creation', - ] - ); - - $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ - 'error' => 'IONOS_API_ERROR', - 'statusCode' => 401, - 'message' => 'No user session found during account creation', - ]); - $response = $this->controller->create($accountName, $emailUser); - - self::assertEquals($expectedResponse, $response); - } -} diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index 234db9b33c..71b7d2b5e8 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -16,13 +16,12 @@ use OCA\Mail\Controller\PageController; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\TagMapper; +use OCA\Mail\Service\AccountProviderService; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\InternalAddressService; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailConfigService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; @@ -115,9 +114,7 @@ class PageControllerTest extends TestCase { private IAvailabilityCoordinator&MockObject $availabilityCoordinator; - private IonosConfigService&MockObject $ionosConfigService; - - private IonosMailConfigService&MockObject $ionosMailConfigService; + private AccountProviderService&MockObject $accountProviderService; protected function setUp(): void { parent::setUp(); @@ -145,8 +142,7 @@ protected function setUp(): void { $this->internalAddressService = $this->createMock(InternalAddressService::class); $this->availabilityCoordinator = $this->createMock(IAvailabilityCoordinator::class); $this->quickActionsService = $this->createMock(QuickActionsService::class); - $this->ionosConfigService = $this->createMock(IonosConfigService::class); - $this->ionosMailConfigService = $this->createMock(IonosMailConfigService::class); + $this->accountProviderService = $this->createMock(AccountProviderService::class); $this->controller = new PageController( $this->appName, @@ -172,8 +168,7 @@ protected function setUp(): void { $this->internalAddressService, $this->availabilityCoordinator, $this->quickActionsService, - $this->ionosConfigService, - $this->ionosMailConfigService, + $this->accountProviderService, ); } @@ -226,6 +221,9 @@ public function testIndex(): void { $account1->expects($this->once()) ->method('getId') ->will($this->returnValue(1)); + $account1->expects($this->once()) + ->method('getEmail') + ->will($this->returnValue('user1@example.com')); $account2->expects($this->once()) ->method('jsonSerialize') ->will($this->returnValue([ @@ -234,6 +232,17 @@ public function testIndex(): void { $account2->expects($this->once()) ->method('getId') ->will($this->returnValue(2)); + $account2->expects($this->once()) + ->method('getEmail') + ->will($this->returnValue('user2@example.com')); + $this->accountProviderService->expects($this->exactly(2)) + ->method('addProviderMetadata') + ->willReturnCallback(function ($json, $userId, $email) { + // Add provider metadata to the account JSON + $json['managedByProvider'] = null; + $json['providerCapabilities'] = null; + return $json; + }); $this->aliasesService->expects($this->exactly(2)) ->method('findAll') ->will($this->returnValueMap([ @@ -243,6 +252,8 @@ public function testIndex(): void { $accountsJson = [ [ 'accountId' => 1, + 'managedByProvider' => null, + 'providerCapabilities' => null, 'aliases' => [ 'a11', 'a12', @@ -253,6 +264,8 @@ public function testIndex(): void { ], [ 'accountId' => 2, + 'managedByProvider' => null, + 'providerCapabilities' => null, 'aliases' => [ 'a21', 'a22', @@ -291,12 +304,10 @@ public function testIndex(): void { $this->returnValue('cron'), $this->returnValue('yes'), ); - $this->ionosMailConfigService->expects($this->once()) - ->method('isMailConfigAvailable') - ->willReturn(false); - $this->ionosConfigService->expects($this->once()) - ->method('getMailDomain') - ->willReturn('example.tld'); + $this->accountProviderService->expects($this->once()) + ->method('getAvailableProvidersForUser') + ->with($this->userId) + ->willReturn([]); $this->aiIntegrationsService->expects(self::exactly(4)) ->method('isLlmProcessingEnabled') ->willReturn(false); @@ -347,8 +358,6 @@ public function testIndex(): void { 'external-avatars' => 'true', 'reply-mode' => 'bottom', 'app-version' => '1.2.3', - 'ionos-mailconfig-enabled' => false, - 'ionos-mailconfig-domain' => 'example.tld', 'collect-data' => 'true', 'start-mailbox-id' => '123', 'tag-classified-messages' => 'false', @@ -356,6 +365,7 @@ public function testIndex(): void { 'layout-mode' => 'vertical-split', 'layout-message-view' => 'threaded', 'follow-up-reminders' => 'true', + 'mail-providers-available' => false, ]], ['prefill_displayName', 'Jane Doe'], ['prefill_email', 'jane@doe.cz'], diff --git a/tests/Unit/Exception/IonosServiceExceptionTest.php b/tests/Unit/Exception/IonosServiceExceptionTest.php deleted file mode 100644 index 390209e9df..0000000000 --- a/tests/Unit/Exception/IonosServiceExceptionTest.php +++ /dev/null @@ -1,72 +0,0 @@ -assertEquals('Test message', $exception->getMessage()); - $this->assertEquals(500, $exception->getCode()); - $this->assertEquals([], $exception->getData()); - } - - public function testConstructorWithData(): void { - $data = [ - 'errorCode' => 'DUPLICATE_EMAIL', - 'email' => 'test@example.com', - 'userId' => 'user123', - ]; - - $exception = new IonosServiceException('Duplicate email', 409, null, $data); - - $this->assertEquals('Duplicate email', $exception->getMessage()); - $this->assertEquals(409, $exception->getCode()); - $this->assertEquals($data, $exception->getData()); - } - - public function testConstructorWithPreviousException(): void { - $previous = new \Exception('Original error'); - $data = ['context' => 'test']; - - $exception = new IonosServiceException('Wrapped error', 500, $previous, $data); - - $this->assertEquals('Wrapped error', $exception->getMessage()); - $this->assertEquals(500, $exception->getCode()); - $this->assertEquals($previous, $exception->getPrevious()); - $this->assertEquals($data, $exception->getData()); - } - - public function testGetDataReturnsEmptyArrayByDefault(): void { - $exception = new IonosServiceException(); - - $this->assertEquals([], $exception->getData()); - } - - public function testGetDataPreservesComplexData(): void { - $data = [ - 'errorCode' => 'VALIDATION_ERROR', - 'fields' => ['email', 'password'], - 'metadata' => [ - 'timestamp' => 1234567890, - 'requestId' => 'req-123', - ], - ]; - - $exception = new IonosServiceException('Validation failed', 400, null, $data); - - $this->assertEquals($data, $exception->getData()); - $this->assertIsArray($exception->getData()['fields']); - $this->assertIsArray($exception->getData()['metadata']); - } -} diff --git a/tests/Unit/Listener/UserDeletedListenerTest.php b/tests/Unit/Listener/UserDeletedListenerTest.php index b027f03f6e..83db0e8a13 100644 --- a/tests/Unit/Listener/UserDeletedListenerTest.php +++ b/tests/Unit/Listener/UserDeletedListenerTest.php @@ -14,8 +14,8 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\ClientException; use OCA\Mail\Listener\UserDeletedListener; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\IonosMailService; use OCP\EventDispatcher\Event; use OCP\IUser; use OCP\User\Events\UserDeletedEvent; @@ -25,7 +25,7 @@ class UserDeletedListenerTest extends TestCase { private AccountService&MockObject $accountService; private LoggerInterface&MockObject $logger; - private IonosMailService&MockObject $ionosMailService; + private ProviderRegistryService&MockObject $providerRegistry; private UserDeletedListener $listener; protected function setUp(): void { @@ -33,12 +33,12 @@ protected function setUp(): void { $this->accountService = $this->createMock(AccountService::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->ionosMailService = $this->createMock(IonosMailService::class); + $this->providerRegistry = $this->createMock(ProviderRegistryService::class); $this->listener = new UserDeletedListener( $this->accountService, $this->logger, - $this->ionosMailService + $this->providerRegistry ); } @@ -61,8 +61,8 @@ public function testImplementsIEventListener(): void { public function testHandleUnrelated(): void { $event = new Event(); - $this->ionosMailService->expects($this->never()) - ->method('tryDeleteEmailAccount'); + $this->providerRegistry->expects($this->never()) + ->method('deleteProviderManagedAccounts'); $this->accountService->expects($this->never()) ->method('findByUserId'); @@ -76,15 +76,15 @@ public function testHandleUserDeletedWithNoAccounts(): void { $user = $this->createUserMock('test-user'); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', []); + $this->accountService->expects($this->never()) ->method('delete'); @@ -99,15 +99,15 @@ public function testHandleUserDeletedWithSingleAccount(): void { $account = $this->createAccountMock(42); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account]); + $this->accountService->expects($this->once()) ->method('delete') ->with('test-user', 42); @@ -125,15 +125,15 @@ public function testHandleUserDeletedWithMultipleAccounts(): void { $account3 = $this->createAccountMock(3); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account1, $account2, $account3]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account1, $account2, $account3]); + $this->accountService->expects($this->exactly(3)) ->method('delete') ->willReturnCallback(function ($userId, $accountId) { @@ -154,15 +154,15 @@ public function testHandleUserDeletedWithClientException(): void { $exception = new ClientException('Test exception'); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account]); + $this->accountService->expects($this->once()) ->method('delete') ->with('test-user', 42) @@ -187,15 +187,15 @@ public function testHandleUserDeletedWithPartialFailure(): void { $exception = new ClientException('Failed to delete account 2'); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account1, $account2, $account3]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account1, $account2, $account3]); + $this->accountService->expects($this->exactly(3)) ->method('delete') ->willReturnCallback(function ($userId, $accountId) use ($exception) { diff --git a/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php b/tests/Unit/Provider/MailAccountProvider/Common/Dto/MailAccountConfigTest.php similarity index 97% rename from tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php rename to tests/Unit/Provider/MailAccountProvider/Common/Dto/MailAccountConfigTest.php index 5b7418a70d..e915904754 100644 --- a/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Common/Dto/MailAccountConfigTest.php @@ -7,11 +7,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS\Dto; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Common\Dto; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; class MailAccountConfigTest extends TestCase { private MailServerConfig $imapConfig; diff --git a/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php b/tests/Unit/Provider/MailAccountProvider/Common/Dto/MailServerConfigTest.php similarity index 97% rename from tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php rename to tests/Unit/Provider/MailAccountProvider/Common/Dto/MailServerConfigTest.php index a5a9b6625b..cfa122cb24 100644 --- a/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Common/Dto/MailServerConfigTest.php @@ -7,10 +7,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS\Dto; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Common\Dto; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; class MailServerConfigTest extends TestCase { private MailServerConfig $config; diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php new file mode 100644 index 0000000000..2d064ae009 --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php @@ -0,0 +1,389 @@ +configService = $this->createMock(IonosConfigService::class); + $this->queryService = $this->createMock(IonosAccountQueryService::class); + $this->mutationService = $this->createMock(IonosAccountMutationService::class); + $this->creationService = $this->createMock(IonosAccountCreationService::class); + $this->mailConfigService = $this->createMock(IonosMailConfigService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->facade = new IonosProviderFacade( + $this->configService, + $this->queryService, + $this->mutationService, + $this->creationService, + $this->mailConfigService, + $this->logger, + ); + } + + public function testIsEnabledReturnsTrue(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $result = $this->facade->isEnabled(); + + $this->assertTrue($result); + } + + public function testIsEnabledReturnsFalse(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(false); + + $result = $this->facade->isEnabled(); + + $this->assertFalse($result); + } + + public function testIsEnabledHandlesException(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willThrowException(new \Exception('Config error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS provider is not enabled', $this->anything()); + + $result = $this->facade->isEnabled(); + + $this->assertFalse($result); + } + + public function testIsAvailableForUserReturnsTrueWhenConfigAvailable(): void { + $userId = 'user123'; + + $this->mailConfigService->expects($this->once()) + ->method('isMailConfigAvailable') + ->with($userId) + ->willReturn(true); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertTrue($result); + } + + public function testIsAvailableForUserReturnsFalseWhenConfigNotAvailable(): void { + $userId = 'user123'; + + $this->mailConfigService->expects($this->once()) + ->method('isMailConfigAvailable') + ->with($userId) + ->willReturn(false); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertFalse($result); + } + + public function testIsAvailableForUserHandlesException(): void { + $userId = 'user123'; + + $this->mailConfigService->expects($this->once()) + ->method('isMailConfigAvailable') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error checking IONOS availability for user', $this->anything()); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertFalse($result); + } + + public function testCreateAccountSuccess(): void { + $userId = 'user123'; + $emailUser = 'john.doe'; + $accountName = 'John Doe'; + $account = $this->createMock(Account::class); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Creating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + $this->creationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); + + $result = $this->facade->createAccount($userId, $emailUser, $accountName); + + $this->assertSame($account, $result); + } + + public function testUpdateAccountSuccess(): void { + $userId = 'user123'; + $emailUser = 'john.doe'; + $accountName = 'John Doe'; + $account = $this->createMock(Account::class); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Updating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + $this->creationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); + + $result = $this->facade->updateAccount($userId, $emailUser, $accountName); + + $this->assertSame($account, $result); + } + + public function testDeleteAccountSuccess(): void { + $userId = 'user123'; + + $this->logger->expects($this->once()) + ->method('info') + ->with('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + $this->mutationService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with($userId); + + $result = $this->facade->deleteAccount($userId); + + $this->assertTrue($result); + } + + public function testDeleteAccountHandlesException(): void { + $userId = 'user123'; + + $this->logger->expects($this->once()) + ->method('info') + ->with('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + $this->mutationService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with($userId) + ->willThrowException(new \Exception('Deletion failed')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error deleting IONOS account via facade', $this->anything()); + + $result = $this->facade->deleteAccount($userId); + + $this->assertFalse($result); + } + + public function testGetProvisionedEmailSuccess(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->getProvisionedEmail($userId); + + $this->assertSame($email, $result); + } + + public function testGetProvisionedEmailHandlesException(): void { + $userId = 'user123'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error getting IONOS provisioned email', $this->anything()); + + $result = $this->facade->getProvisionedEmail($userId); + + $this->assertNull($result); + } + + public function testManagesEmailReturnsTrue(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertTrue($result); + } + + public function testManagesEmailReturnsTrueCaseInsensitive(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + $checkEmail = 'USER@IONOS.COM'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->managesEmail($userId, $checkEmail); + + $this->assertTrue($result); + } + + public function testManagesEmailReturnsFalseWhenNoIonosAccount(): void { + $userId = 'user123'; + $email = 'user@other.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn(null); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertFalse($result); + } + + public function testManagesEmailReturnsFalseWhenDifferentEmail(): void { + $userId = 'user123'; + $ionosEmail = 'user@ionos.com'; + $checkEmail = 'other@ionos.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($ionosEmail); + + $result = $this->facade->managesEmail($userId, $checkEmail); + + $this->assertFalse($result); + } + + public function testManagesEmailHandlesException(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->queryService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error getting IONOS provisioned email', $this->anything()); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertFalse($result); + } + + public function testGetEmailDomainSuccess(): void { + $domain = 'ionos.com'; + + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willReturn($domain); + + $result = $this->facade->getEmailDomain(); + + $this->assertSame($domain, $result); + } + + public function testGetEmailDomainHandlesException(): void { + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willThrowException(new \Exception('Config error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Could not get IONOS email domain', $this->anything()); + + $result = $this->facade->getEmailDomain(); + + $this->assertNull($result); + } + + public function testGenerateAppPasswordSuccess(): void { + $userId = 'user123'; + $appPassword = 'generated-app-password-xyz'; + + $this->logger->expects($this->once()) + ->method('info') + ->with('Generating IONOS app password via facade', [ + 'userId' => $userId, + ]); + + $this->mutationService->expects($this->once()) + ->method('resetAppPassword') + ->with($userId, IonosConfigService::APP_PASSWORD_NAME_USER) + ->willReturn($appPassword); + + $result = $this->facade->generateAppPassword($userId); + + $this->assertSame($appPassword, $result); + } + + public function testGenerateAppPasswordThrowsException(): void { + $userId = 'user123'; + $exception = new \Exception('Password generation failed'); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Generating IONOS app password via facade', [ + 'userId' => $userId, + ]); + + $this->mutationService->expects($this->once()) + ->method('resetAppPassword') + ->with($userId, IonosConfigService::APP_PASSWORD_NAME_USER) + ->willThrowException($exception); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Password generation failed'); + + $this->facade->generateAppPassword($userId); + } +} diff --git a/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/ApiMailConfigClientServiceTest.php similarity index 94% rename from tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php rename to tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/ApiMailConfigClientServiceTest.php index 3cf930e016..e95edad36a 100644 --- a/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/ApiMailConfigClientServiceTest.php @@ -7,13 +7,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations\Ionos\Service; use ChristophWurst\Nextcloud\Testing\TestCase; use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; -use OCA\Mail\Service\IONOS\ApiMailConfigClientService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\ApiMailConfigClientService; class ApiMailConfigClientServiceTest extends TestCase { private ApiMailConfigClientService $service; diff --git a/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/ConflictResolutionResultTest.php similarity index 94% rename from tests/Unit/Service/IONOS/ConflictResolutionResultTest.php rename to tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/ConflictResolutionResultTest.php index a140e4bca1..0c8f6f3f49 100644 --- a/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/ConflictResolutionResultTest.php @@ -7,12 +7,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations\Ionos\Service; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Service\IONOS\ConflictResolutionResult; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\ConflictResolutionResult; class ConflictResolutionResultTest extends TestCase { private MailAccountConfig $accountConfig; diff --git a/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountConflictResolverTest.php similarity index 90% rename from tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php rename to tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountConflictResolverTest.php index 5c8379f2b8..2ab5c818fc 100644 --- a/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountConflictResolverTest.php @@ -7,14 +7,14 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations\Ionos\Service; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; -use OCA\Mail\Service\IONOS\IonosAccountConflictResolver; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailService; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosAccountConflictResolver; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosConfigService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosMailService; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; diff --git a/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountCreationServiceTest.php similarity index 95% rename from tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php rename to tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountCreationServiceTest.php index 3eb96d89c3..655f51a685 100644 --- a/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountCreationServiceTest.php @@ -7,20 +7,20 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations\Ionos\Service; use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; use OCA\Mail\Db\MailAccount; -use OCA\Mail\Exception\IonosServiceException; +use OCA\Mail\Exception\ProviderServiceException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\ConflictResolutionResult; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosAccountConflictResolver; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosAccountCreationService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosMailService; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\ConflictResolutionResult; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; -use OCA\Mail\Service\IONOS\IonosAccountConflictResolver; -use OCA\Mail\Service\IONOS\IonosAccountCreationService; -use OCA\Mail\Service\IONOS\IonosMailService; use OCP\Security\ICrypto; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -192,8 +192,8 @@ public function testCreateOrUpdateAccountExistingAccountEmailMismatch(): void { try { $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); - $this->fail('Expected IonosServiceException to be thrown'); - } catch (IonosServiceException $e) { + $this->fail('Expected ProviderServiceException to be thrown'); + } catch (ProviderServiceException $e) { $this->assertEquals(409, $e->getCode()); $this->assertStringContainsString('IONOS account exists but email mismatch', $e->getMessage()); @@ -486,8 +486,8 @@ public function testCreateOrUpdateAccountNewAccountConflictResolutionEmailMismat try { $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); - $this->fail('Expected IonosServiceException to be thrown'); - } catch (IonosServiceException $e) { + $this->fail('Expected ProviderServiceException to be thrown'); + } catch (ProviderServiceException $e) { $this->assertEquals(409, $e->getCode()); $this->assertStringContainsString('IONOS account exists but email mismatch', $e->getMessage()); $this->assertStringContainsString($emailAddress, $e->getMessage()); diff --git a/tests/Unit/Service/IONOS/IonosAccountDeletionServiceTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountDeletionServiceTest.php similarity index 95% rename from tests/Unit/Service/IONOS/IonosAccountDeletionServiceTest.php rename to tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountDeletionServiceTest.php index 3905180e79..5a93a67637 100644 --- a/tests/Unit/Service/IONOS/IonosAccountDeletionServiceTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosAccountDeletionServiceTest.php @@ -7,12 +7,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations\Ionos\Service; use OCA\Mail\Db\MailAccount; -use OCA\Mail\Service\IONOS\IonosAccountDeletionService; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosAccountDeletionService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosConfigService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosMailService; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Test\TestCase; diff --git a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosConfigServiceTest.php similarity index 96% rename from tests/Unit/Service/IONOS/IonosConfigServiceTest.php rename to tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosConfigServiceTest.php index 8951981ecf..0aeb90f3bf 100644 --- a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosConfigServiceTest.php @@ -7,11 +7,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations\Ionos\Service; use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\AppInfo\Application; -use OCA\Mail\Service\IONOS\IonosConfigService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosConfigService; use OCP\Exceptions\AppConfigException; use OCP\IAppConfig; use OCP\IConfig; @@ -42,6 +42,10 @@ public function testAppNameConstantExists(): void { $this->assertSame('NEXTCLOUD_WORKSPACE', IonosConfigService::APP_NAME); } + public function testAppNameUserConstantExists(): void { + $this->assertSame('NEXTCLOUD_WORKSPACE_USER', IonosConfigService::APP_PASSWORD_NAME_USER); + } + public function testGetExternalReferenceSuccess(): void { $this->config->method('getSystemValue') ->with('ncw.ext_ref') diff --git a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailConfigServiceTest.php similarity index 76% rename from tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php rename to tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailConfigServiceTest.php index e1fdcf980b..b284c66fdf 100644 --- a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailConfigServiceTest.php @@ -7,13 +7,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations\Ionos\Service; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosConfigService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosMailConfigService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosMailService; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailConfigService; -use OCA\Mail\Service\IONOS\IonosMailService; use OCP\IUser; use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; @@ -209,6 +209,61 @@ public function testIsMailConfigAvailableReturnsFalseWhenEmailCannotBeRetrieved( $this->assertFalse($result); } + public function testIsMailConfigAvailableWithExplicitUserIdReturnsTrueWhenUserHasNoRemoteAccount(): void { + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + // When userId is provided, userSession should NOT be called + $this->userSession->expects($this->never()) + ->method('getUser'); + + $this->ionosMailService->expects($this->once()) + ->method('mailAccountExistsForCurrentUser') + ->willReturn(false); + + $this->accountService->expects($this->never()) + ->method('findByUserIdAndAddress'); + + $result = $this->service->isMailConfigAvailable('explicituser'); + + $this->assertTrue($result); + } + + public function testIsMailConfigAvailableWithExplicitUserIdReturnsTrueWhenUserHasRemoteAccountButNotLocal(): void { + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + // When userId is provided, userSession should NOT be called + $this->userSession->expects($this->never()) + ->method('getUser'); + + $this->ionosMailService->expects($this->once()) + ->method('mailAccountExistsForCurrentUser') + ->willReturn(true); + + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('explicituser') + ->willReturn('explicituser@ionos.com'); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with('explicituser', 'explicituser@ionos.com') + ->willReturn([]); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS mail config available - remote account exists but not configured locally', [ + 'email' => 'explicituser@ionos.com', + ]); + + $result = $this->service->isMailConfigAvailable('explicituser'); + + $this->assertTrue($result); + } + public function testIsMailConfigAvailableReturnsFalseOnException(): void { $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailServiceTest.php similarity index 98% rename from tests/Unit/Service/IONOS/IonosMailServiceTest.php rename to tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailServiceTest.php index 2af70670cb..667010a520 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailServiceTest.php @@ -7,7 +7,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Mail\Tests\Unit\Service\IONOS; +namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations\Ionos\Service; use ChristophWurst\Nextcloud\Testing\TestCase; use GuzzleHttp\ClientInterface; @@ -19,10 +19,10 @@ use IONOS\MailConfigurationAPI\Client\Model\MailServer; use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Service\IONOS\ApiMailConfigClientService; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailService; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\ApiMailConfigClientService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosConfigService; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\IonosMailService; use OCP\IUser; use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php new file mode 100644 index 0000000000..7161d0d88e --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php @@ -0,0 +1,374 @@ +facade = $this->createMock(IonosProviderFacade::class); + + $this->provider = new IonosProvider( + $this->facade, + ); + } + + public function testGetId(): void { + $this->assertEquals('ionos', $this->provider->getId()); + } + + public function testGetName(): void { + $this->assertEquals('IONOS Nextcloud Workspace Mail', $this->provider->getName()); + } + + public function testGetCapabilities(): void { + $this->facade->method('getEmailDomain') + ->willReturn('example.com'); + + $capabilities = $this->provider->getCapabilities(); + + $this->assertFalse($capabilities->allowsMultipleAccounts()); + $this->assertTrue($capabilities->supportsAppPasswords()); + $this->assertTrue($capabilities->supportsPasswordReset()); + $this->assertEquals('example.com', $capabilities->getEmailDomain()); + + $configSchema = $capabilities->getConfigSchema(); + $this->assertArrayHasKey('ionos_mailconfig_api_base_url', $configSchema); + $this->assertArrayHasKey('ionos_mailconfig_api_auth_user', $configSchema); + $this->assertArrayHasKey('ionos_mailconfig_api_auth_pass', $configSchema); + + $creationSchema = $capabilities->getCreationParameterSchema(); + $this->assertArrayHasKey('accountName', $creationSchema); + $this->assertArrayHasKey('emailUser', $creationSchema); + } + + public function testGetCapabilitiesWithExceptionOnDomain(): void { + $this->facade->method('getEmailDomain') + ->willReturn(null); + + $capabilities = $this->provider->getCapabilities(); + + $this->assertNull($capabilities->getEmailDomain()); + } + + public function testGetCapabilitiesCached(): void { + $this->facade->expects($this->once()) + ->method('getEmailDomain') + ->willReturn('example.com'); + + // Call twice to test caching + $capabilities1 = $this->provider->getCapabilities(); + $capabilities2 = $this->provider->getCapabilities(); + + $this->assertSame($capabilities1, $capabilities2); + } + + public function testIsEnabledWhenEnabled(): void { + $this->facade->method('isEnabled') + ->willReturn(true); + + $this->assertTrue($this->provider->isEnabled()); + } + + public function testIsEnabledWhenDisabled(): void { + $this->facade->method('isEnabled') + ->willReturn(false); + + $this->assertFalse($this->provider->isEnabled()); + } + + public function testIsAvailableForUserWhenNoAccount(): void { + $this->facade->method('isAvailableForUser') + ->with('testuser') + ->willReturn(true); + + $this->assertTrue($this->provider->isAvailableForUser('testuser')); + } + + public function testIsAvailableForUserWhenHasAccount(): void { + $this->facade->method('isAvailableForUser') + ->with('testuser') + ->willReturn(false); + + $this->assertFalse($this->provider->isAvailableForUser('testuser')); + } + + public function testCreateAccountSuccess(): void { + $userId = 'testuser'; + $parameters = [ + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]; + + $mailAccount = new MailAccount(); + $mailAccount->setId(123); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $this->facade->expects($this->once()) + ->method('createAccount') + ->with($userId, 'user', 'Test Account') + ->willReturn($account); + + $result = $this->provider->createAccount($userId, $parameters); + + $this->assertSame($account, $result); + $this->assertEquals('user@example.com', $result->getEmail()); + } + + public function testCreateAccountWithMissingEmailUser(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'accountName' => 'Test Account', + ]); + } + + public function testCreateAccountWithMissingAccountName(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => 'user', + ]); + } + + public function testCreateAccountWithEmptyEmailUser(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => '', + 'accountName' => 'Test Account', + ]); + } + + public function testCreateAccountWithEmptyAccountName(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => 'user', + 'accountName' => '', + ]); + } + + public function testCreateAccountWithBothEmpty(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => '', + 'accountName' => '', + ]); + } + + public function testCreateAccountWithEmptyArray(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', []); + } + + public function testUpdateAccount(): void { + $userId = 'testuser'; + $accountId = 123; + $parameters = [ + 'emailUser' => 'user', + 'accountName' => 'Updated Account', + ]; + + $mailAccount = new MailAccount(); + $mailAccount->setId($accountId); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $this->facade->expects($this->once()) + ->method('createAccount') + ->with($userId, 'user', 'Updated Account') + ->willReturn($account); + + $result = $this->provider->updateAccount($userId, $accountId, $parameters); + + $this->assertSame($account, $result); + } + + public function testUpdateAccountWithMissingEmailUser(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->updateAccount('testuser', 123, [ + 'accountName' => 'Test Account', + ]); + } + + public function testUpdateAccountWithMissingAccountName(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->updateAccount('testuser', 123, [ + 'emailUser' => 'user', + ]); + } + + public function testUpdateAccountWithEmptyParameters(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->updateAccount('testuser', 123, []); + } + + public function testDeleteAccount(): void { + $this->facade->expects($this->once()) + ->method('deleteAccount') + ->with('testuser') + ->willReturn(true); + + $result = $this->provider->deleteAccount('testuser', 'user@example.com'); + + $this->assertTrue($result); + } + + public function testDeleteAccountReturnsFalse(): void { + $this->facade->expects($this->once()) + ->method('deleteAccount') + ->with('testuser') + ->willReturn(false); + + $result = $this->provider->deleteAccount('testuser', 'user@example.com'); + + $this->assertFalse($result); + } + + public function testManagesEmailWhenMatches(): void { + $this->facade->method('managesEmail') + ->with('testuser', 'user@example.com') + ->willReturn(true); + + $this->assertTrue($this->provider->managesEmail('testuser', 'user@example.com')); + } + + public function testManagesEmailCaseInsensitive(): void { + $this->facade->method('managesEmail') + ->with('testuser', 'USER@EXAMPLE.COM') + ->willReturn(true); + + $this->assertTrue($this->provider->managesEmail('testuser', 'USER@EXAMPLE.COM')); + } + + public function testManagesEmailWhenDoesNotMatch(): void { + $this->facade->method('managesEmail') + ->with('testuser', 'other@example.com') + ->willReturn(false); + + $this->assertFalse($this->provider->managesEmail('testuser', 'other@example.com')); + } + + public function testManagesEmailWhenNoIonosEmail(): void { + $this->facade->method('managesEmail') + ->with('testuser', 'user@example.com') + ->willReturn(false); + + $this->assertFalse($this->provider->managesEmail('testuser', 'user@example.com')); + } + + public function testGetProvisionedEmail(): void { + $this->facade->method('getProvisionedEmail') + ->with('testuser') + ->willReturn('user@example.com'); + + $result = $this->provider->getProvisionedEmail('testuser'); + + $this->assertEquals('user@example.com', $result); + } + + public function testGetProvisionedEmailWithNoEmail(): void { + $this->facade->method('getProvisionedEmail') + ->with('testuser') + ->willReturn(null); + + $result = $this->provider->getProvisionedEmail('testuser'); + $this->assertNull($result); + } + + public function testGenerateAppPasswordSuccess(): void { + $userId = 'testuser'; + $expectedPassword = 'generated-app-password-123'; + + // Mock getEmailDomain for getCapabilities() call + $this->facade->method('getEmailDomain') + ->willReturn('example.com'); + + $this->facade->expects($this->once()) + ->method('generateAppPassword') + ->with($userId) + ->willReturn($expectedPassword); + + $result = $this->provider->generateAppPassword($userId); + + $this->assertEquals($expectedPassword, $result); + } + + public function testGenerateAppPasswordWithException(): void { + $userId = 'testuser'; + $exception = new \Exception('API error'); + + // Mock getEmailDomain for getCapabilities() call + $this->facade->method('getEmailDomain') + ->willReturn('example.com'); + + $this->facade->method('generateAppPassword') + ->with($userId) + ->willThrowException($exception); + + + $this->expectException(\OCA\Mail\Exception\ProviderServiceException::class); + $this->expectExceptionMessage('Failed to generate app password: API error'); + + $this->provider->generateAppPassword($userId); + } + + public function testGenerateAppPasswordWhenNotSupported(): void { + $userId = 'testuser'; + + // Create a provider with a facade that returns null domain + // which will cause getCapabilities to be called + $this->facade->method('getEmailDomain') + ->willReturn('example.com'); + + // Create a mock provider that doesn't support app passwords + // We need to use reflection or create a test double + $capabilities = $this->createMock(\OCA\Mail\Provider\MailAccountProvider\IProviderCapabilities::class); + $capabilities->method('supportsAppPasswords') + ->willReturn(false); + + // Use reflection to inject the capabilities + $reflection = new \ReflectionClass($this->provider); + $property = $reflection->getProperty('capabilities'); + $property->setAccessible(true); + $property->setValue($this->provider, $capabilities); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('IONOS provider does not support app password generation'); + + $this->provider->generateAppPassword($userId); + } +} diff --git a/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php b/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php new file mode 100644 index 0000000000..0ed0176ed3 --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php @@ -0,0 +1,265 @@ +logger = $this->createMock(LoggerInterface::class); + $this->registry = new ProviderRegistryService($this->logger); + } + + public function testRegisterProvider(): void { + $provider = $this->createMockProvider('test', 'Test Provider'); + + $this->registry->registerProvider($provider); + + $this->assertEquals($provider, $this->registry->getProvider('test')); + } + + public function testGetProviderReturnsNullForUnknownId(): void { + $result = $this->registry->getProvider('unknown'); + + $this->assertNull($result); + } + + public function testGetAllProviders(): void { + $provider1 = $this->createMockProvider('test1', 'Test Provider 1'); + $provider2 = $this->createMockProvider('test2', 'Test Provider 2'); + + $this->registry->registerProvider($provider1); + $this->registry->registerProvider($provider2); + + $providers = $this->registry->getAllProviders(); + + $this->assertCount(2, $providers); + $this->assertArrayHasKey('test1', $providers); + $this->assertArrayHasKey('test2', $providers); + } + + public function testGetEnabledProviders(): void { + $enabledProvider = $this->createMockProvider('enabled', 'Enabled', true); + $disabledProvider = $this->createMockProvider('disabled', 'Disabled', false); + + $this->registry->registerProvider($enabledProvider); + $this->registry->registerProvider($disabledProvider); + + $enabled = $this->registry->getEnabledProviders(); + + $this->assertCount(1, $enabled); + $this->assertArrayHasKey('enabled', $enabled); + $this->assertArrayNotHasKey('disabled', $enabled); + } + + public function testGetAvailableProvidersForUser(): void { + $availableProvider = $this->createMockProvider('available', 'Available', true, true); + $unavailableProvider = $this->createMockProvider('unavailable', 'Unavailable', true, false); + + $this->registry->registerProvider($availableProvider); + $this->registry->registerProvider($unavailableProvider); + + $available = $this->registry->getAvailableProvidersForUser('testuser'); + + $this->assertCount(1, $available); + $this->assertArrayHasKey('available', $available); + } + + public function testFindProviderForEmail(): void { + $matchingProvider = $this->createMockProvider('matching', 'Matching', true); + $matchingProvider->method('managesEmail') + ->willReturn(true); + + $nonMatchingProvider = $this->createMockProvider('nonmatching', 'Non-Matching', true); + $nonMatchingProvider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($matchingProvider); + $this->registry->registerProvider($nonMatchingProvider); + + $result = $this->registry->findProviderForEmail('user', 'test@example.com'); + + $this->assertEquals($matchingProvider, $result); + } + + public function testFindProviderForEmailReturnsNullIfNoneMatch(): void { + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($provider); + + $result = $this->registry->findProviderForEmail('user', 'test@example.com'); + + $this->assertNull($result); + } + + public function testGetProviderInfo(): void { + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('test'); + $provider->method('getName')->willReturn('Test Provider'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->registry->registerProvider($provider); + + $info = $this->registry->getProviderInfo(); + + $this->assertArrayHasKey('test', $info); + $this->assertEquals('test', $info['test']['id']); + $this->assertEquals('Test Provider', $info['test']['name']); + $this->assertTrue($info['test']['enabled']); + $this->assertTrue($info['test']['capabilities']['multipleAccounts']); + $this->assertTrue($info['test']['capabilities']['appPasswords']); + $this->assertFalse($info['test']['capabilities']['passwordReset']); + } + + public function testDeleteProviderManagedAccountsWithNoProviderManaged(): void { + $userId = 'testuser'; + $account = $this->createMockAccount('user@example.com'); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($provider); + + $provider->expects($this->never()) + ->method('deleteAccount'); + + $this->registry->deleteProviderManagedAccounts($userId, [$account]); + } + + public function testDeleteProviderManagedAccountsWithProviderManaged(): void { + $userId = 'testuser'; + $email = 'user@example.com'; + $account = $this->createMockAccount($email); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->with($userId, $email) + ->willReturn(true); + $provider->expects($this->once()) + ->method('deleteAccount') + ->with($userId, $email); + + $this->registry->registerProvider($provider); + + $this->registry->deleteProviderManagedAccounts($userId, [$account]); + } + + public function testDeleteProviderManagedAccountsWithMultipleAccounts(): void { + $userId = 'testuser'; + $email1 = 'user1@example.com'; + $email2 = 'user2@example.com'; + $email3 = 'user3@example.com'; + + $account1 = $this->createMockAccount($email1); + $account2 = $this->createMockAccount($email2); + $account3 = $this->createMockAccount($email3); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturnMap([ + [$userId, $email1, true], + [$userId, $email2, false], + [$userId, $email3, true], + ]); + $provider->expects($this->exactly(2)) + ->method('deleteAccount') + ->willReturnCallback(function ($uid, $email) use ($userId, $email1, $email3) { + $this->assertSame($userId, $uid); + $this->assertContains($email, [$email1, $email3]); + return true; + }); + + $this->registry->registerProvider($provider); + + $this->registry->deleteProviderManagedAccounts($userId, [$account1, $account2, $account3]); + } + + public function testDeleteProviderManagedAccountsContinuesOnException(): void { + $userId = 'testuser'; + $email1 = 'user1@example.com'; + $email2 = 'user2@example.com'; + + $account1 = $this->createMockAccount($email1); + $account2 = $this->createMockAccount($email2); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(true); + $provider->expects($this->exactly(2)) + ->method('deleteAccount') + ->willReturnCallback(function ($uid, $email) use ($email1) { + if ($email === $email1) { + throw new \Exception('Deletion failed'); + } + return true; + }); + + $this->registry->registerProvider($provider); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Failed to delete provider-managed account', $this->anything()); + + // Should not throw exception, continues with second account + $this->registry->deleteProviderManagedAccounts($userId, [$account1, $account2]); + } + + private function createMockAccount(string $email): object { + return new class($email) { + private string $email; + + public function __construct(string $email) { + $this->email = $email; + } + + public function getEmail(): string { + return $this->email; + } + }; + } + + private function createMockProvider( + string $id, + string $name, + bool $enabled = true, + bool $availableForUser = true, + ): IMailAccountProvider&MockObject { + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn($id); + $provider->method('getName')->willReturn($name); + $provider->method('isEnabled')->willReturn($enabled); + $provider->method('isAvailableForUser')->willReturn($availableForUser); + + $capabilities = new ProviderCapabilities(); + $provider->method('getCapabilities')->willReturn($capabilities); + + return $provider; + } +} diff --git a/tests/Unit/Service/AccountProviderServiceTest.php b/tests/Unit/Service/AccountProviderServiceTest.php new file mode 100644 index 0000000000..f66d1f7d25 --- /dev/null +++ b/tests/Unit/Service/AccountProviderServiceTest.php @@ -0,0 +1,182 @@ +providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new AccountProviderService( + $this->providerRegistry, + $this->logger, + ); + } + + public function testAddProviderMetadataWithNoProvider(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willReturn(null); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + $this->assertArrayHasKey('managedByProvider', $result); + $this->assertNull($result['managedByProvider']); + $this->assertArrayHasKey('providerCapabilities', $result); + $this->assertNull($result['providerCapabilities']); + } + + public function testAddProviderMetadataWithProvider(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + emailDomain: 'example.com', + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId') + ->willReturn('test-provider'); + $provider->method('getCapabilities') + ->willReturn($capabilities); + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willReturn($provider); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + $this->assertEquals('test-provider', $result['managedByProvider']); + $this->assertIsArray($result['providerCapabilities']); + $this->assertTrue($result['providerCapabilities']['multipleAccounts']); + $this->assertTrue($result['providerCapabilities']['appPasswords']); + $this->assertFalse($result['providerCapabilities']['passwordReset']); + $this->assertEquals('example.com', $result['providerCapabilities']['emailDomain']); + } + + public function testAddProviderMetadataWithException(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willThrowException(new \Exception('Test exception')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error determining account provider', $this->anything()); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + // Should return safe defaults + $this->assertNull($result['managedByProvider']); + $this->assertNull($result['providerCapabilities']); + } + + public function testGetAvailableProvidersForUser(): void { + $capabilities1 = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + creationParameterSchema: [ + 'param1' => ['type' => 'string', 'required' => true], + ], + emailDomain: 'example.com', + ); + + $capabilities2 = new ProviderCapabilities( + multipleAccounts: false, + appPasswords: false, + passwordReset: true, + creationParameterSchema: [ + 'param2' => ['type' => 'string', 'required' => false], + ], + emailDomain: 'test.com', + ); + + $provider1 = $this->createMock(IMailAccountProvider::class); + $provider1->method('getId')->willReturn('provider1'); + $provider1->method('getName')->willReturn('Provider 1'); + $provider1->method('getCapabilities')->willReturn($capabilities1); + + $provider2 = $this->createMock(IMailAccountProvider::class); + $provider2->method('getId')->willReturn('provider2'); + $provider2->method('getName')->willReturn('Provider 2'); + $provider2->method('getCapabilities')->willReturn($capabilities2); + + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn([ + 'provider1' => $provider1, + 'provider2' => $provider2, + ]); + + $result = $this->service->getAvailableProvidersForUser('testuser'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('provider1', $result); + $this->assertArrayHasKey('provider2', $result); + + // Check provider1 + $this->assertEquals('provider1', $result['provider1']['id']); + $this->assertEquals('Provider 1', $result['provider1']['name']); + $this->assertTrue($result['provider1']['capabilities']['multipleAccounts']); + $this->assertTrue($result['provider1']['capabilities']['appPasswords']); + $this->assertFalse($result['provider1']['capabilities']['passwordReset']); + $this->assertEquals('example.com', $result['provider1']['capabilities']['emailDomain']); + $this->assertArrayHasKey('param1', $result['provider1']['parameterSchema']); + + // Check provider2 + $this->assertEquals('provider2', $result['provider2']['id']); + $this->assertEquals('Provider 2', $result['provider2']['name']); + $this->assertFalse($result['provider2']['capabilities']['multipleAccounts']); + $this->assertFalse($result['provider2']['capabilities']['appPasswords']); + $this->assertTrue($result['provider2']['capabilities']['passwordReset']); + $this->assertEquals('test.com', $result['provider2']['capabilities']['emailDomain']); + $this->assertArrayHasKey('param2', $result['provider2']['parameterSchema']); + } + + public function testGetAvailableProvidersForUserWithNoProviders(): void { + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn([]); + + $result = $this->service->getAvailableProvidersForUser('testuser'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} diff --git a/tests/Unit/Service/AccountServiceTest.php b/tests/Unit/Service/AccountServiceTest.php index 2cf25638b4..4731e3b853 100644 --- a/tests/Unit/Service/AccountServiceTest.php +++ b/tests/Unit/Service/AccountServiceTest.php @@ -17,9 +17,9 @@ use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Exception\ClientException; use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AliasesService; -use OCA\Mail\Service\IONOS\IonosAccountDeletionService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\IConfig; @@ -62,7 +62,7 @@ class AccountServiceTest extends TestCase { private IConfig&MockObject $config; private ITimeFactory&MockObject $time; - private IonosAccountDeletionService&MockObject $ionosAccountDeletionService; + private ProviderRegistryService&MockObject $providerRegistry; protected function setUp(): void { parent::setUp(); @@ -74,7 +74,7 @@ protected function setUp(): void { $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); $this->config = $this->createMock(IConfig::class); $this->time = $this->createMock(ITimeFactory::class); - $this->ionosAccountDeletionService = $this->createMock(IonosAccountDeletionService::class); + $this->providerRegistry = $this->createMock(ProviderRegistryService::class); $this->accountService = new AccountService( $this->mapper, $this->aliasesService, @@ -82,7 +82,7 @@ protected function setUp(): void { $this->imapClientFactory, $this->config, $this->time, - $this->ionosAccountDeletionService, + $this->providerRegistry, ); $this->account1 = new MailAccount(); @@ -143,9 +143,9 @@ public function testFindById() { public function testDelete() { $accountId = 33; - $this->ionosAccountDeletionService->expects($this->once()) - ->method('handleMailAccountDeletion') - ->with($this->account1); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with($this->user, [$this->account1]); $this->mapper->expects($this->once()) ->method('find') @@ -160,15 +160,17 @@ public function testDelete() { public function testDeleteByAccountId() { $accountId = 33; - - $this->ionosAccountDeletionService->expects($this->once()) - ->method('handleMailAccountDeletion') - ->with($this->account1); + $this->account1->setUserId($this->user); $this->mapper->expects($this->once()) ->method('findById') ->with($accountId) ->will($this->returnValue($this->account1)); + + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with($this->user, [$this->account1]); + $this->mapper->expects($this->once()) ->method('delete') ->with($this->account1);