171 lines
7.0 KiB
Markdown
171 lines
7.0 KiB
Markdown
# Multi Mail Accounts — Design Spec
|
|
|
|
**Date:** 2026-05-26
|
|
**Status:** Approved
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
Replace the single-Azure-account Email tab with a full multi-account mail management system. Admins can add any number of named mail accounts (Microsoft 365 or SMTP), enable/disable them individually, and reference them in code by name via `Mail::mailer('account-name')`.
|
|
|
|
---
|
|
|
|
## Goals
|
|
|
|
- Support unlimited named mail accounts of two types: **Microsoft 365 (Azure AD)** and **SMTP**
|
|
- Each account has a slug name used in code (`Mail::mailer('invoices')`)
|
|
- Enable/disable toggle per account (used for vacation substitution: disable A, enable B)
|
|
- Add / Edit / Delete accounts via AJAX modal on the Settings → Integrations → Email tab
|
|
- Test Connection action per account
|
|
- Dynamic mailer registration: all enabled accounts available as named Laravel mailers
|
|
- Abandon the old single `azure_mail_*` Setting keys; admin re-enters via new UI
|
|
|
|
---
|
|
|
|
## Data Model
|
|
|
|
### Table: `mail_accounts`
|
|
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| `id` | bigint PK | |
|
|
| `name` | varchar(100) unique | slug, lowercase + hyphens, used as mailer name |
|
|
| `label` | varchar(150) | human-readable display name |
|
|
| `type` | enum(`azure`, `smtp`) | |
|
|
| `from_address` | varchar(255) | sender email address |
|
|
| `from_name` | varchar(150) nullable | sender display name |
|
|
| `config` | JSON | type-specific credentials (encrypted at rest) |
|
|
| `enabled` | boolean default true | |
|
|
| `timestamps` | | |
|
|
|
|
**Azure config keys:** `tenant_id`, `client_id`, `client_secret`
|
|
|
|
**SMTP config keys:** `host`, `port` (int), `encryption` (enum: `tls`/`ssl`/`none`), `username`, `password`
|
|
|
|
### Model: `App\Models\MailAccount`
|
|
|
|
- Cast `config` as encrypted JSON (`'config' => 'encrypted:array'`)
|
|
- Method `buildTransport(): \Symfony\Component\Mailer\Transport\TransportInterface`
|
|
- `azure` → `new AzureTransport(new GraphClient(new TokenManager([...config + from_address]), [...]), [...])`
|
|
- `smtp` → `new EsmtpTransport(host, port, encryption === 'ssl')` with username/password
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
### Files changed
|
|
|
|
| File | Change |
|
|
|------|--------|
|
|
| `database/migrations/…_create_mail_accounts_table.php` | New migration |
|
|
| `app/Models/MailAccount.php` | New model with `buildTransport()` |
|
|
| `app/Providers/AppServiceProvider.php` | Register dynamic mailers in `boot()` |
|
|
| `app/Http/Controllers/MailAccountController.php` | New controller: index, store, update, destroy, testConnection |
|
|
| `routes/web.php` | Add 5 new routes inside `role:Admin` group |
|
|
| `app/Http/Controllers/SettingsController.php` | Remove Azure methods + `$azureSettings` from `integrations()` |
|
|
| `resources/views/settings/integrations.blade.php` | Replace Email tab content with account list + JS modal |
|
|
|
|
---
|
|
|
|
## Controller: `MailAccountController`
|
|
|
|
**`index(): JsonResponse`** — return all accounts ordered by name, without `config` values (for security)
|
|
|
|
**`store(Request $request): JsonResponse`**
|
|
- Validate: `name` (required, unique, regex:/^[a-z0-9\-]+$/), `label` (required, max:150), `type` (required, in:azure,smtp), `from_address` (required, email), `from_name` (nullable), type-specific config fields
|
|
- Create `MailAccount`, return `{success: true, account: {...}}`
|
|
|
|
**`update(Request $request, MailAccount $mailAccount): JsonResponse`**
|
|
- Same validation (name unique ignoring self), update, return `{success: true, account: {...}}`
|
|
|
|
**`destroy(MailAccount $mailAccount): JsonResponse`**
|
|
- Delete, return `{success: true}`
|
|
|
|
**`testConnection(MailAccount $mailAccount): JsonResponse`**
|
|
- Azure: instantiate `TokenManager`, call `getToken()`, return `{success: true/false, message}`
|
|
- SMTP: open TCP socket to host:port (or attempt SMTP EHLO), return `{success: true/false, message}`
|
|
|
|
**`toggleEnabled(MailAccount $mailAccount): JsonResponse`**
|
|
- Flip `enabled`, return `{success: true, enabled: bool}`
|
|
|
|
---
|
|
|
|
## Routes (inside `role:Admin` group)
|
|
|
|
```php
|
|
Route::get('settings/integrations/mail-accounts', [MailAccountController::class, 'index'])->name('settings.mail-accounts.index');
|
|
Route::post('settings/integrations/mail-accounts', [MailAccountController::class, 'store'])->name('settings.mail-accounts.store');
|
|
Route::put('settings/integrations/mail-accounts/{mailAccount}', [MailAccountController::class, 'update'])->name('settings.mail-accounts.update');
|
|
Route::delete('settings/integrations/mail-accounts/{mailAccount}', [MailAccountController::class, 'destroy'])->name('settings.mail-accounts.destroy');
|
|
Route::post('settings/integrations/mail-accounts/{mailAccount}/test', [MailAccountController::class, 'testConnection'])->name('settings.mail-accounts.test');
|
|
Route::patch('settings/integrations/mail-accounts/{mailAccount}/toggle', [MailAccountController::class, 'toggleEnabled'])->name('settings.mail-accounts.toggle');
|
|
```
|
|
|
|
---
|
|
|
|
## Dynamic Mailer Registration (AppServiceProvider)
|
|
|
|
```php
|
|
$this->callAfterResolving(MailManager::class, function (MailManager $manager) {
|
|
try {
|
|
foreach (MailAccount::all() as $account) {
|
|
$manager->extend($account->name, fn() => $account->buildTransport());
|
|
}
|
|
} catch (\Exception) {
|
|
// DB not ready (fresh install) — skip silently
|
|
}
|
|
});
|
|
```
|
|
|
|
All accounts are registered (not just enabled ones), so `Mail::mailer('name')` always resolves. Whether the account is used is the caller's responsibility.
|
|
|
|
---
|
|
|
|
## Email Tab UI
|
|
|
|
### Account list
|
|
|
|
- Header: "Mail Accounts" + count + "Add Account" button (dark pill)
|
|
- Each row: type icon, account name (bold) + type badge, from address, enable toggle, Edit + Delete buttons
|
|
- Empty state: "No mail accounts configured. Click Add Account to get started."
|
|
|
|
### Add/Edit Modal
|
|
|
|
Fields:
|
|
1. **Account Name** (slug) — text, validated `^[a-z0-9\-]+$`, unique
|
|
2. **Label** — text, display name
|
|
3. **Type** — dropdown: Microsoft 365 / SMTP
|
|
4. **Type-specific section** (shown/hidden based on type):
|
|
- Azure: Tenant ID, Client ID, Client Secret (masked)
|
|
- SMTP: Host, Port (default 587), Encryption (TLS/SSL/None), Username, Password (masked)
|
|
5. **From Address** — email
|
|
6. **From Name** — text, optional
|
|
7. Footer: Test Connection link | Cancel + Save Account buttons
|
|
|
|
### Test Connection
|
|
|
|
Links to `testConnection` route for that account. Returns toast + inline status text.
|
|
|
|
### Delete
|
|
|
|
Uses `confirmAction()` modal before calling destroy route. On success, removes row from DOM.
|
|
|
|
---
|
|
|
|
## AJAX Pattern
|
|
|
|
All operations use the `api()` helper from CLAUDE.md rule #11. The account list is loaded via `fetch GET` on page load (Email tab visible) and updated in-place after each mutation — no page reloads.
|
|
|
|
---
|
|
|
|
## Removed
|
|
|
|
- `SettingsController::updateAzureMail()`
|
|
- `SettingsController::testAzureMailConnection()`
|
|
- `SettingsController::sendTestEmail()`
|
|
- Routes: `settings.integrations.azure-mail`, `settings.integrations.test-azure-mail`, `settings.integrations.send-test-email`
|
|
- `$azureSettings` from `SettingsController::integrations()`
|
|
- The single-account Email tab form in `integrations.blade.php`
|
|
- The "Send Test Email" accordion from the Email tab (test is now per-account via Test Connection)
|