7.0 KiB
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
configas encrypted JSON ('config' => 'encrypted:array') - Method
buildTransport(): \Symfony\Component\Mailer\Transport\TransportInterfaceazure→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, callgetToken(), 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)
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)
$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:
- Account Name (slug) — text, validated
^[a-z0-9\-]+$, unique - Label — text, display name
- Type — dropdown: Microsoft 365 / SMTP
- 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)
- From Address — email
- From Name — text, optional
- 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 $azureSettingsfromSettingsController::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)