diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..43265a402 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,197 @@ +name: CI + +on: + push: + pull_request: + +jobs: + php-lint: + name: PHP Lint (own code) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: gd, curl, soap, mbstring, intl, mysqli, pdo_mysql + coverage: none + + - name: Lint changed/own PHP files + shell: bash + run: | + set -e + # Lint our own code only — vendor/ is third-party + # Focused on areas touched by this branch + general project code + paths=( + "classes" + "www/pages" + "www/lib" + "cronjobs" + "migrations" + "upgrade/data/upgrade.php" + ) + fail=0 + for p in "${paths[@]}"; do + if [ -e "$p" ]; then + while IFS= read -r -d '' f; do + if ! php -l "$f" >/dev/null 2>&1; then + echo "::error file=$f::PHP syntax error" + php -l "$f" || true + fail=1 + fi + done < <(find "$p" -type f -name '*.php' -print0) + fi + done + exit $fail + + json-validate: + name: Validate JSON files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate db_schema.json + run: python3 -c "import json,sys; json.load(open('upgrade/data/db_schema.json')); print('db_schema.json OK')" + + - name: Validate composer.json + run: python3 -c "import json,sys; json.load(open('composer.json')); print('composer.json OK')" + + db-schema: + name: Database schema integrity + runs-on: ubuntu-latest + services: + mariadb: + image: mariadb:10.6 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: openxe_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -uroot -proot --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + steps: + - uses: actions/checkout@v4 + + - name: Wait for MariaDB + run: | + for i in {1..30}; do + if mysqladmin ping -h127.0.0.1 -uroot -proot --silent; then + echo "MariaDB is up"; exit 0 + fi + sleep 2 + done + echo "MariaDB did not start"; exit 1 + + - name: Import database/struktur.sql + run: | + # struktur.sql is a mysqldump containing seed INSERTs that omit some + # NOT NULL columns without defaults. Disable STRICT mode for the + # import session, matching how the OpenXE installer ingests it. + mysql -h127.0.0.1 -uroot -proot --init-command="SET SESSION sql_mode=''" openxe_test < database/struktur.sql + + - name: Verify Office365 tables exist + run: | + set -e + for t in office365_account office365_access_token office365_account_property office365_account_scope; do + count=$(mysql -h127.0.0.1 -uroot -proot -N -B -e \ + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='openxe_test' AND table_name='$t';") + if [ "$count" -ne 1 ]; then + echo "::error::Missing Office365 table: $t" + exit 1 + fi + echo "OK: $t" + done + + - name: Verify Office365 migration is idempotent + run: | + # The migration uses CREATE TABLE IF NOT EXISTS, so re-running on top + # of struktur.sql must not fail. The ALTER TABLE foreign-key statements + # may already exist; allow that specific case. + mysql -h127.0.0.1 -uroot -proot --init-command="SET SESSION sql_mode=''" openxe_test < migrations/office365_oauth_tables.sql || \ + echo "Migration ALTER statements may duplicate existing FKs (expected on top of struktur.sql)." + + - name: Cross-check db_schema.json vs struktur.sql + run: | + python3 - <<'PY' + import json, re, sys + + schema = json.load(open('upgrade/data/db_schema.json')) + schema_tables = {t['name'] for t in schema.get('tables', [])} + + sql = open('database/struktur.sql', encoding='utf-8', errors='ignore').read() + sql_tables = set(re.findall(r'CREATE TABLE `([^`]+)`', sql)) + + only_in_schema = sorted(schema_tables - sql_tables) + only_in_sql = sorted(sql_tables - schema_tables) + + if only_in_schema: + print('Tables in db_schema.json but missing in struktur.sql:') + for t in only_in_schema: print(f' - {t}') + if only_in_sql: + print('Tables in struktur.sql but missing in db_schema.json:') + for t in only_in_sql: print(f' - {t}') + + # Hard requirement: Office365 tables must be in BOTH files + required = {'office365_account', 'office365_access_token', + 'office365_account_property', 'office365_account_scope'} + missing_schema = required - schema_tables + missing_sql = required - sql_tables + if missing_schema or missing_sql: + if missing_schema: print(f'::error::Missing in db_schema.json: {sorted(missing_schema)}') + if missing_sql: print(f'::error::Missing in struktur.sql: {sorted(missing_sql)}') + sys.exit(1) + print('Office365 tables present in both schema files.') + PY + + office365-module: + name: Office365Api module smoke check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: gd, curl, soap, mbstring, intl + coverage: none + + - name: Lint Office365Api module files + run: | + set -e + for f in $(find classes/Modules/Office365Api -type f -name '*.php'); do + php -l "$f" + done + + - name: Lint Office365 transport + auth integration + run: | + set -e + php -l classes/Components/Mailer/Transport/Office365SmtpTransport.php + php -l classes/Components/Mailer/Exception/Office365SmtpException.php + php -l classes/Modules/SystemMailer/Transport/PhpMailerOffice365Authentification.php + php -l classes/Modules/SystemMailer/SystemMailer.php + php -l classes/Modules/SystemMailer/Service/MailerTransportFactory.php + php -l classes/Modules/SystemMailer/Data/EmailBackupAccount.php + + - name: Verify AUTH_OFFICE365 wiring + run: | + set -e + # Constant defined + grep -q "AUTH_OFFICE365 = 'oauth_office365'" classes/Modules/SystemMailer/Data/EmailBackupAccount.php + + # Allowed in SystemMailer::composeAndSendEmail + grep -q "AUTH_OFFICE365" classes/Modules/SystemMailer/SystemMailer.php + + # Handled by transport factory + grep -q "AUTH_OFFICE365" classes/Modules/SystemMailer/Service/MailerTransportFactory.php + + # UI dropdown contains the option + grep -q "oauth_office365" www/pages/emailbackup.php + + echo "AUTH_OFFICE365 wired through Account, SystemMailer, Factory, and UI." diff --git a/.gitignore b/.gitignore index 00dc3a55a..9ada823a5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ www/cache/ node_modules/ www/themes/new/css/custom.css docker-compose.override.yml -.idea \ No newline at end of file +.idea +.claude/ diff --git a/OFFICE365_OAUTH2.md b/OFFICE365_OAUTH2.md new file mode 100644 index 000000000..c51b7ffd4 --- /dev/null +++ b/OFFICE365_OAUTH2.md @@ -0,0 +1,342 @@ +# Office365 OAuth2 Integration + +This document describes how to set up and use Microsoft Office365 OAuth2 authentication for email accounts in OpenXE, and explains the underlying implementation. + +The integration provides: +- OAuth2 authorization code grant flow with automatic token refresh +- Secure credential storage (no passwords required) +- A custom SMTP client supporting Office365's case-sensitive `XOAUTH2` mechanism +- Full support for HTML, plain text and attachments via MIME + +--- + +## Part 1 — Setup Guide + +### Prerequisites + +- Microsoft Azure account with admin access +- Running OpenXE installation with database access + +### Step 1: Azure App Registration + +#### 1.1 Register an Application + +1. Go to the [Azure Portal](https://portal.azure.com/) +2. Navigate to **Azure Active Directory → App registrations → New registration** +3. Enter: + - **Name**: `OpenXE Office365 Integration` (or similar) + - **Supported account types**: *Accounts in any organizational directory and personal Microsoft accounts* +4. Click **Register** + +#### 1.2 Create Client Secret + +1. Open **Certificates & secrets → New client secret** +2. Description: `OpenXE Integration` +3. Expiry: 24 months (or as desired) +4. **Copy the Value** (not the Secret ID) — this is your `CLIENT_SECRET` + +#### 1.3 Configure API Permissions + +1. Open **API permissions → Add a permission → Microsoft Graph → Delegated permissions** +2. Add: + - `Mail.Read` + - `Mail.ReadWrite` + - `Mail.Send` + - `offline_access` (required for refresh tokens) +3. Click **Grant admin consent for [Your Organization]** + +#### 1.4 Configure Redirect URI + +1. Open **Authentication → Add URI** +2. Enter your callback URL: + ``` + https://your-openxe-domain.com/index.php?module=emailbackup&action=oauth_callback&provider=office365 + ``` +3. Save + +#### 1.5 Collect Required Values + +From the **Overview** page note: +- **Application (client) ID** → `CLIENT_ID` +- **Directory (tenant) ID** → `TENANT_ID` + +### Step 2: Database Migration + +Run the migration to create the necessary Office365 tables (see [Database Schema](#database-schema)). + +```bash +cd /path/to/OpenXE +php migrations/run_migration.php +``` + +Alternatively: + +```bash +mysql -u openxe -p openxe < migrations/office365_oauth_tables.sql +``` + +### Step 3: Store OAuth Credentials in OpenXE + +Login to OpenXE as admin, open the Office365 OAuth Configuration in the system settings, and enter: +- **Client ID**: from step 1.5 +- **Client Secret**: from step 1.2 +- **Redirect URI**: from step 1.4 +- **Tenant ID**: from step 1.5 + +The values are stored in the `konfiguration` table under the keys +`office365_client_id`, `office365_client_secret`, `office365_redirect_uri`, `office365_tenant_id`. + +### Step 4: Configure an Email Account + +1. Open **E-Mail Accounts** (E-Mail Backup) and create a new account +2. Fill in basic information (email address, display name) +3. In the **SMTP** section: + - Enable **SMTP benutzen** (Use SMTP) + - **Server**: `smtp.office365.com` + - **Encryption**: TLS + - **Port**: `587` + - **Auth Type**: `Microsoft Office365 OAuth2` +4. Save + +### Step 5: Authorize the Account + +1. Click the **Office365 Authorize** button on the account +2. Sign in with the matching Office365 account at Microsoft +3. Grant the requested permissions +4. You are redirected back to OpenXE; the account is now ready + +After authorization the database holds: +- `office365_account.refresh_token` +- `office365_access_token.token` and `expires` + +### Step 6: Send a Test Email + +Use the **Test Mail Sending** action on the account. On success, the access token is fetched (and refreshed if needed) and the mail is sent through the custom SMTP client. + +--- + +## Part 2 — Architecture & Implementation + +### Module Structure + +The Office365Api module mirrors the existing GoogleApi module: + +``` +classes/Modules/Office365Api/ +├── Bootstrap.php # DI container registration +├── Service/ +│ ├── Office365AccountGateway.php # Database access layer +│ ├── Office365AuthorizationService.php # OAuth2 flow handler +│ └── Office365CredentialsService.php # App credentials management +├── Data/ +│ ├── Office365AccountData.php +│ ├── Office365AccessTokenData.php +│ ├── Office365TokenResponseData.php +│ ├── Office365CredentialsData.php +│ ├── Office365AccountPropertyValue.php +│ └── Office365AccountPropertyCollection.php +└── Exception/ + ├── Office365AccountException.php + ├── Office365OAuthException.php + ├── AuthorizationExpiredException.php + ├── NoAccessTokenException.php + └── NoRefreshTokenException.php +``` + +### Key Components + +- **Office365CredentialsService** — Manages OAuth app credentials stored in `konfiguration`. +- **Office365AuthorizationService** — Handles the authorization code grant flow. + - Authorization endpoint: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` + - Token endpoint: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` + - Scopes requested: + - `https://outlook.office.com/SMTP.Send` + - `https://outlook.office.com/IMAP.AccessAsUser.All` + - `https://outlook.office.com/POP.AccessAsUser.All` + - `offline_access` +- **Office365AccountGateway** — Database access for accounts, tokens, scopes and properties. +- **PhpMailerOffice365Authentification** — Provides XOAUTH2 tokens at SMTP authentication time, refreshing the access token when its TTL drops below 30 seconds. + +### Integration with SystemMailer + +- `EmailBackupAccount::AUTH_OFFICE365 = 'oauth_office365'` +- `MailerTransportFactory` handles `AUTH_OFFICE365` and returns an `Office365SmtpTransport`. +- `SystemMailer::composeAndSendEmail()` accepts the Office365 auth type alongside SMTP and Gmail. +- `emailbackup.php` exposes the OAuth authorize/callback actions; `emailbackup_edit.tpl` renders the dropdown entry and the Authorize button. + +### OAuth2 Flow + +1. User selects Office365 auth type for an email account. +2. User clicks **Office365 Authorize** → `emailbackup_office365_authorize()` builds the Microsoft login URL. +3. User authenticates with Microsoft and grants consent. +4. Microsoft redirects back to the callback (`emailbackup_office365_callback()`). +5. The callback exchanges the authorization code for `access_token` and `refresh_token` and stores them in the database. + +### Token Refresh + +Access tokens expire after about an hour. `PhpMailerOffice365Authentification::getOauth64()` checks the TTL on every send and, if less than 30 seconds remain, calls `Office365AuthorizationService::refreshAccessToken()` (POST to the token endpoint with the stored refresh token) and updates `office365_access_token`. Refresh is fully transparent to the caller. + +### Custom SMTP Client (`Office365SmtpTransport`) + +Located at `classes/Components/Mailer/Transport/Office365SmtpTransport.php`. + +**Why a custom client?** Office365 advertises `AUTH LOGIN XOAUTH2` (uppercase) and only accepts the uppercase mechanism name. PHPMailer sends `AUTH xoauth2` (lowercase) and is rejected with *"Requested auth method not available: xoauth2"*. + +**What it does:** +- Implements `MailerTransportInterface` so it integrates transparently into the existing mail pipeline. +- Opens an SMTP connection via `stream_socket_client()` and negotiates `STARTTLS`. +- Sends `AUTH XOAUTH2` (uppercase) followed by the base64-encoded XOAUTH2 token (`user={email}\x01auth=Bearer {token}\x01\x01`). +- Builds RFC 2822 / MIME multipart messages with full attachment support. +- Refreshes OAuth tokens via the existing `Office365AuthorizationService` infrastructure. + +**Typical SMTP exchange:** + +``` +1. Connect smtp.office365.com:587 → 220 service ready +2. EHLO hostname → 250-AUTH LOGIN XOAUTH2 … +3. STARTTLS → 220 Ready, upgrade to TLS +4. AUTH XOAUTH2 → 334 (server prompt) +5. → 235 2.7.0 Authentication successful +6. MAIL FROM / RCPT TO / DATA / . → 250 Message queued +7. QUIT +``` + +### Database Schema + +```sql +CREATE TABLE `office365_account` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) unsigned NOT NULL DEFAULT 0, + `refresh_token` varchar(255) DEFAULT NULL, + `identifier` varchar(255) DEFAULT NULL, + `tenant_id` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`) +) ENGINE=InnoDB; + +CREATE TABLE `office365_access_token` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) unsigned NOT NULL, + `token` varchar(2000) DEFAULT NULL, + `expires` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `office365_account_id` (`office365_account_id`), + FOREIGN KEY (`office365_account_id`) + REFERENCES `office365_account` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE `office365_account_scope` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) unsigned NOT NULL, + `scope` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `office365_account_id` (`office365_account_id`), + FOREIGN KEY (`office365_account_id`) + REFERENCES `office365_account` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE `office365_account_property` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) unsigned NOT NULL, + `varname` varchar(64) DEFAULT NULL, + `value` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `office365_account_id` (`office365_account_id`), + FOREIGN KEY (`office365_account_id`) + REFERENCES `office365_account` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB; +``` + +### Modified Files + +- `classes/Modules/SystemMailer/Data/EmailBackupAccount.php` — added `AUTH_OFFICE365` constant +- `classes/Modules/SystemMailer/Service/MailerTransportFactory.php` — added Office365 transport creation +- `classes/Modules/SystemMailer/SystemMailer.php` — allow `AUTH_OFFICE365` in `composeAndSendEmail()` +- `www/pages/emailbackup.php` — added `oauth_authorize` / `oauth_callback` action handlers +- `www/pages/content/emailbackup_edit.tpl` — added dropdown option and Authorize button + +--- + +## Part 3 — Troubleshooting + +### "Authtype error." when sending + +`SystemMailer::composeAndSendEmail()` rejects accounts whose `smtp_authtype` is not in the allowed list, or whose `smtp_extra` (SMTP active flag) is `0`. + +```sql +SELECT id, email, smtp_extra, smtp_authtype +FROM emailbackup +WHERE email = 'user@example.com'; +``` + +Expected: `smtp_extra = 1`, `smtp_authtype = 'oauth_office365'`. + +### "No Office365 account configured" + +- Ensure the Office365 account was authorized after creation. +- The account's email address must match the Office365 account that granted consent. + +### "Token refresh failed" / "Authorization expired" + +```sql +SELECT refresh_token FROM office365_account WHERE id = ?; +``` + +If `refresh_token` is empty, re-authorize via the Office365 Authorize button. Also verify the configured `office365_client_secret` is current (Azure secrets expire). + +### Check token validity + +```sql +SELECT oat.token, oat.expires, TIMEDIFF(oat.expires, NOW()) AS ttl +FROM office365_account oa +LEFT JOIN office365_access_token oat ON oa.id = oat.office365_account_id +WHERE oa.id = ?; +``` + +### Check granted scopes + +```sql +SELECT scope FROM office365_account_scope WHERE office365_account_id = ?; +``` + +Expected: `SMTP.Send`, `IMAP.AccessAsUser.All`, `POP.AccessAsUser.All`, `offline_access`. + +### "AUTH XOAUTH2" still rejected + +- Confirm the account uses `Office365SmtpTransport` (i.e. `smtp_authtype = 'oauth_office365'`). The transport sends uppercase `AUTH XOAUTH2`; PHPMailer's lowercase variant would be rejected by Office365. +- Verify the access token is current (see token validity query above). + +### OAuth authorization fails + +- The Redirect URI in Azure must match exactly (scheme, host, query parameters). +- Verify Client ID and Client Secret are correct. +- Confirm admin consent has been granted for the requested permissions. + +### Connection refused / timeout + +- Outbound TCP 587 must be allowed by the firewall. +- DNS must resolve `smtp.office365.com`. +- The PHP environment must support TLS streams. + +### Logs + +```bash +tail -f /path/to/OpenXE/userdata/logs/mail.log +``` + +Enable verbose SMTP debug output by setting the account's "SMTP Debug" flag, which raises the log level to include the full SMTP conversation. + +--- + +## Security Notes + +- **Client Secret**: never share or commit; rotate periodically. +- **Refresh Tokens**: stored in the database; treat as secrets. +- **Access Tokens**: cached short-term, transmitted only over TLS. +- **No passwords**: OAuth2 only — the user's Microsoft password is never seen by OpenXE. +- **Scope Limitation**: only the requested mail scopes are granted. + +## References + +- [Microsoft identity platform — OAuth2 authorization code flow](https://learn.microsoft.com/azure/active-directory/develop/v2-oauth2-auth-code-flow) +- [SMTP AUTH XOAUTH2 mechanism](https://learn.microsoft.com/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth) diff --git a/classes/Components/Mailer/Exception/Office365SmtpException.php b/classes/Components/Mailer/Exception/Office365SmtpException.php new file mode 100644 index 000000000..6b7a659e7 --- /dev/null +++ b/classes/Components/Mailer/Exception/Office365SmtpException.php @@ -0,0 +1,9 @@ +config = $config; + $this->gateway = $gateway; + $this->authorizationService = $authorizationService; + $this->office365Account = $office365Account; + $this->logger = $logger; + $this->status = self::STATUS_PREPARE; + $this->socket = null; + } + + public function sendEmail(EmailMessage $email): bool + { + try { + $senderEmail = $this->config->getConfigValue('sender_email', ''); + if (empty($senderEmail)) { + throw new Office365SmtpException('Sender email not configured'); + } + $this->senderEmail = $senderEmail; + + $token = $this->getOAuth2Token(); + if (empty($token)) { + throw new Office365SmtpException('Failed to obtain OAuth2 token'); + } + + $host = $this->config->getConfigValue('host', 'smtp.office365.com'); + $port = $this->config->getConfigValue('port', 587); + + $this->connect($host, $port); + $this->starttls(); + $this->authenticate($token, $senderEmail); + $this->sendMessage($email); + $this->disconnect(); + + $this->status = self::STATUS_SUCCSESS; + return true; + } catch (Office365SmtpException $e) { + $this->errors[] = $e->getMessage(); + $this->status = self::STATUS_ERROR; + $this->safeDisconnect(); + if ($this->logger) { + $this->logger->error('Office365SmtpTransport: ' . $e->getMessage()); + } + return false; + } catch (\Exception $e) { + $this->errors[] = $e->getMessage(); + $this->status = self::STATUS_ERROR; + $this->safeDisconnect(); + if ($this->logger) { + $this->logger->error('Office365SmtpTransport: Unexpected error: ' . $e->getMessage()); + } + return false; + } + } + + public function getStatus(): string + { + return $this->status; + } + + public function hasErrors(): bool + { + return !empty($this->errors); + } + + public function getErrorMessages(): array + { + return $this->errors; + } + + public function getConfigValues(): array + { + return $this->config->getValues(); + } + + private function connect(string $host, int $port): void + { + $context = stream_context_create([ + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => false + ] + ]); + + $this->socket = @stream_socket_client( + "tcp://{$host}:{$port}", + $errno, + $errstr, + 30, + STREAM_CLIENT_CONNECT, + $context + ); + + if (!$this->socket) { + throw new Office365SmtpException("Failed to connect to {$host}:{$port}: {$errstr}"); + } + + stream_set_timeout($this->socket, 30); + + $response = $this->readResponse(); + if (strpos($response, '220') === false) { + throw new Office365SmtpException("Invalid server response: {$response}"); + } + + $this->sendCommand('EHLO ' . php_uname('n')); + } + + private function starttls(): void + { + $this->sendCommand('STARTTLS'); + + if (!stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT)) { + throw new Office365SmtpException('Failed to establish TLS connection'); + } + + $this->sendCommand('EHLO ' . php_uname('n')); + } + + private function authenticate(string $token, string $email): void + { + $authString = "user={$email}\001auth=Bearer {$token}\001\001"; + $encodedAuth = base64_encode($authString); + + $response = $this->sendCommand('AUTH XOAUTH2'); + + if (strpos($response, '334') === false) { + throw new Office365SmtpException('Server did not accept AUTH XOAUTH2: ' . $response); + } + + $response = $this->sendCommand($encodedAuth); + + if (strpos($response, '235') === false) { + throw new Office365SmtpException('Authentication failed: ' . $response); + } + } + + private function sendMessage(EmailMessage $email): void + { + if (empty($email->getRecipients())) { + throw new Office365SmtpException('No recipients specified'); + } + + $this->sendCommand('MAIL FROM:<' . $this->senderEmail . '>'); + + foreach ($email->getRecipients() as $recipient) { + $this->sendCommand('RCPT TO:<' . $recipient->getEmail() . '>'); + } + + foreach ($email->getCcRecipients() as $cc) { + $this->sendCommand('RCPT TO:<' . $cc->getEmail() . '>'); + } + + foreach ($email->getBccRecipients() as $bcc) { + $this->sendCommand('RCPT TO:<' . $bcc->getEmail() . '>'); + } + + $this->sendCommand('DATA'); + + $mimeMessage = $this->buildMimeMessage($email); + fwrite($this->socket, $mimeMessage . "\r\n.\r\n"); + + $response = $this->readResponse(); + if (strpos($response, '250') === false) { + throw new Office365SmtpException('Failed to send message: ' . $response); + } + } + + private function buildMimeMessage(EmailMessage $email): string + { + $messageId = '<' . time() . '.' . uniqid() . '@' . php_uname('n') . '>'; + $boundary = 'boundary_' . uniqid(); + + $headers = []; + $headers[] = 'From: ' . $this->getSenderHeader(); + $headers[] = 'To: ' . $this->getRecipientsHeader($email->getRecipients()); + + if (!empty($email->getCcRecipients())) { + $headers[] = 'Cc: ' . $this->getRecipientsHeader($email->getCcRecipients()); + } + + $headers[] = 'Subject: ' . $this->encodeSubject($email->getSubject()); + $headers[] = 'Date: ' . date('r'); + $headers[] = 'Message-ID: ' . $messageId; + $headers[] = 'MIME-Version: 1.0'; + + $body = ''; + + if (empty($email->getAttachments())) { + $contentType = $email->isHtml() ? 'text/html' : 'text/plain'; + $headers[] = 'Content-Type: ' . $contentType . '; charset=utf-8'; + $headers[] = 'Content-Transfer-Encoding: 8bit'; + $body = $email->getBody(); + } else { + $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + + $body = '--' . $boundary . "\r\n"; + $contentType = $email->isHtml() ? 'text/html' : 'text/plain'; + $body .= 'Content-Type: ' . $contentType . '; charset=utf-8' . "\r\n"; + $body .= 'Content-Transfer-Encoding: 8bit' . "\r\n\r\n"; + $body .= $email->getBody() . "\r\n"; + + foreach ($email->getAttachments() as $attachment) { + $body .= '--' . $boundary . "\r\n"; + $body .= $this->buildAttachmentPart($attachment); + } + + $body .= '--' . $boundary . '--' . "\r\n"; + } + + foreach ($email->getCustomHeaders() as $name => $value) { + $headers[] = $name . ': ' . $value; + } + + return implode("\r\n", $headers) . "\r\n\r\n" . $body; + } + + private function buildAttachmentPart($attachment): string + { + $filename = ''; + $content = ''; + + if ($attachment instanceof FileAttachment) { + $filename = $attachment->getName(); + $content = file_get_contents($attachment->getPath()); + $type = $attachment->getType(); + } elseif ($attachment instanceof ImageAttachment) { + $filename = $attachment->getName(); + $content = file_get_contents($attachment->getPath()); + $type = $attachment->getType(); + } elseif ($attachment instanceof StringAttachment) { + $filename = $attachment->getName(); + $content = $attachment->getContent(); + $type = $attachment->getType(); + } else { + return ''; + } + + $encoded = base64_encode($content); + $encoded = chunk_split($encoded, 76, "\r\n"); + + $part = 'Content-Type: ' . $type . '; name="' . $filename . '"' . "\r\n"; + $part .= 'Content-Transfer-Encoding: base64' . "\r\n"; + $part .= 'Content-Disposition: attachment; filename="' . $filename . '"' . "\r\n\r\n"; + $part .= $encoded . "\r\n"; + + return $part; + } + + private function getSenderHeader(): string + { + $senderName = $this->config->getConfigValue('sender_name', ''); + if (!empty($senderName)) { + return '"' . str_replace('"', '\"', $senderName) . '" <' . $this->senderEmail . '>'; + } + return $this->senderEmail; + } + + private function getRecipientsHeader(array $recipients): string + { + $formatted = []; + foreach ($recipients as $recipient) { + $name = $recipient->getName(); + if (!empty($name)) { + $formatted[] = '"' . str_replace('"', '\"', $name) . '" <' . $recipient->getEmail() . '>'; + } else { + $formatted[] = $recipient->getEmail(); + } + } + return implode(', ', $formatted); + } + + private function encodeSubject(string $subject): string + { + if (empty($subject)) { + return ''; + } + + if (preg_match('/[^\x20-\x7E]/', $subject)) { + return '=?UTF-8?B?' . base64_encode($subject) . '?='; + } + + return $subject; + } + + private function sendCommand(string $command): string + { + if ($this->logger) { + $this->logger->debug('Office365SmtpTransport: Sending: ' . substr($command, 0, 50)); + } + + fwrite($this->socket, $command . "\r\n"); + return $this->readResponse(); + } + + private function readResponse(): string + { + $response = ''; + + while (!feof($this->socket)) { + $line = fgets($this->socket, 512); + if ($line === false) { + break; + } + + $response .= $line; + + if (strpos($line, ' ') !== false && strpos($line, ' ') < 4) { + break; + } + } + + if ($this->logger) { + $this->logger->debug('Office365SmtpTransport: Response: ' . trim($response)); + } + + return $response; + } + + private function disconnect(): void + { + if ($this->socket) { + fwrite($this->socket, "QUIT\r\n"); + fclose($this->socket); + $this->socket = null; + } + } + + private function safeDisconnect(): void + { + if ($this->socket) { + @fclose($this->socket); + $this->socket = null; + } + } + + private function getOAuth2Token(): string + { + $accountId = $this->office365Account->getId(); + + $token = $this->gateway->getAccessToken($accountId); + + if ($token === null) { + throw new Office365SmtpException('No access token available for Office365 account'); + } + + if ($token->getTimeToLive() < 30) { + if ($this->logger) { + $this->logger->info('Office365SmtpTransport: Token expired, refreshing...'); + } + $token = $this->authorizationService->refreshAccessToken($this->office365Account); + } + + return $token->getToken(); + } +} diff --git a/classes/Components/Mailer/Transport/PhpMailerTransport.php b/classes/Components/Mailer/Transport/PhpMailerTransport.php index c7c833012..dd10ef79c 100644 --- a/classes/Components/Mailer/Transport/PhpMailerTransport.php +++ b/classes/Components/Mailer/Transport/PhpMailerTransport.php @@ -32,6 +32,7 @@ public function __construct(PHPMailer $mailer, MailerConfigInterface $config, Lo { $this->config = $config; $this->phpMailer = $mailer; + //no username, no password = no SMTPAuth if($config->getConfigValue('username', '') == '' && $config->getConfigValue('pasword', '') == '') { $this->phpMailer->SMTPAuth = false; @@ -63,6 +64,7 @@ public function __construct(PHPMailer $mailer, MailerConfigInterface $config, Lo $this->phpMailer->SMTPKeepAlive = $config->getConfigValue('smtp_keepalive', false); $this->phpMailer->SingleTo = $config->getConfigValue('singleto', false); $this->phpMailer->Sendmail = $config->getConfigValue('sendmail', '/usr/sbin/sendmail'); + $this->status = self::STATUS_PREPARE; if ($logger !== null) { $this->phpMailer->Debugoutput = $logger; diff --git a/classes/Modules/Office365Api/Bootstrap.php b/classes/Modules/Office365Api/Bootstrap.php new file mode 100644 index 000000000..8a290fc17 --- /dev/null +++ b/classes/Modules/Office365Api/Bootstrap.php @@ -0,0 +1,52 @@ + 'onInitOffice365CredentialsService', + 'Office365AccountGateway' => 'onInitOffice365AccountGateway', + 'Office365AuthorizationService' => 'onInitOffice365AuthorizationService', + ]; + } + + public static function onInitOffice365CredentialsService(ContainerInterface $container): Office365CredentialsService + { + return new Office365CredentialsService( + self::onInitCompanyConfigWrapper($container) + ); + } + + public static function onInitOffice365AccountGateway(ContainerInterface $container): Office365AccountGateway + { + return new Office365AccountGateway($container->get('Database')); + } + + public static function onInitOffice365AuthorizationService(ContainerInterface $container): Office365AuthorizationService + { + return new Office365AuthorizationService( + $container->get('Office365AccountGateway'), + $container->get('Office365CredentialsService') + ); + } + + private static function onInitCompanyConfigWrapper(ContainerInterface $container): CompanyConfigWrapper + { + /** @var ApplicationCore $app */ + $app = $container->get('LegacyApplication'); + + return new CompanyConfigWrapper($app->erp); + } +} diff --git a/classes/Modules/Office365Api/Data/Office365AccessTokenData.php b/classes/Modules/Office365Api/Data/Office365AccessTokenData.php new file mode 100644 index 000000000..33c48b632 --- /dev/null +++ b/classes/Modules/Office365Api/Data/Office365AccessTokenData.php @@ -0,0 +1,86 @@ +token = $token; + $this->expiresAt = $expiresAt; + $this->tokenType = $tokenType; + } + + public static function fromArray(array $data): self + { + $token = $data['token'] ?? ''; + $expiresAt = null; + + if (!empty($data['expires'])) { + try { + $expiresAt = new DateTime($data['expires']); + } catch (\Exception $e) { + $expiresAt = null; + } + } + + $tokenType = $data['token_type'] ?? 'Bearer'; + + return new self($token, $expiresAt, $tokenType); + } + + public function getToken(): string + { + return $this->token; + } + + public function getExpiresAt(): ?DateTimeInterface + { + return $this->expiresAt; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getTimeToLive(): int + { + if ($this->expiresAt === null) { + return 0; + } + + $now = new DateTime(); + $diff = $this->expiresAt->getTimestamp() - $now->getTimestamp(); + + return max(0, $diff); + } + + public function isExpired(): bool + { + return $this->getTimeToLive() <= 0; + } + + public function toArray(): array + { + return [ + 'token' => $this->token, + 'expires' => $this->expiresAt ? $this->expiresAt->format('Y-m-d H:i:s') : null, + 'token_type' => $this->tokenType, + ]; + } +} diff --git a/classes/Modules/Office365Api/Data/Office365AccountData.php b/classes/Modules/Office365Api/Data/Office365AccountData.php new file mode 100644 index 000000000..ded5b9db9 --- /dev/null +++ b/classes/Modules/Office365Api/Data/Office365AccountData.php @@ -0,0 +1,78 @@ +id = $id; + $this->userId = $userId; + $this->identifier = $identifier; + $this->refreshToken = $refreshToken; + $this->tenantId = $tenantId; + } + + public static function fromDbRow(array $row): self + { + return new self( + (int)$row['id'], + (int)$row['user_id'], + $row['identifier'] ?? null, + $row['refresh_token'] ?? null, + $row['tenant_id'] ?? null + ); + } + + public function getId(): int + { + return $this->id; + } + + public function getUserId(): int + { + return $this->userId; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } + + public function getRefreshToken(): ?string + { + return $this->refreshToken; + } + + public function getTenantId(): ?string + { + return $this->tenantId; + } + + public function hasRefreshToken(): bool + { + return $this->refreshToken !== null && $this->refreshToken !== ''; + } +} diff --git a/classes/Modules/Office365Api/Data/Office365AccountPropertyCollection.php b/classes/Modules/Office365Api/Data/Office365AccountPropertyCollection.php new file mode 100644 index 000000000..35918b98c --- /dev/null +++ b/classes/Modules/Office365Api/Data/Office365AccountPropertyCollection.php @@ -0,0 +1,53 @@ +properties[$property->getName()] = $property; + } + } + } + + public function add(Office365AccountPropertyValue $property): void + { + $this->properties[$property->getName()] = $property; + } + + public function get(string $name): ?string + { + return $this->properties[$name]?->getValue(); + } + + public function has(string $name): bool + { + return isset($this->properties[$name]); + } + + public function all(): array + { + return array_map( + fn(Office365AccountPropertyValue $prop) => $prop->getValue(), + $this->properties + ); + } + + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->properties); + } + + public function count(): int + { + return count($this->properties); + } +} diff --git a/classes/Modules/Office365Api/Data/Office365AccountPropertyValue.php b/classes/Modules/Office365Api/Data/Office365AccountPropertyValue.php new file mode 100644 index 000000000..1085fec76 --- /dev/null +++ b/classes/Modules/Office365Api/Data/Office365AccountPropertyValue.php @@ -0,0 +1,30 @@ +name = $name; + $this->value = $value; + } + + public function getName(): string + { + return $this->name; + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/classes/Modules/Office365Api/Data/Office365CredentialsData.php b/classes/Modules/Office365Api/Data/Office365CredentialsData.php new file mode 100644 index 000000000..875102033 --- /dev/null +++ b/classes/Modules/Office365Api/Data/Office365CredentialsData.php @@ -0,0 +1,66 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->redirectUri = $redirectUri; + $this->tenantId = $tenantId; + } + + public function validate(): void + { + if (empty($this->clientId)) { + throw new Office365OAuthException('Client ID is required'); + } + if (empty($this->clientSecret)) { + throw new Office365OAuthException('Client Secret is required'); + } + if (empty($this->redirectUri)) { + throw new Office365OAuthException('Redirect URI is required'); + } + if (empty($this->tenantId)) { + throw new Office365OAuthException('Tenant ID is required'); + } + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function getClientSecret(): string + { + return $this->clientSecret; + } + + public function getRedirectUri(): string + { + return $this->redirectUri; + } + + public function getTenantId(): string + { + return $this->tenantId; + } +} diff --git a/classes/Modules/Office365Api/Data/Office365TokenResponseData.php b/classes/Modules/Office365Api/Data/Office365TokenResponseData.php new file mode 100644 index 000000000..e042a31a7 --- /dev/null +++ b/classes/Modules/Office365Api/Data/Office365TokenResponseData.php @@ -0,0 +1,82 @@ +accessToken = $accessToken; + $this->expiresIn = $expiresIn; + $this->refreshToken = $refreshToken; + $this->tokenType = $tokenType; + } + + public static function fromArray(array $data): self + { + if (empty($data['access_token'])) { + throw new Office365OAuthException('Missing access_token in OAuth response'); + } + + $accessToken = $data['access_token']; + $expiresIn = (int)($data['expires_in'] ?? 3600); + $refreshToken = $data['refresh_token'] ?? null; + $tokenType = $data['token_type'] ?? 'Bearer'; + + return new self($accessToken, $expiresIn, $refreshToken, $tokenType); + } + + public function getAccessToken(): string + { + return $this->accessToken; + } + + public function getRefreshToken(): ?string + { + return $this->refreshToken; + } + + public function getExpiresIn(): int + { + return $this->expiresIn; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getExpirationDateTime(): DateTime + { + $now = new DateTime(); + $now->modify("+{$this->expiresIn} seconds"); + + return $now; + } + + public function toAccessTokenData(): Office365AccessTokenData + { + return new Office365AccessTokenData( + $this->accessToken, + $this->getExpirationDateTime(), + $this->tokenType + ); + } +} diff --git a/classes/Modules/Office365Api/Exception/AuthorizationExpiredException.php b/classes/Modules/Office365Api/Exception/AuthorizationExpiredException.php new file mode 100644 index 000000000..eccfe35a6 --- /dev/null +++ b/classes/Modules/Office365Api/Exception/AuthorizationExpiredException.php @@ -0,0 +1,9 @@ +database = $database; + } + + public function getAccount(int $id): ?Office365AccountData + { + $query = 'SELECT * FROM `office365_account` WHERE `id` = :id'; + $result = $this->database->fetchRow($query, ['id' => $id]); + + if (empty($result)) { + return null; + } + + return Office365AccountData::fromDbRow($result); + } + + public function getAccountByEmailAddress(string $email): ?Office365AccountData + { + $query = <<database->fetchRow($query, ['email' => $email]); + + if (empty($result)) { + return null; + } + + return Office365AccountData::fromDbRow($result); + } + + public function getAccountByUserId(int $userId): ?Office365AccountData + { + $query = 'SELECT * FROM `office365_account` WHERE `user_id` = :user_id LIMIT 1'; + $result = $this->database->fetchRow($query, ['user_id' => $userId]); + + if (empty($result)) { + return null; + } + + return Office365AccountData::fromDbRow($result); + } + + public function getAccessToken(int $accountId): ?Office365AccessTokenData + { + $query = 'SELECT * FROM `office365_access_token` WHERE `office365_account_id` = :account_id ORDER BY `id` DESC LIMIT 1'; + $result = $this->database->fetchRow($query, ['account_id' => $accountId]); + + if (empty($result)) { + return null; + } + + return Office365AccessTokenData::fromArray($result); + } + + public function saveAccessToken(int $accountId, Office365AccessTokenData $token): void + { + $tokenArray = $token->toArray(); + + $query = <<database->perform($query, [ + 'account_id' => $accountId, + 'token' => $tokenArray['token'], + 'expires' => $tokenArray['expires'] + ]); + } + + public function saveAccount(Office365AccountData $account): int + { + $query = <<database->perform($query, [ + 'user_id' => $account->getUserId(), + 'identifier' => $account->getIdentifier(), + 'refresh_token' => $account->getRefreshToken(), + 'tenant_id' => $account->getTenantId() + ]); + + if ($account->getId() === 0) { + return (int)$this->database->lastInsertId(); + } + + return $account->getId(); + } + + public function getAccountProperties(int $accountId): Office365AccountPropertyCollection + { + $query = 'SELECT varname, value FROM `office365_account_property` WHERE `office365_account_id` = :account_id'; + $results = $this->database->fetchAll($query, ['account_id' => $accountId]); + + $properties = []; + if (!empty($results)) { + foreach ($results as $row) { + $properties[] = new Office365AccountPropertyValue($row['varname'], $row['value']); + } + } + + return new Office365AccountPropertyCollection($properties); + } + + public function saveAccountProperty(int $accountId, string $name, string $value): void + { + $query = <<database->perform($query, [ + 'account_id' => $accountId, + 'varname' => $name, + 'value' => $value + ]); + } + + public function hasAccountScope(int $accountId, string $scope): bool + { + $query = 'SELECT COUNT(*) as count FROM `office365_account_scope` WHERE `office365_account_id` = :account_id AND `scope` = :scope'; + $result = $this->database->fetchRow($query, ['account_id' => $accountId, 'scope' => $scope]); + + return isset($result['count']) && (int)$result['count'] > 0; + } + + public function saveAccountScope(int $accountId, string $scope): void + { + if ($this->hasAccountScope($accountId, $scope)) { + return; + } + + $query = 'INSERT INTO `office365_account_scope` (office365_account_id, scope) VALUES (:account_id, :scope)'; + $this->database->perform($query, ['account_id' => $accountId, 'scope' => $scope]); + } + + public function getScopes(int $accountId): array + { + $query = 'SELECT scope FROM `office365_account_scope` WHERE `office365_account_id` = :account_id'; + $results = $this->database->fetchAll($query, ['account_id' => $accountId]); + + if (empty($results)) { + return []; + } + + return array_map(fn($row) => $row['scope'], $results); + } +} diff --git a/classes/Modules/Office365Api/Service/Office365AuthorizationService.php b/classes/Modules/Office365Api/Service/Office365AuthorizationService.php new file mode 100644 index 000000000..27269f3b7 --- /dev/null +++ b/classes/Modules/Office365Api/Service/Office365AuthorizationService.php @@ -0,0 +1,211 @@ +gateway = $gateway; + $this->credentialsService = $credentialsService; + } + + public function getAuthorizationUrl(array $scopes = [], string $state = ''): string + { + $credentials = $this->credentialsService->getCredentials(); + + if (empty($scopes)) { + $scopes = [ + 'https://outlook.office.com/SMTP.Send', + 'https://outlook.office.com/IMAP.AccessAsUser.All', + 'https://outlook.office.com/POP.AccessAsUser.All', + 'offline_access' + ]; + } + + $params = [ + 'client_id' => $credentials->getClientId(), + 'redirect_uri' => $credentials->getRedirectUri(), + 'response_type' => 'code', + 'scope' => implode(' ', $scopes), + 'response_mode' => 'query', + 'prompt' => 'select_account', + ]; + + if (!empty($state)) { + $params['state'] = $state; + } + + $url = sprintf( + 'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize?%s', + urlencode($credentials->getTenantId()), + http_build_query($params) + ); + + return $url; + } + + public function authorizationCallback( + string $code, + int $userId, + string $tenantId = null + ): Office365AccountData { + $credentials = $this->credentialsService->getCredentials(); + $tenantId = $tenantId ?? $credentials->getTenantId(); + + $tokenResponse = $this->requestAccessToken($code, $credentials); + + $accountData = new Office365AccountData( + 0, + $userId, + null, + $tokenResponse->getRefreshToken(), + $tenantId + ); + + $accountId = $this->gateway->saveAccount($accountData); + $accountData = new Office365AccountData( + $accountId, + $userId, + null, + $tokenResponse->getRefreshToken(), + $tenantId + ); + + $this->gateway->saveAccessToken($accountId, $tokenResponse->toAccessTokenData()); + + $scopes = [ + 'https://outlook.office.com/SMTP.Send', + 'https://outlook.office.com/IMAP.AccessAsUser.All', + 'https://outlook.office.com/POP.AccessAsUser.All', + 'offline_access' + ]; + foreach ($scopes as $scope) { + $this->gateway->saveAccountScope($accountId, $scope); + } + + return $accountData; + } + + public function refreshAccessToken(Office365AccountData $account): Office365AccessTokenData + { + if (!$account->hasRefreshToken()) { + throw new NoRefreshTokenException('No refresh token available for account'); + } + + $credentials = $this->credentialsService->getCredentials(); + + $postData = [ + 'client_id' => $credentials->getClientId(), + 'client_secret' => $credentials->getClientSecret(), + 'refresh_token' => $account->getRefreshToken(), + 'grant_type' => 'refresh_token', + 'scope' => 'https://outlook.office.com/SMTP.Send https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/POP.AccessAsUser.All offline_access', + ]; + + $tokenUrl = sprintf( + 'https://login.microsoftonline.com/%s/oauth2/v2.0/token', + urlencode($credentials->getTenantId()) + ); + + $response = $this->postRequest($tokenUrl, $postData); + + if (empty($response['access_token'])) { + throw new Office365OAuthException('Failed to refresh access token'); + } + + $tokenResponse = Office365TokenResponseData::fromArray($response); + $accessTokenData = $tokenResponse->toAccessTokenData(); + + $this->gateway->saveAccessToken($account->getId(), $accessTokenData); + + return $accessTokenData; + } + + private function requestAccessToken(string $code, Office365CredentialsData $credentials): Office365TokenResponseData + { + $postData = [ + 'client_id' => $credentials->getClientId(), + 'client_secret' => $credentials->getClientSecret(), + 'code' => $code, + 'redirect_uri' => $credentials->getRedirectUri(), + 'grant_type' => 'authorization_code', + 'scope' => 'https://outlook.office.com/SMTP.Send https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/POP.AccessAsUser.All offline_access', + ]; + + $tokenUrl = sprintf( + 'https://login.microsoftonline.com/%s/oauth2/v2.0/token', + urlencode($credentials->getTenantId()) + ); + + $response = $this->postRequest($tokenUrl, $postData); + + if (empty($response['access_token'])) { + throw new Office365OAuthException('Failed to obtain access token'); + } + + return Office365TokenResponseData::fromArray($response); + } + + private function postRequest(string $url, array $postData): array + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData)); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($response === false || $httpCode >= 400) { + throw new Office365OAuthException('Microsoft OAuth request failed'); + } + + $decoded = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Office365OAuthException('Invalid JSON response from Microsoft'); + } + + return $decoded ?? []; + } + + public function revokeAuthorization(Office365AccountData $account): void + { + // Microsoft doesn't have a simple revoke endpoint like Google + // We just clear the refresh token from our database + $accountToUpdate = new Office365AccountData( + $account->getId(), + $account->getUserId(), + $account->getIdentifier(), + null, + $account->getTenantId() + ); + + $this->gateway->saveAccount($accountToUpdate); + } +} diff --git a/classes/Modules/Office365Api/Service/Office365CredentialsService.php b/classes/Modules/Office365Api/Service/Office365CredentialsService.php new file mode 100644 index 000000000..974a84c16 --- /dev/null +++ b/classes/Modules/Office365Api/Service/Office365CredentialsService.php @@ -0,0 +1,64 @@ +configWrapper = $configWrapper; + } + + public function getCredentials(): Office365CredentialsData + { + $clientId = $this->configWrapper->get('office365_client_id'); + $clientSecret = $this->configWrapper->get('office365_client_secret'); + $redirectUri = $this->configWrapper->get('office365_redirect_uri'); + $tenantId = $this->configWrapper->get('office365_tenant_id'); + + $credentials = new Office365CredentialsData( + $clientId ?? '', + $clientSecret ?? '', + $redirectUri ?? '', + $tenantId ?? '' + ); + + $credentials->validate(); + + return $credentials; + } + + public function existCredentials(): bool + { + $clientId = $this->configWrapper->get('office365_client_id'); + $clientSecret = $this->configWrapper->get('office365_client_secret'); + $tenantId = $this->configWrapper->get('office365_tenant_id'); + + return !empty($clientId) && !empty($clientSecret) && !empty($tenantId); + } + + public function saveCredentials(Office365CredentialsData $credentials): void + { + $this->configWrapper->set('office365_client_id', $credentials->getClientId()); + $this->configWrapper->set('office365_client_secret', $credentials->getClientSecret()); + $this->configWrapper->set('office365_redirect_uri', $credentials->getRedirectUri()); + $this->configWrapper->set('office365_tenant_id', $credentials->getTenantId()); + } + + public function deleteCredentials(): void + { + $this->configWrapper->delete('office365_client_id'); + $this->configWrapper->delete('office365_client_secret'); + $this->configWrapper->delete('office365_redirect_uri'); + $this->configWrapper->delete('office365_tenant_id'); + } +} diff --git a/classes/Modules/Office365Api/Wrapper/CompanyConfigWrapper.php b/classes/Modules/Office365Api/Wrapper/CompanyConfigWrapper.php new file mode 100644 index 000000000..b8b45b443 --- /dev/null +++ b/classes/Modules/Office365Api/Wrapper/CompanyConfigWrapper.php @@ -0,0 +1,46 @@ +erpApi = $erpApi; + } + + public function get(string $key): ?string + { + try { + $value = $this->erpApi->GetKonfiguration($key); + return $value ?: null; + } catch (\Exception $e) { + return null; + } + } + + public function set(string $key, string $value): void + { + try { + $this->erpApi->SetKonfigurationValue($key, $value); + } catch (\Exception $e) { + // Silently fail - config might not be available in all contexts + } + } + + public function delete(string $key): void + { + try { + $this->erpApi->SetKonfigurationValue($key, ''); + } catch (\Exception $e) { + // Silently fail + } + } +} diff --git a/classes/Modules/SystemMailClient/Bootstrap.php b/classes/Modules/SystemMailClient/Bootstrap.php index 5d389566e..e508bfda9 100644 --- a/classes/Modules/SystemMailClient/Bootstrap.php +++ b/classes/Modules/SystemMailClient/Bootstrap.php @@ -26,10 +26,19 @@ public static function registerServices(): array */ public static function onInitMailClientConfigProvider(ServiceContainer $container): MailClientConfigProvider { + $office365Gateway = $container->has('Office365AccountGateway') + ? $container->get('Office365AccountGateway') + : null; + $office365AuthService = $container->has('Office365AuthorizationService') + ? $container->get('Office365AuthorizationService') + : null; + return new MailClientConfigProvider( $container->get('EmailAccountGateway'), $container->get('GoogleAccountGateway'), - $container->get('GoogleApiClientFactory') + $container->get('GoogleApiClientFactory'), + $office365Gateway, + $office365AuthService ); } diff --git a/classes/Modules/SystemMailClient/MailClientConfigProvider.php b/classes/Modules/SystemMailClient/MailClientConfigProvider.php index d27493e61..ce32b7c21 100644 --- a/classes/Modules/SystemMailClient/MailClientConfigProvider.php +++ b/classes/Modules/SystemMailClient/MailClientConfigProvider.php @@ -8,6 +8,8 @@ use Xentral\Components\MailClient\Config\ImapMailClientConfig; use Xentral\Modules\GoogleApi\Client\GoogleApiClientFactory; use Xentral\Modules\GoogleApi\Service\GoogleAccountGateway; +use Xentral\Modules\Office365Api\Service\Office365AccountGateway; +use Xentral\Modules\Office365Api\Service\Office365AuthorizationService; use Xentral\Modules\SystemMailClient\Exception\MailClientConfigException; use Xentral\Modules\SystemMailClient\Exception\OAuthException; use Xentral\Modules\SystemMailer\Data\EmailBackupAccount; @@ -25,20 +27,32 @@ class MailClientConfigProvider /** @var GoogleApiClientFactory $clientFactory */ private $clientFactory; + /** @var Office365AccountGateway|null $office365Gateway */ + private $office365Gateway; + + /** @var Office365AuthorizationService|null $office365AuthService */ + private $office365AuthService; + /** - * @param EmailAccountGateway $accountGateway - * @param GoogleAccountGateway $googleAccountGateway - * @param GoogleApiClientFactory $clientFactory + * @param EmailAccountGateway $accountGateway + * @param GoogleAccountGateway $googleAccountGateway + * @param GoogleApiClientFactory $clientFactory + * @param Office365AccountGateway|null $office365Gateway + * @param Office365AuthorizationService|null $office365AuthService */ public function __construct( EmailAccountGateway $accountGateway, GoogleAccountGateway $googleAccountGateway, - GoogleApiClientFactory $clientFactory + GoogleApiClientFactory $clientFactory, + ?Office365AccountGateway $office365Gateway = null, + ?Office365AuthorizationService $office365AuthService = null ) { $this->accountGateway = $accountGateway; $this->googleAccountGateway = $googleAccountGateway; $this->clientFactory = $clientFactory; + $this->office365Gateway = $office365Gateway; + $this->office365AuthService = $office365AuthService; } /** @@ -84,6 +98,11 @@ public function createImapConfigFromAccount(EmailBackupAccount $account): ImapMa return $this->createGoogleOauthImapConfig($account->getEmailAddress()); break; + // IMAP via Office365 OAuth + case 6: + return $this->createOffice365OauthImapConfig($account); + break; + // IMAP without SSL default: break; @@ -128,4 +147,53 @@ private function createGoogleOauthImapConfig(string $email): ImapMailClientConfi 'INBOX' ); } + + /** + * @param EmailBackupAccount $account + * + * @throws OAuthException + * + * @return ImapMailClientConfig + */ + private function createOffice365OauthImapConfig(EmailBackupAccount $account): ImapMailClientConfig + { + if ($this->office365Gateway === null || $this->office365AuthService === null) { + throw new OAuthException('Office365Api services are not available'); + } + + $email = $account->getEmailAddress(); + + try { + $office365Account = $this->office365Gateway->getAccountByEmailAddress($email); + if ($office365Account === null) { + throw new OAuthException('No Office365 account configured for ' . $email); + } + + $token = $this->office365Gateway->getAccessToken($office365Account->getId()); + if ($token === null || $token->getTimeToLive() < 30) { + $token = $this->office365AuthService->refreshAccessToken($office365Account); + } + } catch (Exception $e) { + throw new OAuthException($e->getMessage(), $e->getCode(), $e); + } + + $imapServer = $account->getImapServer(); + if ($imapServer === '') { + $imapServer = 'outlook.office365.com'; + } + $imapPort = $account->getImapPort(); + if ((int)$imapPort === 0) { + $imapPort = 993; + } + + return new ImapMailClientConfig( + $imapServer, + (int)$imapPort, + $email, + $token->getToken(), + ImapMailClientConfig::AUTH_XOAUTH2, + true, + 'INBOX' + ); + } } diff --git a/classes/Modules/SystemMailClient/MailClientProvider.php b/classes/Modules/SystemMailClient/MailClientProvider.php index 64a4bc1f2..9bdc71177 100644 --- a/classes/Modules/SystemMailClient/MailClientProvider.php +++ b/classes/Modules/SystemMailClient/MailClientProvider.php @@ -115,6 +115,8 @@ public function createMailClientFromAccount(EmailBackupAccount $account): MailCl case 3: // NO BREAK case 5: + // NO BREAK + case 6: $config = $this->configProvider->createImapConfigFromAccount($account); return $this->factory->createImapClient($config); diff --git a/classes/Modules/SystemMailer/Data/EmailBackupAccount.php b/classes/Modules/SystemMailer/Data/EmailBackupAccount.php index 26e7fc6f5..528a10963 100644 --- a/classes/Modules/SystemMailer/Data/EmailBackupAccount.php +++ b/classes/Modules/SystemMailer/Data/EmailBackupAccount.php @@ -18,6 +18,9 @@ final class EmailBackupAccount implements MailAccountInterface /** @var string AUTH_GMAIL */ public const AUTH_GMAIL = 'oauth_google'; + /** @var string AUTH_OFFICE365 */ + public const AUTH_OFFICE365 = 'oauth_office365'; + /** @var int $id */ private $id; @@ -303,7 +306,7 @@ public function getSenderName():string */ public function getUserName():string { - if ($this->smtpAuthType === MailAccountInterface::TYPE_GOOGLE) { + if ($this->smtpAuthType === MailAccountInterface::TYPE_GOOGLE || $this->smtpAuthType === self::AUTH_OFFICE365) { return $this->smtpSenderEmail; } diff --git a/classes/Modules/SystemMailer/Service/MailerTransportFactory.php b/classes/Modules/SystemMailer/Service/MailerTransportFactory.php index 68935ff84..88cfad7c1 100644 --- a/classes/Modules/SystemMailer/Service/MailerTransportFactory.php +++ b/classes/Modules/SystemMailer/Service/MailerTransportFactory.php @@ -9,6 +9,7 @@ use Xentral\Components\Mailer\Config\OAuthMailerConfig; use Xentral\Components\Mailer\Config\SmtpMailerConfig; use Xentral\Components\Mailer\Transport\MailerTransportInterface; +use Xentral\Components\Mailer\Transport\Office365SmtpTransport; use Xentral\Components\Mailer\Transport\PhpMailerOAuth; use Xentral\Components\Mailer\Transport\PhpMailerTransport; use Xentral\Core\DependencyInjection\ContainerInterface; @@ -17,10 +18,14 @@ use Xentral\Modules\GoogleApi\Service\GoogleAccountGateway; use Xentral\Modules\GoogleApi\Service\GoogleAuthorizationService; use Xentral\Modules\GoogleApi\Service\GoogleCredentialsService; +use Xentral\Modules\Office365Api\Exception\Office365OAuthException; +use Xentral\Modules\Office365Api\Service\Office365AccountGateway; +use Xentral\Modules\Office365Api\Service\Office365AuthorizationService; use Xentral\Modules\SystemMailer\Data\EmailBackupAccount; use Xentral\Modules\SystemMailer\Exception\GmailOAuthException; use Xentral\Modules\SystemMailer\Exception\InvalidArgumentException; use Xentral\Modules\SystemMailer\Transport\PhpMailerGoogleAuthentification; +use Xentral\Modules\SystemMailer\Transport\PhpMailerOffice365Authentification; class MailerTransportFactory { @@ -49,11 +54,13 @@ public function createMailerTransport(EmailBackupAccount $account):MailerTranspo case EmailBackupAccount::AUTH_SMTP: return $this->createSmtpTransport($account); - break; case EmailBackupAccount::AUTH_GMAIL: return $this->createGoogleOAuthTransport($account); + case EmailBackupAccount::AUTH_OFFICE365: + return $this->createOffice365OAuthTransport($account); + default: throw new InvalidArgumentException('Only SMTP accounts are supported.'); } @@ -132,7 +139,9 @@ public function createGmailMailerConfig(EmailBackupAccount $account):OAuthMailer 'port' => $account->getSmtpPort(), 'smtp_security' => $account->getSmtpSecurity(), 'mailer' => 'smtp', - 'smtp_debug' => $debug + 'smtp_debug' => $debug, + 'username' => $email, + 'password' => 'oauth' ]; if ($cfgValues['host'] === '') { $cfgValues['host'] = 'smtp.gmail.com'; @@ -184,4 +193,90 @@ public function createGoogleOAuthTransport(EmailBackupAccount $account):PhpMaile return new PhpMailerTransport($mailer, $config, $this->logger); } + + /** + * @param EmailBackupAccount $account + * + * @return OAuthMailerConfig + */ + public function createOffice365MailerConfig(EmailBackupAccount $account):OAuthMailerConfig + { + if ( + $account->isSmtpEnabled() === false + || $account->getSmtpAuthType() !== EmailBackupAccount::AUTH_OFFICE365 + ) { + throw new InvalidArgumentException('Only Office365 OAuth accounts are supported.'); + } + + $email = $account->getSmtpSenderEmail(); + if ($email === '') { + $email = $account->getEmailAddress(); + } + $sender = $account->getSmtpSenderName(); + if ($sender === '') { + $sender = $account->getDisplayName(); + } + $debug = 0; + if ($account->isSmtpDebugEnabled()) { + $debug = 4; + } + + $cfgValues = [ + 'sender_email' => $email, + 'sender_name' => $sender, + 'host' => $account->getSmtpServer(), + 'hostname' => $account->getClientAlias(), + 'port' => $account->getSmtpPort(), + 'smtp_security' => $account->getSmtpSecurity(), + 'mailer' => 'smtp', + 'smtp_debug' => $debug, + 'username' => $email, + 'password' => 'oauth' + ]; + + if ($cfgValues['host'] === '') { + $cfgValues['host'] = 'smtp.office365.com'; + } + if ($cfgValues['port'] === 0) { + $cfgValues['port'] = 587; + } + if ($cfgValues['smtp_security'] === '') { + $cfgValues['smtp_security'] = 'tls'; + } + + return new OAuthMailerConfig($cfgValues); + } + + /** + * @param EmailBackupAccount $account + * + * @throws Office365OAuthException + * + * @return MailerTransportInterface + */ + public function createOffice365OAuthTransport(EmailBackupAccount $account):MailerTransportInterface + { + $config = $this->createOffice365MailerConfig($account); + + /** @var Office365AccountGateway $office365Gateway */ + $office365Gateway = $this->container->get('Office365AccountGateway'); + $senderEmail = $account->getSenderEmailAddress(); + + $office365Account = $office365Gateway->getAccountByEmailAddress($senderEmail); + + if ($office365Account === null) { + throw new Office365OAuthException('No Office365 account configured for email: ' . $senderEmail); + } + + /** @var Office365AuthorizationService $office365Auth */ + $office365Auth = $this->container->get('Office365AuthorizationService'); + + return new Office365SmtpTransport( + $config, + $office365Gateway, + $office365Auth, + $office365Account, + $this->logger + ); + } } diff --git a/classes/Modules/SystemMailer/SystemMailer.php b/classes/Modules/SystemMailer/SystemMailer.php index 67c9c7bd6..7199bf31f 100644 --- a/classes/Modules/SystemMailer/SystemMailer.php +++ b/classes/Modules/SystemMailer/SystemMailer.php @@ -131,12 +131,13 @@ public function composeAndSendEmail( return false; } - //use Mailer component only for 'smtp' or 'gmail' authtype + //use Mailer component only for 'smtp', 'gmail' or 'office365' authtype if ( $account->isSmtpEnabled() === false || ( $account->getSmtpAuthType() !== EmailBackupAccount::AUTH_SMTP && $account->getSmtpAuthType() !== EmailBackupAccount::AUTH_GMAIL + && $account->getSmtpAuthType() !== EmailBackupAccount::AUTH_OFFICE365 ) ) { $mailerror_text = 'Authtype error.'; diff --git a/classes/Modules/SystemMailer/Transport/PhpMailerOffice365Authentification.php b/classes/Modules/SystemMailer/Transport/PhpMailerOffice365Authentification.php new file mode 100644 index 000000000..6a82ef760 --- /dev/null +++ b/classes/Modules/SystemMailer/Transport/PhpMailerOffice365Authentification.php @@ -0,0 +1,69 @@ +gateway = $gateway; + $this->authorizationService = $authorizationService; + $this->office365Account = $office365Account; + } + + public function getOauth64(): string + { + try { + $properties = $this->gateway->getAccountProperties($this->office365Account->getId()); + $emailAddress = $properties->get('email_address'); + + if ($this->office365Account === null || $emailAddress === null) { + throw new OAuthCredentialsException('Office365 OAuth - missing credentials'); + } + + $token = $this->gateway->getAccessToken($this->office365Account->getId()); + + if ($token === null) { + throw new NoAccessTokenException('No access token available'); + } + + if ($token->getTimeToLive() < 30) { + $token = $this->authorizationService->refreshAccessToken($this->office365Account); + } + + $offlineToken = $token->getToken(); + $oauthString = sprintf( + "user=%s\001auth=Bearer %s\001\001", + $emailAddress, + $offlineToken + ); + + return base64_encode($oauthString); + } catch (\Exception $e) { + throw $e; + } + } +} diff --git a/database/struktur.sql b/database/struktur.sql index eb61ea9a0..ca75a1013 100755 --- a/database/struktur.sql +++ b/database/struktur.sql @@ -9480,6 +9480,82 @@ CREATE TABLE `offenevorgaenge` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `office365_access_token` +-- + +DROP TABLE IF EXISTS `office365_access_token`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `office365_access_token` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) NOT NULL, + `token` longtext DEFAULT NULL, + `expires` datetime DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `office365_account_id` (`office365_account_id`), + KEY `expires` (`expires`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `office365_account` +-- + +DROP TABLE IF EXISTS `office365_account`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `office365_account` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) unsigned NOT NULL DEFAULT 0, + `refresh_token` longtext DEFAULT NULL, + `identifier` varchar(255) DEFAULT NULL, + `tenant_id` varchar(255) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `office365_account_property` +-- + +DROP TABLE IF EXISTS `office365_account_property`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `office365_account_property` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) NOT NULL, + `varname` varchar(64) DEFAULT NULL, + `value` varchar(255) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `account_varname` (`office365_account_id`,`varname`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `office365_account_scope` +-- + +DROP TABLE IF EXISTS `office365_account_scope`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `office365_account_scope` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) NOT NULL, + `scope` varchar(255) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `office365_account_id` (`office365_account_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `onlineshop_transfer_cart` -- diff --git a/migrations/office365_oauth_tables.sql b/migrations/office365_oauth_tables.sql new file mode 100644 index 000000000..0d5e4df9d --- /dev/null +++ b/migrations/office365_oauth_tables.sql @@ -0,0 +1,60 @@ +-- Office365 OAuth2 Integration Tables +-- Created for Office365 email account management + +-- Main account table +CREATE TABLE IF NOT EXISTS `office365_account` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) unsigned NOT NULL DEFAULT 0, + `refresh_token` longtext DEFAULT NULL COMMENT 'Long-lived refresh token from Microsoft', + `identifier` varchar(255) DEFAULT NULL, + `tenant_id` varchar(255) DEFAULT NULL COMMENT 'Microsoft tenant ID (organization)', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +-- Access token cache table +CREATE TABLE IF NOT EXISTS `office365_access_token` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) NOT NULL, + `token` longtext DEFAULT NULL COMMENT 'Current OAuth access token', + `expires` datetime DEFAULT NULL COMMENT 'Token expiration timestamp', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `office365_account_id` (`office365_account_id`), + KEY `expires` (`expires`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +-- OAuth scopes granted by user +CREATE TABLE IF NOT EXISTS `office365_account_scope` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) NOT NULL, + `scope` varchar(255) DEFAULT NULL COMMENT 'OAuth scope granted by user', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `office365_account_id` (`office365_account_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +-- Account metadata (email, etc) +CREATE TABLE IF NOT EXISTS `office365_account_property` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `office365_account_id` int(11) NOT NULL, + `varname` varchar(64) DEFAULT NULL COMMENT 'Property name (e.g., email_address)', + `value` varchar(255) DEFAULT NULL COMMENT 'Property value', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `account_varname` (`office365_account_id`,`varname`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +-- Add foreign key constraints after all tables are created +ALTER TABLE `office365_access_token` ADD CONSTRAINT `fk_office365_access_token_account` + FOREIGN KEY (`office365_account_id`) REFERENCES `office365_account` (`id`) ON DELETE CASCADE; + +ALTER TABLE `office365_account_scope` ADD CONSTRAINT `fk_office365_account_scope_account` + FOREIGN KEY (`office365_account_id`) REFERENCES `office365_account` (`id`) ON DELETE CASCADE; + +ALTER TABLE `office365_account_property` ADD CONSTRAINT `fk_office365_account_property_account` + FOREIGN KEY (`office365_account_id`) REFERENCES `office365_account` (`id`) ON DELETE CASCADE; diff --git a/migrations/office365_oauth_unique_indexes.sql b/migrations/office365_oauth_unique_indexes.sql new file mode 100644 index 000000000..2350d8495 --- /dev/null +++ b/migrations/office365_oauth_unique_indexes.sql @@ -0,0 +1,30 @@ +-- Office365 OAuth2 — Add UNIQUE constraints for token cache and account properties +-- Run this AFTER office365_oauth_tables.sql on existing installations. +-- This dedupes any leftover duplicate rows (one per account / one per varname) +-- and then adds UNIQUE indexes that make INSERT ... ON DUPLICATE KEY UPDATE +-- behave correctly going forward. + +-- 1) Deduplicate office365_access_token: keep the most recent row per account +DELETE t1 FROM `office365_access_token` t1 +INNER JOIN `office365_access_token` t2 + ON t1.office365_account_id = t2.office365_account_id + AND t1.id < t2.id; + +-- 2) Add UNIQUE index (drop the non-unique one if it exists) +ALTER TABLE `office365_access_token` + DROP INDEX `office365_account_id`; +ALTER TABLE `office365_access_token` + ADD UNIQUE KEY `office365_account_id` (`office365_account_id`); + +-- 3) Deduplicate office365_account_property: keep the most recent row per (account, varname) +DELETE p1 FROM `office365_account_property` p1 +INNER JOIN `office365_account_property` p2 + ON p1.office365_account_id = p2.office365_account_id + AND p1.varname = p2.varname + AND p1.id < p2.id; + +-- 4) Add composite UNIQUE index for property upserts +ALTER TABLE `office365_account_property` + DROP INDEX `office365_account_id`; +ALTER TABLE `office365_account_property` + ADD UNIQUE KEY `account_varname` (`office365_account_id`, `varname`); diff --git a/migrations/run_migration.php b/migrations/run_migration.php new file mode 100644 index 000000000..0a33478d3 --- /dev/null +++ b/migrations/run_migration.php @@ -0,0 +1,58 @@ +DB; + + // Read the migration SQL file + $sqlFile = __DIR__ . '/office365_oauth_tables.sql'; + $sql = file_get_contents($sqlFile); + + if (!$sql) { + throw new Exception("Could not read migration file: $sqlFile"); + } + + // Remove comments and split SQL statements + $sql = preg_replace('/^--.*$/m', '', $sql); // Remove SQL comments + $statements = array_filter( + array_map( + 'trim', + preg_split('/;[\s\n]*/', $sql) + ) + ); + + // Execute each statement + $count = 0; + foreach ($statements as $statement) { + if (!empty($statement) && trim($statement) !== '') { + echo "Executing statement " . ($count + 1) . "...\n"; + try { + $db->query($statement); + $count++; + echo " ✓ Success\n"; + } catch (Exception $e) { + echo " ✗ Error: " . $e->getMessage() . "\n"; + throw $e; + } + } + } + + echo "\n✓ Migration completed successfully! Created/updated 4 tables and 3 foreign key constraints.\n"; + echo "Tables created:\n"; + echo " - office365_account\n"; + echo " - office365_access_token\n"; + echo " - office365_account_scope\n"; + echo " - office365_account_property\n"; + +} catch (Exception $e) { + echo "✗ Migration failed: " . $e->getMessage() . "\n"; + exit(1); +} diff --git a/upgrade/data/db_schema.json b/upgrade/data/db_schema.json index 1d835c9e9..3ca569218 100644 --- a/upgrade/data/db_schema.json +++ b/upgrade/data/db_schema.json @@ -64541,6 +64541,368 @@ } ] }, + { + "name": "office365_access_token", + "collation": "utf8mb3_general_ci", + "type": "BASE TABLE", + "columns": [ + { + "Field": "id", + "Type": "int(11)", + "Collation": null, + "Null": "NO", + "Key": "PRI", + "Default": null, + "Extra": "auto_increment", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "office365_account_id", + "Type": "int(11)", + "Collation": null, + "Null": "NO", + "Key": "UNI", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "token", + "Type": "longtext", + "Collation": "utf8mb3_general_ci", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "expires", + "Type": "datetime", + "Collation": null, + "Null": "YES", + "Key": "MUL", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "created_at", + "Type": "datetime", + "Collation": null, + "Null": "YES", + "Key": "", + "Default": "current_timestamp()", + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "updated_at", + "Type": "datetime", + "Collation": null, + "Null": "YES", + "Key": "", + "Default": "current_timestamp()", + "Extra": "on update current_timestamp()", + "Privileges": "select,insert,update,references", + "Comment": "" + } + ], + "keys": [ + { + "Key_name": "PRIMARY", + "Index_type": "BTREE", + "columns": [ + "id" + ], + "Non_unique": "" + }, + { + "Key_name": "office365_account_id", + "Index_type": "BTREE", + "columns": [ + "office365_account_id" + ], + "Non_unique": "0" + }, + { + "Key_name": "expires", + "Index_type": "BTREE", + "columns": [ + "expires" + ], + "Non_unique": "" + } + ] + }, + { + "name": "office365_account", + "collation": "utf8mb3_general_ci", + "type": "BASE TABLE", + "columns": [ + { + "Field": "id", + "Type": "int(11)", + "Collation": null, + "Null": "NO", + "Key": "PRI", + "Default": null, + "Extra": "auto_increment", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "user_id", + "Type": "int(11) unsigned", + "Collation": null, + "Null": "NO", + "Key": "MUL", + "Default": "0", + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "refresh_token", + "Type": "longtext", + "Collation": "utf8mb3_general_ci", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "identifier", + "Type": "varchar(255)", + "Collation": "utf8mb3_general_ci", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "tenant_id", + "Type": "varchar(255)", + "Collation": "utf8mb3_general_ci", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "created_at", + "Type": "datetime", + "Collation": null, + "Null": "YES", + "Key": "", + "Default": "current_timestamp()", + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "updated_at", + "Type": "datetime", + "Collation": null, + "Null": "YES", + "Key": "", + "Default": "current_timestamp()", + "Extra": "on update current_timestamp()", + "Privileges": "select,insert,update,references", + "Comment": "" + } + ], + "keys": [ + { + "Key_name": "PRIMARY", + "Index_type": "BTREE", + "columns": [ + "id" + ], + "Non_unique": "" + }, + { + "Key_name": "user_id", + "Index_type": "BTREE", + "columns": [ + "user_id" + ], + "Non_unique": "" + } + ] + }, + { + "name": "office365_account_property", + "collation": "utf8mb3_general_ci", + "type": "BASE TABLE", + "columns": [ + { + "Field": "id", + "Type": "int(11)", + "Collation": null, + "Null": "NO", + "Key": "PRI", + "Default": null, + "Extra": "auto_increment", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "office365_account_id", + "Type": "int(11)", + "Collation": null, + "Null": "NO", + "Key": "MUL", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "varname", + "Type": "varchar(64)", + "Collation": "utf8mb3_general_ci", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "value", + "Type": "varchar(255)", + "Collation": "utf8mb3_general_ci", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "created_at", + "Type": "datetime", + "Collation": null, + "Null": "YES", + "Key": "", + "Default": "current_timestamp()", + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "updated_at", + "Type": "datetime", + "Collation": null, + "Null": "YES", + "Key": "", + "Default": "current_timestamp()", + "Extra": "on update current_timestamp()", + "Privileges": "select,insert,update,references", + "Comment": "" + } + ], + "keys": [ + { + "Key_name": "PRIMARY", + "Index_type": "BTREE", + "columns": [ + "id" + ], + "Non_unique": "" + }, + { + "Key_name": "account_varname", + "Index_type": "BTREE", + "columns": [ + "office365_account_id", + "varname" + ], + "Non_unique": "0" + } + ] + }, + { + "name": "office365_account_scope", + "collation": "utf8mb3_general_ci", + "type": "BASE TABLE", + "columns": [ + { + "Field": "id", + "Type": "int(11)", + "Collation": null, + "Null": "NO", + "Key": "PRI", + "Default": null, + "Extra": "auto_increment", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "office365_account_id", + "Type": "int(11)", + "Collation": null, + "Null": "NO", + "Key": "MUL", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "scope", + "Type": "varchar(255)", + "Collation": "utf8mb3_general_ci", + "Null": "YES", + "Key": "", + "Default": null, + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + }, + { + "Field": "created_at", + "Type": "datetime", + "Collation": null, + "Null": "YES", + "Key": "", + "Default": "current_timestamp()", + "Extra": "", + "Privileges": "select,insert,update,references", + "Comment": "" + } + ], + "keys": [ + { + "Key_name": "PRIMARY", + "Index_type": "BTREE", + "columns": [ + "id" + ], + "Non_unique": "" + }, + { + "Key_name": "office365_account_id", + "Index_type": "BTREE", + "columns": [ + "office365_account_id" + ], + "Non_unique": "" + } + ] + }, { "name": "onlineshop_transfer_cart", "collation": "utf8mb3_general_ci", diff --git a/www/pages/content/emailbackup_edit.tpl b/www/pages/content/emailbackup_edit.tpl index 4c06d945d..e6c0c8ed5 100644 --- a/www/pages/content/emailbackup_edit.tpl +++ b/www/pages/content/emailbackup_edit.tpl @@ -103,6 +103,15 @@ + + {|Office365 Authorization|}: + + + Office365 authorisieren + +
Klicke um dein Office365-Konto zu autorisieren + + {|Client alias|}: @@ -344,3 +353,25 @@ + + diff --git a/www/pages/emailbackup.php b/www/pages/emailbackup.php index 4e82d20de..ac8a745d4 100644 --- a/www/pages/emailbackup.php +++ b/www/pages/emailbackup.php @@ -15,12 +15,15 @@ function __construct($app, $intern = false) { return; $this->app->ActionHandlerInit($this); - $this->app->ActionHandler("list", "emailbackup_list"); + $this->app->ActionHandler("list", "emailbackup_list"); $this->app->ActionHandler("create", "emailbackup_edit"); // This automatically adds a "New" button $this->app->ActionHandler("edit", "emailbackup_edit"); $this->app->ActionHandler("delete", "emailbackup_delete"); $this->app->ActionHandler("test_smtp",'emailbackup_test_smtp'); $this->app->ActionHandler("test_imap",'emailbackup_test_imap'); + $this->app->ActionHandler("office365_authorize",'emailbackup_office365_authorize'); + $this->app->ActionHandler("office365_callback",'emailbackup_office365_callback'); + $this->app->ActionHandler("oauth_callback",'emailbackup_office365_callback'); $this->app->DefaultActionHandler("list"); $this->app->ActionHandlerListen($app); @@ -202,18 +205,20 @@ function emailbackup_edit() { $smtp_ssl_select = $this->app->erp->GetSelectAsso($smtp_ssl_select,$emailbackup['smtp_ssl']); $this->app->Tpl->Set('SMTP_SSL_SELECT',$smtp_ssl_select); - $smtp_authtype_select = Array( + $smtp_authtype_select = Array( '' => 'Kein', 'smtp' => 'SMTP', - 'oauth_google' => 'Oauth Google' - ); + 'oauth_google' => 'Oauth Google', + 'oauth_office365' => 'Office365 OAuth2' + ); $smtp_authtype_select = $this->app->erp->GetSelectAsso($smtp_authtype_select,$emailbackup['smtp_authtype']); $this->app->Tpl->Set('SMTP_AUTHTYPE_SELECT',$smtp_authtype_select); $imap_type_select = Array( '1' => 'Standard', '3' => 'SSL', - '5' => 'Oauth' + '5' => 'Oauth (Google)', + '6' => 'Oauth (Office365)' ); $imap_type_select = $this->app->erp->GetSelectAsso($imap_type_select,$emailbackup['imap_type']); $this->app->Tpl->Set('IMAP_TYPE_SELECT',$imap_type_select); @@ -289,8 +294,8 @@ function emailbackup_test_smtp() { $result = $this->app->DB->SelectArr("SELECT angezeigtername, email FROM emailbackup WHERE id='$id' LIMIT 1"); - if( - $this->app->erp->MailSend( + try { + $success = $this->app->erp->MailSend( $result[0]['email'], $result[0]['angezeigtername'], array($result[0]['email']), @@ -299,17 +304,23 @@ function emailbackup_test_smtp() { 'Dies ist eine Testmail für Account "'.$result[0]['email'].'".', '',0,false,'','', true - ) - ) { - $msg = $this->app->erp->base64_url_encode( - '
Die Testmail wurde erfolgreich versendet an '.$result[0]['email'].'. '.$this->app->erp->mail_error.'
' ); - } - else { + + if($success) { + $msg = $this->app->erp->base64_url_encode( + '
Die Testmail wurde erfolgreich versendet an '.$result[0]['email'].'. '.$this->app->erp->mail_error.'
' + ); + } else { + $msg = $this->app->erp->base64_url_encode( + '
Fehler beim Versenden der Testmail: '.$this->app->erp->mail_error.'
' + ); + } + } catch (\Exception $e) { $msg = $this->app->erp->base64_url_encode( - '
Fehler beim Versenden der Testmail: '.$this->app->erp->mail_error.'
' + '
Fehler beim Versenden der Testmail: ' . $e->getMessage() . '
' ); } + $this->app->Location->execute("index.php?module=emailbackup&id=$id&action=edit&msg=$msg"); } @@ -391,6 +402,131 @@ function emailbackup_test_imap() { } $this->app->Location->execute("index.php?module=emailbackup&id=$id&action=edit&msg=$msg"); - } + } + + public function emailbackup_office365_authorize() + { + $id = (int) $this->app->Secure->GetGET('id'); + $email = $this->app->Secure->GetGET('email'); + + if (empty($id) || empty($email)) { + $this->app->Tpl->Set('MESSAGE', "
Ungültige Parameter für Office365 Authorization
"); + $this->emailbackup_edit(); + return; + } + + try { + $office365AuthService = $this->app->Container->get('Office365AuthorizationService'); + + // Use account ID as state parameter for security + $state = 'account_' . $id . '_' . bin2hex(random_bytes(16)); + $authUrl = $office365AuthService->getAuthorizationUrl([], $state); + + // Also store in session as backup + session_start(); + $_SESSION['office365_account_id'] = $id; + $_SESSION['office365_email'] = $email; + $_SESSION['office365_state'] = $state; + + header('Location: ' . $authUrl); + exit; + } catch (Exception $e) { + error_log("Office365 Authorization Error: " . $e->getMessage()); + $this->app->Tpl->Set('MESSAGE', "
Office365 Authorization Fehler: " . $e->getMessage() . "
"); + $this->emailbackup_edit(); + } + } + + public function emailbackup_office365_callback() + { + session_start(); + + $code = $this->app->Secure->GetGET('code'); + $error = $this->app->Secure->GetGET('error'); + $state = $this->app->Secure->GetGET('state'); + + // Extract account ID from state parameter (format: account__) + $accountId = null; + if (!empty($state) && strpos($state, 'account_') === 0) { + $stateParts = explode('_', $state); + if (count($stateParts) >= 2) { + $accountId = (int)$stateParts[1]; + } + } + + if (!empty($error)) { + $msg = $this->app->erp->base64_url_encode("
Office365 Authorization abgelehnt: " . htmlspecialchars($error) . "
"); + if (!empty($accountId)) { + header("Location: index.php?module=emailbackup&action=edit&id=" . $accountId . "&msg=" . $msg); + } else { + header("Location: index.php?module=emailbackup&action=list&msg=" . $msg); + } + exit; + } + + if (empty($code)) { + $msg = $this->app->erp->base64_url_encode("
Kein Authorization Code erhalten
"); + header("Location: index.php?module=emailbackup&action=list&msg=" . $msg); + exit; + } + + // CSRF protection: state parameter must match the value stored in session + $sessionState = $_SESSION['office365_state'] ?? null; + if (empty($state) || empty($sessionState) || !hash_equals($sessionState, $state)) { + $msg = $this->app->erp->base64_url_encode("
Ungültiger State-Parameter (CSRF-Schutz). Bitte erneut autorisieren.
"); + header("Location: index.php?module=emailbackup&action=list&msg=" . $msg); + exit; + } + + // Get account ID from state or session as fallback + if (empty($accountId)) { + $accountId = $_SESSION['office365_account_id'] ?? null; + } + $email = $_SESSION['office365_email'] ?? null; + + if (empty($accountId)) { + $msg = $this->app->erp->base64_url_encode("
Session abgelaufen - bitte versuche es erneut
"); + header("Location: index.php?module=emailbackup&action=list&msg=" . $msg); + exit; + } + + try { + // Load the account first to verify it exists + $result = $this->app->DB->SelectArr("SELECT email FROM emailbackup WHERE id = $accountId LIMIT 1"); + if (empty($result)) { + throw new Exception("Account not found"); + } + $email = $result[0]['email'] ?? $email; + + // Get services from container + $office365AuthService = $this->app->Container->get('Office365AuthorizationService'); + $office365Gateway = $this->app->Container->get('Office365AccountGateway'); + + // Get current user ID + $userId = (int)$this->app->User->GetID(); + if (empty($userId)) { + throw new Exception("No user session found"); + } + + // Process the authorization with correct user ID + $office365Account = $office365AuthService->authorizationCallback($code, $userId); + + // Save email address as property + $office365Gateway->saveAccountProperty($office365Account->getId(), 'email_address', $email); + + unset($_SESSION['office365_account_id']); + unset($_SESSION['office365_email']); + unset($_SESSION['office365_state']); + + $msg = $this->app->erp->base64_url_encode("
Office365 Account erfolgreich authorisiert!
"); + header("Location: index.php?module=emailbackup&action=edit&id=" . $accountId . "&msg=" . $msg); + exit; + } catch (Exception $e) { + error_log("Office365 Callback Error: " . $e->getMessage() . " - " . $e->getTraceAsString()); + $msg = $this->app->erp->base64_url_encode("
Authorization Fehler: " . htmlspecialchars($e->getMessage()) . "
"); + header("Location: index.php?module=emailbackup&action=edit&id=" . $accountId . "&msg=" . $msg); + exit; + } + } }