# Email SMTP Configuration (Admin UI) — Plan & Build Record

> **Status (2026-05-17):** ⏳ Planned.
>
> **Goal:** Build an admin-panel page where the **superadmin** (`mpsjajournal@gmail.com`) types in SMTP credentials (host, port, username, password, encryption, from-address, from-name), saves them to the database, and clicks a **"Send Test Email"** button to verify the configuration works — all without editing `.env`.
>
> **Non-goal (for this plan):** Building Mailables for password-reset, contact-form, etc. Those are downstream features that will *consume* the SMTP config this plan delivers. They will be planned separately.
>
> **Pattern reference:** Mirrors the existing admin CRUD pattern established in `circulars-admin-plan.md` (controller + Blade view + DataTable/form), with the difference that this is a **singleton** resource (one mail_settings row, not many), so no DataTable / no list view — just an edit form.

---

## 1. Why DB-stored, not `.env`?

Current `.env` setup:

```env
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
...
```

`.env` requires:
- SSH/file access to the server to edit
- A `php artisan config:clear` after every change
- A developer (the superadmin is not necessarily a dev)

Moving SMTP config to the DB lets the superadmin self-serve from `/admin/settings/email`. Trade-off: the SMTP password now lives in MySQL instead of a server file — must be **encrypted at rest** (see §6).

---

## 2. Data Model

A **singleton** table — the app only ever needs one active SMTP config.

### Migration

```php
// database/migrations/2026_05_17_xxxxxx_create_mail_settings_table.php
Schema::create('mail_settings', function (Blueprint $table) {
    $table->id();
    $table->string('mailer')->default('smtp');           // smtp | log
    $table->string('host')->nullable();
    $table->unsignedSmallInteger('port')->nullable();
    $table->string('username')->nullable();
    $table->text('password')->nullable();                // TEXT because encrypted ciphertext is longer than the plaintext
    $table->string('encryption', 16)->nullable();        // tls | ssl | null
    $table->string('from_address')->nullable();
    $table->string('from_name')->nullable();
    $table->timestamp('last_tested_at')->nullable();
    $table->string('last_test_status', 16)->nullable();  // success | failed
    $table->text('last_test_error')->nullable();
    $table->timestamps();
});
```

### Model

```php
// app/Models/MailSetting.php
class MailSetting extends Model
{
    protected $fillable = [
        'mailer', 'host', 'port', 'username', 'password',
        'encryption', 'from_address', 'from_name',
    ];

    protected $casts = [
        'password'        => 'encrypted',   // ← AES-256-CBC via APP_KEY, auto-encrypt/decrypt
        'last_tested_at'  => 'datetime',
    ];

    // Singleton accessor — there's only ever one row.
    public static function current(): self
    {
        return static::firstOrCreate(['id' => 1], ['mailer' => 'log']);
    }
}
```

> **Decided (2026-05-17):** Singleton `mail_settings` table (this design). If other admin-editable config arrives later (site title, contact email, etc.) it can get its own purpose-built table or a separate generic `settings` table — keep this one focused on mail.

---

## 3. Runtime Config Override

Laravel reads `config/mail.php` at boot. The DB row must override that config **before** any `Mail::send()` call resolves a mailer.

### Service Provider

```php
// app/Providers/MailSettingsServiceProvider.php
class MailSettingsServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Skip during migration / install — table may not exist yet.
        if (! Schema::hasTable('mail_settings')) {
            return;
        }

        $cfg = MailSetting::current();

        // Fall back to .env defaults if the row is unconfigured.
        if (blank($cfg->host)) {
            return;
        }

        Config::set('mail.default', $cfg->mailer);
        Config::set('mail.mailers.smtp.host',       $cfg->host);
        Config::set('mail.mailers.smtp.port',       $cfg->port);
        Config::set('mail.mailers.smtp.username',   $cfg->username);
        Config::set('mail.mailers.smtp.password',   $cfg->password);  // decrypted by cast
        Config::set('mail.mailers.smtp.encryption', $cfg->encryption);
        Config::set('mail.from.address',            $cfg->from_address);
        Config::set('mail.from.name',               $cfg->from_name);
    }
}
```

Register it in `bootstrap/providers.php` (Laravel 11 convention, not `config/app.php`).

**Caching note:** if `config:cache` is later run in production, this provider still runs on every request (it boots after the cached config loads), so the DB values take precedence. Good.

