diff --git a/docs/superpowers/plans/2026-05-26-multi-mail-accounts.md b/docs/superpowers/plans/2026-05-26-multi-mail-accounts.md new file mode 100644 index 0000000..6f6621c --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-multi-mail-accounts.md @@ -0,0 +1,832 @@ +# Multi Mail Accounts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the single Azure-account Email tab with a full multi-account mail management system supporting Microsoft 365 (Azure AD) and SMTP accounts. + +**Architecture:** New `mail_accounts` DB table with encrypted JSON config; `MailAccount` model with `buildTransport()` factory; `MailAccountController` for CRUD + test + toggle; `AppServiceProvider` registers all accounts as named Laravel mailers; Email tab in integrations view rebuilt as account list + AJAX modal. + +**Tech Stack:** Laravel 12, Alpine.js v3, `PromoSeven\AzureMailer` (local package), `Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport`, `Setting` model (existing). + +--- + +## File Map + +| File | Change | +|------|--------| +| `database/migrations/2026_05_26_000001_create_mail_accounts_table.php` | Create | +| `app/Models/MailAccount.php` | Create | +| `app/Http/Controllers/MailAccountController.php` | Create | +| `app/Providers/AppServiceProvider.php` | Add dynamic mailer registration | +| `routes/web.php` | Add 7 new routes; remove 3 old azure routes | +| `app/Http/Controllers/SettingsController.php` | Remove azure methods + `$azureSettings` | +| `resources/views/settings/integrations.blade.php` | Replace Email tab with account list + modal | + +--- + +### Task 1: Migration and MailAccount model + +**Files:** +- Create: `database/migrations/2026_05_26_000001_create_mail_accounts_table.php` +- Create: `app/Models/MailAccount.php` + +- [ ] **Step 1: Create the migration** + +```php +id(); + $table->string('name', 100)->unique(); + $table->string('label', 150); + $table->enum('type', ['azure', 'smtp']); + $table->string('from_address', 255); + $table->string('from_name', 150)->nullable(); + $table->text('config'); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mail_accounts'); + } +}; +``` + +- [ ] **Step 2: Run the migration** + +```bash +php artisan migrate +``` + +Expected: `mail_accounts` table created with no errors. + +- [ ] **Step 3: Create `app/Models/MailAccount.php`** + +```php + 'encrypted:array', + 'enabled' => 'boolean', + ]; + + public function buildTransport(): TransportInterface + { + if ($this->type === 'azure') { + $config = array_merge($this->config, [ + 'from_address' => $this->from_address, + 'graph_api_version' => 'v1.0', + 'timeout' => 30, + 'save_to_sent_items' => false, + ]); + return new AzureTransport( + new GraphClient(new TokenManager($config), $config), + $config + ); + } + + $cfg = $this->config; + $tls = ($cfg['encryption'] ?? 'tls') === 'ssl'; + $transport = new EsmtpTransport($cfg['host'], (int) ($cfg['port'] ?? 587), $tls); + if (!empty($cfg['username'])) { + $transport->setUsername($cfg['username']); + $transport->setPassword($cfg['password'] ?? ''); + } + return $transport; + } +} +``` + +- [ ] **Step 4: Verify model is autoloaded** + +```bash +php artisan tinker --execute="echo App\Models\MailAccount::count();" +``` + +Expected: prints `0` with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add database/migrations/2026_05_26_000001_create_mail_accounts_table.php app/Models/MailAccount.php +git commit -m "feat: add mail_accounts migration and MailAccount model" +``` + +--- + +### Task 2: MailAccountController and routes + +**Files:** +- Create: `app/Http/Controllers/MailAccountController.php` +- Modify: `routes/web.php` + +- [ ] **Step 1: Create `app/Http/Controllers/MailAccountController.php`** + +```php +get()->map(fn($a) => $this->accountData($a)); + return response()->json(['accounts' => $accounts]); + } + + public function show(MailAccount $mailAccount): JsonResponse + { + return response()->json([ + 'account' => array_merge($this->accountData($mailAccount), ['config' => $mailAccount->config]), + ]); + } + + public function store(Request $request): JsonResponse + { + $data = $this->validated($request); + $account = MailAccount::create($data); + return response()->json(['success' => true, 'account' => $this->accountData($account)], 201); + } + + public function update(Request $request, MailAccount $mailAccount): JsonResponse + { + $data = $this->validated($request, $mailAccount->id); + $mailAccount->update($data); + return response()->json(['success' => true, 'account' => $this->accountData($mailAccount->fresh())]); + } + + public function destroy(MailAccount $mailAccount): JsonResponse + { + $mailAccount->delete(); + return response()->json(['success' => true]); + } + + public function testConnection(MailAccount $mailAccount): JsonResponse + { + try { + if ($mailAccount->type === 'azure') { + $config = array_merge($mailAccount->config, ['from_address' => $mailAccount->from_address]); + (new TokenManager($config))->getToken(); + } else { + $cfg = $mailAccount->config; + $host = $cfg['host'] ?? ''; + $port = (int) ($cfg['port'] ?? 587); + $socket = @fsockopen($host, $port, $errno, $errstr, 5); + if (! $socket) { + throw new \RuntimeException("Cannot connect to {$host}:{$port} — {$errstr}"); + } + fclose($socket); + } + return response()->json(['success' => true]); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()]); + } + } + + public function toggleEnabled(MailAccount $mailAccount): JsonResponse + { + $mailAccount->update(['enabled' => ! $mailAccount->enabled]); + return response()->json(['success' => true, 'enabled' => $mailAccount->fresh()->enabled]); + } + + private function validated(Request $request, ?int $ignoreId = null): array + { + $nameUnique = 'unique:mail_accounts,name' . ($ignoreId ? ",{$ignoreId}" : ''); + $rules = [ + 'name' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9\-]+$/', $nameUnique], + 'label' => ['required', 'string', 'max:150'], + 'type' => ['required', 'in:azure,smtp'], + 'from_address' => ['required', 'email', 'max:255'], + 'from_name' => ['nullable', 'string', 'max:150'], + 'enabled' => ['boolean'], + ]; + + if ($request->input('type') === 'azure') { + $rules['config.tenant_id'] = ['required', 'string', 'max:100']; + $rules['config.client_id'] = ['required', 'string', 'max:100']; + $rules['config.client_secret'] = ['required', 'string', 'max:500']; + } else { + $rules['config.host'] = ['required', 'string', 'max:255']; + $rules['config.port'] = ['required', 'integer', 'min:1', 'max:65535']; + $rules['config.encryption'] = ['required', 'in:tls,ssl,none']; + $rules['config.username'] = ['nullable', 'string', 'max:255']; + $rules['config.password'] = ['nullable', 'string', 'max:500']; + } + + $v = $request->validate($rules); + return [ + 'name' => $v['name'], + 'label' => $v['label'], + 'type' => $v['type'], + 'from_address' => $v['from_address'], + 'from_name' => $v['from_name'] ?? null, + 'config' => $v['config'], + 'enabled' => $v['enabled'] ?? true, + ]; + } + + private function accountData(MailAccount $account): array + { + return [ + 'id' => $account->id, + 'name' => $account->name, + 'label' => $account->label, + 'type' => $account->type, + 'from_address' => $account->from_address, + 'from_name' => $account->from_name, + 'enabled' => $account->enabled, + ]; + } +} +``` + +- [ ] **Step 2: Add the 7 new routes and remove the 3 old azure routes in `routes/web.php`** + +Find the block: +```php +Route::post('settings/integrations/azure-mail', [SettingsController::class, 'updateAzureMail'])->name('settings.integrations.azure-mail'); +Route::post('settings/integrations/test-azure-mail', [SettingsController::class, 'testAzureMailConnection'])->name('settings.integrations.test-azure-mail'); +Route::post('settings/integrations/send-test-email', [SettingsController::class, 'sendTestEmail'])->name('settings.integrations.send-test-email'); +``` + +Replace it with: +```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::get('settings/integrations/mail-accounts/{mailAccount}', [MailAccountController::class, 'show'])->name('settings.mail-accounts.show'); +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'); +``` + +Also add the `MailAccountController` use statement at the top of `routes/web.php` alongside the existing controller use statements: +```php +use App\Http\Controllers\MailAccountController; +``` + +- [ ] **Step 3: Verify routes** + +```bash +php artisan route:list --name=settings.mail-accounts +``` + +Expected: 7 rows — index, store, show, update, destroy, test, toggle. + +- [ ] **Step 4: Commit** + +```bash +git add app/Http/Controllers/MailAccountController.php routes/web.php +git commit -m "feat: add MailAccountController with CRUD + test + toggle routes" +``` + +--- + +### Task 3: AppServiceProvider + SettingsController cleanup + +**Files:** +- Modify: `app/Providers/AppServiceProvider.php` +- Modify: `app/Http/Controllers/SettingsController.php` + +- [ ] **Step 1: Update `app/Providers/AppServiceProvider.php`** + +Replace the entire file content with: + +```php + Setting::get('ultramsg_instance_id', config('ultra-message.instance_id')), + 'token' => Setting::get('ultramsg_token', config('ultra-message.token')), + 'webhook_secret' => Setting::get('ultramsg_webhook_secret', config('ultra-message.webhook_secret')), + 'webhook_path' => Setting::get('ultramsg_webhook_path', config('ultra-message.webhook_path', 'ultra-message/webhook')), + 'timeout' => config('ultra-message.timeout', 30), + 'enabled' => (bool) Setting::get('ultramsg_enabled', config('ultra-message.enabled', true)), + ]; + }); + + $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 on fresh install — skip silently + } + }); + } +} +``` + +- [ ] **Step 2: Clean up `app/Http/Controllers/SettingsController.php`** + +Remove the `use Illuminate\Support\Facades\Mail;` and `use PromoSeven\AzureMailer\Graph\TokenManager;` use statements. + +Change `integrations()` to only pass `$whatsappSettings` (remove `$azureSettings`): + +```php +public function integrations(): View +{ + $whatsappSettings = [ + 'enabled' => Setting::get('ultramsg_enabled', false), + 'instance_id' => Setting::get('ultramsg_instance_id', ''), + 'token' => Setting::get('ultramsg_token', ''), + 'webhook_secret' => Setting::get('ultramsg_webhook_secret', ''), + 'webhook_path' => Setting::get('ultramsg_webhook_path', 'ultra-message/webhook'), + ]; + + return view('settings.integrations', compact('whatsappSettings')); +} +``` + +Delete the three azure methods entirely: `updateAzureMail()`, `testAzureMailConnection()`, `sendTestEmail()`. + +- [ ] **Step 3: Verify no parse errors** + +```bash +php artisan route:list --name=settings.integrations 2>&1 | tail -3 +``` + +Expected: shows 4 rows (GET + whatsapp POST + test-whatsapp POST + send-test-message POST), no errors. + +- [ ] **Step 4: Commit** + +```bash +git add app/Providers/AppServiceProvider.php app/Http/Controllers/SettingsController.php +git commit -m "feat: register dynamic mailers in AppServiceProvider, remove single-azure methods from SettingsController" +``` + +--- + +### Task 4: Rewrite Email tab in integrations.blade.php + +**Files:** +- Modify: `resources/views/settings/integrations.blade.php` + +Replace the entire `{{-- ===== Email tab ===== --}}` section (and the old Email modal/accordion) with the following. + +- [ ] **Step 1: Replace the Email tab panel and add the modal** + +The complete replacement for everything from `{{-- ===== Email tab ===== --}}` to `{{-- end Email tab --}}` (inclusive), plus the new modal added just before the closing `` of the outer wrapper, plus the new JS block replacing the old azure JS functions: + +**Email tab panel** (replaces old email tab div): + +```blade +{{-- ===== Email tab ===== --}} +{{-- end Email tab --}} +``` + +**Modal** (add just before the closing `` of the outer Alpine wrapper, after the email tab div): + +```blade +{{-- Mail Accounts Modal --}} + +``` + +- [ ] **Step 2: Update the Email tab button to also load accounts** + +In the pill tab row, change the Email tab button's `@click` from: +```blade +' + + '' + + '' + + ''; +} + +function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } +function escJs(s) { return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'"); } + +function maTypeChange() { + var type = document.getElementById('ma-form-type').value; + document.getElementById('ma-azure-section').style.display = type === 'azure' ? 'block' : 'none'; + document.getElementById('ma-smtp-section').style.display = type === 'smtp' ? 'block' : 'none'; +} + +function maOpen(id) { + _maEditId = id || null; + document.getElementById('ma-modal-title').textContent = id ? 'Edit Mail Account' : 'Add Mail Account'; + document.getElementById('ma-form-name').value = ''; + document.getElementById('ma-form-name').disabled = !!id; + document.getElementById('ma-form-label').value = ''; + document.getElementById('ma-form-type').value = 'smtp'; + document.getElementById('ma-form-from-address').value = ''; + document.getElementById('ma-form-from-name').value = ''; + ['ma-form-azure-tenant','ma-form-azure-client','ma-form-azure-secret', + 'ma-form-smtp-host','ma-form-smtp-username','ma-form-smtp-password'].forEach(function(id) { + document.getElementById(id).value = ''; + }); + document.getElementById('ma-form-smtp-port').value = '587'; + document.getElementById('ma-form-smtp-enc').value = 'tls'; + document.getElementById('ma-test-status').style.display = 'none'; + maTypeChange(); + + if (id) { + fetch(MA_BASE + '/' + id, { headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': CSRF } }) + .then(function(r) { return r.json(); }) + .then(function(data) { + var a = data.account, cfg = a.config || {}; + document.getElementById('ma-form-name').value = a.name; + document.getElementById('ma-form-label').value = a.label; + document.getElementById('ma-form-type').value = a.type; + document.getElementById('ma-form-from-address').value = a.from_address; + document.getElementById('ma-form-from-name').value = a.from_name || ''; + if (a.type === 'azure') { + document.getElementById('ma-form-azure-tenant').value = cfg.tenant_id || ''; + document.getElementById('ma-form-azure-client').value = cfg.client_id || ''; + document.getElementById('ma-form-azure-secret').value = cfg.client_secret || ''; + } else { + document.getElementById('ma-form-smtp-host').value = cfg.host || ''; + document.getElementById('ma-form-smtp-port').value = cfg.port || 587; + document.getElementById('ma-form-smtp-enc').value = cfg.encryption || 'tls'; + document.getElementById('ma-form-smtp-username').value = cfg.username || ''; + document.getElementById('ma-form-smtp-password').value = cfg.password || ''; + } + maTypeChange(); + }); + } + document.getElementById('ma-modal-overlay').style.display = 'flex'; +} + +function maClose() { + document.getElementById('ma-modal-overlay').style.display = 'none'; +} + +function maSave() { + var type = document.getElementById('ma-form-type').value; + var config = type === 'azure' ? { + tenant_id: document.getElementById('ma-form-azure-tenant').value.trim(), + client_id: document.getElementById('ma-form-azure-client').value.trim(), + client_secret: document.getElementById('ma-form-azure-secret').value.trim(), + } : { + host: document.getElementById('ma-form-smtp-host').value.trim(), + port: parseInt(document.getElementById('ma-form-smtp-port').value) || 587, + encryption: document.getElementById('ma-form-smtp-enc').value, + username: document.getElementById('ma-form-smtp-username').value.trim(), + password: document.getElementById('ma-form-smtp-password').value, + }; + + var data = { + name: document.getElementById('ma-form-name').value.trim(), + label: document.getElementById('ma-form-label').value.trim(), + type: type, + from_address: document.getElementById('ma-form-from-address').value.trim(), + from_name: document.getElementById('ma-form-from-name').value.trim() || null, + config: config, + }; + + var btn = document.getElementById('ma-save-btn'); + var url = _maEditId ? MA_BASE + '/' + _maEditId : MA_BASE; + var method = _maEditId ? 'PUT' : 'POST'; + btn.disabled = true; btn.style.opacity = '.6'; + + fetch(url, { + method: method, + headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(function(r) { + return r.json().then(function(body) { if (!r.ok) return Promise.reject(body); return body; }); + }).then(function() { + maClose(); + showToast(_maEditId ? 'Account updated.' : 'Account added.', 'success'); + loadMailAccounts(); + }).catch(function(err) { + var msg = err.errors ? Object.values(err.errors)[0][0] : (err.message || 'Error saving account.'); + showToast(msg, 'error'); + }).finally(function() { btn.disabled = false; btn.style.opacity = '1'; }); +} + +function maDelete(id, label) { + confirmAction('Delete Mail Account', 'Delete "' + label + '"? This cannot be undone.', function() { + fetch(MA_BASE + '/' + id, { + method: 'DELETE', + headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' } + }).then(function(r) { return r.json(); }).then(function(res) { + if (res.success) { showToast('Account deleted.', 'success'); loadMailAccounts(); } + }); + }); +} + +function maToggle(id) { + fetch(MA_BASE + '/' + id + '/toggle', { + method: 'PATCH', + headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' } + }).then(function(r) { return r.json(); }).then(function(res) { + if (!res.success) return; + var track = document.getElementById('ma-toggle-' + id); + var thumb = document.getElementById('ma-thumb-' + id); + if (track && thumb) { + track.style.background = res.enabled ? '#22c55e' : '#d1d5db'; + thumb.style.left = res.enabled ? '22px' : '2px'; + } + var acc = _maAccounts.find(function(a) { return a.id === id; }); + if (acc) acc.enabled = res.enabled; + }); +} + +function maTest() { + if (!_maEditId) { showToast('Save the account first, then test it.', 'warn'); return; } + var statusEl = document.getElementById('ma-test-status'); + statusEl.textContent = 'Testing…'; statusEl.style.display = ''; statusEl.style.color = '#6b7280'; + fetch(MA_BASE + '/' + _maEditId + '/test', { + method: 'POST', + headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' } + }).then(function(r) { return r.json(); }).then(function(data) { + if (data.success) { + statusEl.textContent = 'Connected ✓'; statusEl.style.color = '#16a34a'; + showToast('Connection successful.', 'success'); + } else { + statusEl.textContent = 'Failed.'; statusEl.style.color = '#dc2626'; + showToast(data.message || 'Connection failed.', 'error'); + } + }).catch(function() { statusEl.textContent = 'Request failed.'; statusEl.style.color = '#dc2626'; }); +} +``` + +- [ ] **Step 4: Verify page loads** + +Visit `http://localhost:8000/settings/integrations` as Admin. +- WhatsApp tab loads normally +- Clicking Email tab shows "Mail Accounts" header + "Add Account" button + empty state +- Clicking "Add Account" opens the modal with SMTP fields visible by default +- Switching type to "Microsoft 365" shows Azure fields, hides SMTP fields + +- [ ] **Step 5: Verify Add Account (SMTP)** + +In the modal: fill in name `test-smtp`, label `Test SMTP`, type SMTP, host `smtp.gmail.com`, port `587`, encryption `TLS`, username `test@test.com`, password `secret`, from address `test@test.com`. Click Save Account. +Expected: toast "Account added.", modal closes, account row appears in list. + +- [ ] **Step 6: Verify Edit and Delete** + +Click Edit on the row just created — modal opens with pre-filled values. Change the label to `Test SMTP Updated`, Save. Expected: toast "Account updated.", row updates. + +Click Delete — confirmation modal appears. Confirm. Expected: toast "Account deleted.", row disappears. + +- [ ] **Step 7: Commit** + +```bash +git add resources/views/settings/integrations.blade.php +git commit -m "feat: rewrite Email tab as multi-account list with Add/Edit/Delete modal" +``` diff --git a/resources/views/settings/integrations.blade.php b/resources/views/settings/integrations.blade.php index 760a6dd..4eef4f5 100644 --- a/resources/views/settings/integrations.blade.php +++ b/resources/views/settings/integrations.blade.php @@ -17,7 +17,7 @@ style="padding:7px 18px;border-radius:999px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;"> 💬 WhatsApp - + + + + + + + {{-- end Email tab --}} + + {{-- Mail Accounts Modal --}} +