---

## 4. Admin Routes

Added to the existing `auth + role:superadmin` group in `routes/web.php` (see `backendwork.md` — `EnsureUserHasRole` middleware aliased as `role`):

```php
Route::middleware(['auth', 'role:superadmin'])->prefix('admin')->name('admin.')->group(function () {
    // ... existing admin routes ...

    Route::prefix('settings/email')->name('settings.email.')->group(function () {
        Route::get('/',          [EmailSettingController::class, 'edit'])->name('edit');
        Route::post('/',         [EmailSettingController::class, 'save'])->name('save');
        Route::post('/test',     [EmailSettingController::class, 'sendTest'])->name('test');
    });
});
```

---

## 5. Controller

`app/Http/Controllers/Admin/EmailSettingController.php` — three actions. Follows the inline-`Validator::make` pattern already locked in across the admin panel (per `backendwork.md` status table).

| Method | Verb + path | Job |
|---|---|---|
| `edit()` | `GET /admin/settings/email` | Render the form, pre-filled from `MailSetting::current()`. Password field rendered empty (never echo decrypted secrets back to the browser). |
| `save()` | `POST /admin/settings/email` | Validate → update the singleton row. **If the password field is blank, keep the existing value** (so the admin can update host without retyping the password). Redirect back with success flash. |
| `sendTest()` | `POST /admin/settings/email/test` | Validate `to_email` → send a `Mail::raw()` test message → update `last_tested_at` + `last_test_status` + `last_test_error` → return JSON for SweetAlert. |

### `sendTest()` sketch

```php
public function sendTest(Request $request)
{
    $v = Validator::make($request->all(), [
        'to_email' => 'required|email',
    ]);
    if ($v->fails()) {
        return response()->json(['ok' => false, 'error' => $v->errors()->first()], 422);
    }

    $cfg = MailSetting::current();

    try {
        Mail::raw(
            "This is a test email from JotiJournal admin panel.\nSent at " . now()->toDateTimeString(),
            fn ($m) => $m->to($request->to_email)->subject('JotiJournal — SMTP test')
        );

        $cfg->update([
            'last_tested_at'   => now(),
            'last_test_status' => 'success',
            'last_test_error'  => null,
        ]);

        return response()->json(['ok' => true, 'message' => "Test email sent to {$request->to_email}"]);
    } catch (\Throwable $e) {
        $cfg->update([
            'last_tested_at'   => now(),
            'last_test_status' => 'failed',
            'last_test_error'  => $e->getMessage(),
        ]);

        return response()->json(['ok' => false, 'error' => $e->getMessage()], 500);
    }
}
```

> **Decision needed (UX):** Should the test-email button send using **(a) the currently-saved row** (admin must Save first) or **(b) the form values the admin just typed, before save** (lets them probe credentials without committing a broken config)?
>
> Option (b) is friendlier — but means `sendTest()` accepts host/port/etc. in the request and temporarily overrides config for that one send via `config()->set()` inside the action. Slightly more code, much better UX for trial-and-error tuning.
>
> Recommendation: **option (b)**, because the whole point of a test button is to validate config *before* saving anything bad.

---

## 6. Security

- **Password encryption:** the `encrypted` cast on `MailSetting::password` uses Laravel's `Crypt` facade (AES-256-CBC, key = `APP_KEY`). Rotating `APP_KEY` will orphan the stored password — document this in the deployment runbook.
- **Never echo the password back:** the edit form renders the password `<input>` empty. The save action treats empty-string as "keep existing".
- **Role gate:** the route is behind `role:superadmin` (per `project_superadmin_account.md` — `mpsjajournal@gmail.com` is the only superadmin). Regular `user` role cannot reach the page.
- **CSRF:** Both the save form and the test-email AJAX call must include `@csrf` / the `X-CSRF-TOKEN` header. Laravel's `VerifyCsrfToken` middleware is on by default for `web` routes.
- **Audit trail:** `last_tested_at` / `last_test_status` / `last_test_error` columns give a minimal audit. Consider a richer `mail_test_logs` table if you want history of all attempts. **Out of scope for v1.**

---

## 7. View

`resources/views/admin/settings/email.blade.php` — single Blade extending `layouts/admin`. Form layout ported from the AdminKit template `forms-layouts.html` (referenced in `backendwork.md`).

### Field list

| Field | Type | Notes |
|---|---|---|
| `mailer` | `<select>` | `smtp`, `log` |
| `host` | text | placeholder: `smtp.gmail.com` |
| `port` | number | placeholder: `587` |
| `encryption` | `<select>` | `tls`, `ssl`, `(none)` |
| `username` | text | placeholder: `mpsjajournal@gmail.com` |
| `password` | password | rendered empty; "Leave blank to keep current" helper text |
| `from_address` | email | |
| `from_name` | text | |
| `to_email` (test only) | email | sits in a separate panel beside the form |

### Buttons

- **Save settings** — submits to `admin.settings.email.save`
- **Send Test Email** — opens a SweetAlert modal asking for the recipient, then AJAX POSTs to `admin.settings.email.test` with the **current form values** (option (b) above); shows success/error in another SweetAlert. (Same pattern as the AJAX-modal CRUD already established in Phase 2.1 of `backendwork.md`.)

### Status panel

A small read-only card showing `last_tested_at`, `last_test_status`, and `last_test_error` so the admin can see at a glance whether the saved config is known-good.

---

## 8. Sidebar Link

Add a new item to `resources/views/admin/partials/sidebar.blade.php` under a "Settings" group:

```blade
<li class="sidebar-item {{ request()->routeIs('admin.settings.email.*') ? 'active' : '' }}">
  <a class="sidebar-link" href="{{ route('admin.settings.email.edit') }}">
    <i class="align-middle" data-feather="mail"></i>
    <span class="align-middle">Email (SMTP)</span>
  </a>
</li>
```

---

## 9. Build Sequence (Checklist)

- [ ] **Decisions (close before building)**
  - [ ] Singleton row vs. generic settings table → §2
  - [ ] Test button: saved row vs. live form values → §5
- [ ] **Phase 1 — Schema & model**
  - [ ] Migration `create_mail_settings_table`
  - [ ] `App\Models\MailSetting` with `encrypted` cast on password + `current()` singleton helper
  - [ ] Seed a default `mailer = 'log'` row so the app boots cleanly on first install
- [ ] **Phase 2 — Runtime override**
  - [ ] `App\Providers\MailSettingsServiceProvider`
  - [ ] Register in `bootstrap/providers.php`
  - [ ] Verify with `php artisan tinker`: `config('mail.mailers.smtp.host')` reflects DB row
- [ ] **Phase 3 — Admin UI**
  - [ ] Routes in `routes/web.php` (under existing `role:superadmin` group)
  - [ ] `Admin\EmailSettingController` — `edit`, `save`, `sendTest`
  - [ ] Blade view `admin/settings/email.blade.php` (form + status panel)
  - [ ] Sidebar link
- [ ] **Phase 4 — Test-email AJAX**
  - [ ] JS in the Blade view (mirroring `datatable.custom.js` AJAX pattern)
  - [ ] SweetAlert success/failure modals
  - [ ] Manual smoke test against Gmail SMTP (App Password)
- [ ] **Phase 5 — Hardening**
  - [ ] Confirm `MAIL_PASSWORD` is removed from `.env` once DB config works (or leave as a fallback — decide)
  - [ ] Document `APP_KEY` rotation impact in deployment notes

---

## 10. Manual Test Plan

After build, log in as `mpsjajournal@gmail.com` and:

1. Navigate to `/admin/settings/email`.
2. Enter Gmail SMTP values (host `smtp.gmail.com`, port `587`, encryption `tls`, username = the Gmail address, password = a 16-char App Password from `myaccount.google.com/apppasswords`).
3. Click **Send Test Email**, enter your own inbox as the recipient.
4. Expect: SweetAlert "success", a real email in the inbox, and the status panel showing `last_tested_at = now`, `last_test_status = success`.
5. Click **Save settings**.
6. Reload the page — fields are pre-filled except password (blank). Status panel still shows the success.
7. Edit host to a deliberately wrong value (`smtp.notreal.com`), click **Send Test Email** without saving — expect a SweetAlert error and the status panel showing `failed` with the exception message.

---

## 11. Open Questions

1. Should `mailer = 'log'` remain a selectable option in the dropdown (useful for taking the app off real mail temporarily, e.g. during data migrations)?
2. Should there be a "reset to .env defaults" button, or is the admin expected to wipe the row manually if they want to revert?
3. Do we want a `mail_test_logs` table for full history of test attempts, or is the single `last_test_*` triple enough?
