diff --git a/.gitignore b/.gitignore
index f0fa3ee..0831ffd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ vendor/
coverage/
storage/
logs/
+vault/
*.log
*.cache
*.tmp
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ff1d57..6f41af3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,33 @@ All notable changes to `bitshost/upmvc-saas-pack`.
## [Unreleased]
+### Harvest — ops / mail / notify kernel / i18n / MFA primitives
+
+Lifted from the `myCMMS-PHP` probe into pack libraries so every SaaS product gets shared factory pieces without CMMS domain coupling. See `docs/09-harvest-ops.md`.
+
+#### Added
+- **`database/migrations/002_saas_ops_layer.sql`** — `platform_settings`, `smtp_settings`, `email_log`, `audit_log`, `notification_*`, `i18n_overlays`, optional `users.totp_*`.
+- **`BitsHost\UpmvcSaas\Mail\MailGateway`** + **`Security\SecretBox`** — platform SMTP, plan-gated tenant SMTP, `.env` fallback, send log.
+- **`BitsHost\UpmvcSaas\Notify\{NotifyRegistry,NotifyGate,Notify}`** — empty type registry (apps register), global pause, instant fire with mutes.
+- **`BitsHost\UpmvcSaas\Audit\AuditLog`** — append-only audit with impersonator support.
+- **`BitsHost\UpmvcSaas\Security\Totp`** — RFC 6238 helper for platform MFA.
+- **`BitsHost\UpmvcSaas\Ops\{Attention,PlatformSettings,SchedulerHeartbeat}`** — generic Attention + flags + cron heartbeat.
+- **`BitsHost\UpmvcSaas\I18n\Catalog`** — file catalogs + dotted-key DB overlays.
+- **`Api/Modules/PlatformOps`**, **`Api/Modules/I18n`** — thin overridable API surfaces.
+- **`public/lang/{en,ro}.json`** — minimal shell i18n seed.
+- **`tests/Unit/HarvestKernelTest.php`** — Totp, registry, audit actors, catalog overlays, migration guard.
+- **`docs/09-harvest-ops.md`** — wiring guide and pack vs new-pack rule.
+
+#### Changed
+- **`App\Modules\Mail\MailController`** — delegates to `MailGateway` (legacy helpers kept, deprecated).
+- **Harvest kernel classes** (`AuditLog`, `Totp`, `SecretBox`, `MailGateway`, `NotifyGate`, `Notify`) — dropped `final` so consuming apps can thin-extend without import churn.
+- **`Security\SecretBox` / `Security\Totp`** — dual-unseal read bridge: `seal()` writes with pack peppers only; `unseal()` also accepts myCMMS legacy peppers (`|smtp-password-v1`, `mycmms-totp|`) so path installs do not brick existing SMTP/MFA rows. See `docs/09-harvest-ops.md`.
+- **`.gitignore`** — ignore local `vault/` (agent handoff notes; never commit contents).
+- **`tests/Unit/HarvestKernelTest.php`** — dual-unseal coverage for legacy SMTP and TOTP peppers.
+
+#### Deliberately out of pack
+- Domain notify types, payments/billing, full PlatformAdmin UI tabs, AI translate.
+
---
## v1.0.0 "Tenancy" - Multi-tenant SaaS modules for upMVC (2026-07-29)
diff --git a/database/migrations/002_saas_ops_layer.sql b/database/migrations/002_saas_ops_layer.sql
new file mode 100644
index 0000000..a031e3a
--- /dev/null
+++ b/database/migrations/002_saas_ops_layer.sql
@@ -0,0 +1,168 @@
+-- ============================================================
+-- upMVC SaaS Pack — Migration 002 — Ops layer (Harvest)
+--
+-- Platform settings, SMTP gateway + email log, audit trail,
+-- notification kill-switches / mutes / settings, i18n overlays,
+-- optional TOTP columns on users.
+--
+-- Domain notification *types* stay in the app (NotifyRegistry).
+-- ============================================================
+
+SET @db := DATABASE();
+
+CREATE TABLE IF NOT EXISTS `platform_settings` (
+ `setting_key` varchar(64) NOT NULL,
+ `setting_value` text DEFAULT NULL,
+ `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
+ PRIMARY KEY (`setting_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT IGNORE INTO `platform_settings` (`setting_key`, `setting_value`) VALUES
+ ('signup_enabled', '1'),
+ ('maintenance_mode', '0'),
+ ('scheduler_heartbeat', ''),
+ ('default_locale', 'en'),
+ ('announcement_json', '');
+
+CREATE TABLE IF NOT EXISTS `smtp_settings` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `tenant_id` int DEFAULT NULL,
+ `host` varchar(253) NOT NULL,
+ `port` int NOT NULL DEFAULT 587,
+ `secure` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1 = implicit TLS (465)',
+ `username` varchar(254) DEFAULT NULL,
+ `password_enc` text DEFAULT NULL,
+ `from_email` varchar(254) NOT NULL,
+ `from_name` varchar(120) DEFAULT NULL,
+ `reply_to` varchar(254) DEFAULT NULL,
+ `enabled` tinyint(1) NOT NULL DEFAULT 1,
+ `last_test_at` datetime DEFAULT NULL,
+ `last_test_ok` tinyint(1) DEFAULT NULL,
+ `last_error` varchar(500) DEFAULT NULL,
+ `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
+ `created_at` datetime NOT NULL DEFAULT current_timestamp(),
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_smtp_tenant` (`tenant_id`),
+ CONSTRAINT `fk_smtp_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+SET @q := (
+ SELECT IF(
+ EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='smtp_settings' AND COLUMN_NAME='platform_slot'),
+ 'SELECT 1',
+ 'ALTER TABLE smtp_settings ADD COLUMN platform_slot TINYINT GENERATED ALWAYS AS (IF(tenant_id IS NULL, 1, NULL)) STORED'
+ )
+);
+PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @q := (
+ SELECT IF(
+ EXISTS(SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='smtp_settings' AND INDEX_NAME='uk_smtp_platform'),
+ 'SELECT 1',
+ 'ALTER TABLE smtp_settings ADD UNIQUE KEY uk_smtp_platform (platform_slot)'
+ )
+);
+PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;
+
+CREATE TABLE IF NOT EXISTS `email_log` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `tenant_id` int DEFAULT NULL,
+ `to_email` varchar(254) NOT NULL,
+ `subject` varchar(300) NOT NULL,
+ `template` varchar(80) NOT NULL DEFAULT 'generic',
+ `transport` enum('tenant','platform','none') NOT NULL DEFAULT 'none',
+ `status` enum('sent','failed','skipped') NOT NULL,
+ `error` varchar(500) DEFAULT NULL,
+ `type` varchar(64) DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT current_timestamp(),
+ PRIMARY KEY (`id`),
+ KEY `idx_email_log_at` (`created_at`),
+ KEY `idx_email_log_tenant` (`tenant_id`, `created_at`),
+ KEY `idx_email_log_status` (`status`, `created_at`),
+ CONSTRAINT `fk_email_log_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `audit_log` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `tenant_id` int DEFAULT NULL,
+ `user_id` int DEFAULT NULL,
+ `impersonator_id` int DEFAULT NULL,
+ `entity` varchar(64) NOT NULL,
+ `entity_id` int DEFAULT NULL,
+ `action` varchar(64) NOT NULL,
+ `details` json DEFAULT NULL,
+ `created_at` datetime NOT NULL DEFAULT current_timestamp(),
+ PRIMARY KEY (`id`),
+ KEY `idx_audit_tenant_at` (`tenant_id`, `created_at`),
+ KEY `idx_audit_action` (`action`, `created_at`),
+ KEY `idx_audit_impersonator` (`impersonator_id`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `notification_global` (
+ `type` varchar(64) NOT NULL,
+ `paused` tinyint(1) NOT NULL DEFAULT 0,
+ `reason` varchar(500) DEFAULT NULL,
+ `updated_by` int DEFAULT NULL,
+ `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
+ PRIMARY KEY (`type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `notification_mutes` (
+ `user_id` int NOT NULL,
+ `mute_key` varchar(64) NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT current_timestamp(),
+ PRIMARY KEY (`user_id`, `mute_key`),
+ CONSTRAINT `fk_notif_mute_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `notification_settings` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `tenant_id` int DEFAULT NULL COMMENT 'NULL = platform zone',
+ `type` varchar(64) NOT NULL,
+ `enabled` tinyint(1) NOT NULL DEFAULT 1,
+ `frequency` varchar(16) DEFAULT NULL,
+ `hour` tinyint DEFAULT NULL,
+ `weekday` tinyint DEFAULT NULL,
+ `roles` json DEFAULT NULL,
+ `only_new` tinyint(1) NOT NULL DEFAULT 1,
+ `params` json DEFAULT NULL,
+ `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_notif_settings` (`tenant_id`, `type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `i18n_overlays` (
+ `locale` varchar(10) NOT NULL,
+ `msg_key` varchar(191) NOT NULL,
+ `msg_value` text NOT NULL,
+ `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
+ PRIMARY KEY (`locale`, `msg_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- TOTP (optional MFA) on users
+SET @q := (
+ SELECT IF(
+ EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_enabled'),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN totp_enabled TINYINT(1) NOT NULL DEFAULT 0'
+ )
+);
+PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @q := (
+ SELECT IF(
+ EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_secret_enc'),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN totp_secret_enc VARCHAR(255) NULL DEFAULT NULL'
+ )
+);
+PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @q := (
+ SELECT IF(
+ EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='users' AND COLUMN_NAME='totp_last_step'),
+ 'SELECT 1',
+ 'ALTER TABLE users ADD COLUMN totp_last_step BIGINT NULL DEFAULT NULL'
+ )
+);
+PREPARE s FROM @q; EXECUTE s; DEALLOCATE PREPARE s;
diff --git a/docs/09-harvest-ops.md b/docs/09-harvest-ops.md
new file mode 100644
index 0000000..8797246
--- /dev/null
+++ b/docs/09-harvest-ops.md
@@ -0,0 +1,66 @@
+# 09 — Harvest: mail, notify kernel, ops, i18n, MFA
+
+This layer was proven in `myCMMS-PHP` and harvested into the pack so every SaaS product gets the same factory pieces without CMMS domain coupling.
+
+## What ships
+
+| Piece | Namespace / path | Role |
+|---|---|---|
+| Mail gateway | `BitsHost\UpmvcSaas\Mail\MailGateway` | Platform SMTP + plan-gated tenant SMTP + `.env` fallback + `email_log` |
+| Secret seal | `BitsHost\UpmvcSaas\Security\SecretBox` | AES-GCM for SMTP passwords (dual-unseal; see below) |
+| Notify registry | `BitsHost\UpmvcSaas\Notify\NotifyRegistry` | Apps **register** types; pack stays empty |
+| Notify gate | `BitsHost\UpmvcSaas\Notify\NotifyGate` | Global pause / kill switch |
+| Notify fire | `BitsHost\UpmvcSaas\Notify\Notify` | Instant send with mute + MailGateway |
+| Audit | `BitsHost\UpmvcSaas\Audit\AuditLog` | Append-only trail + impersonator |
+| TOTP | `BitsHost\UpmvcSaas\Security\Totp` | Platform MFA helper (dual-unseal; see below) |
+| Attention | `BitsHost\UpmvcSaas\Ops\Attention` | SMTP / email fails / scheduler / flags |
+| Settings | `BitsHost\UpmvcSaas\Ops\PlatformSettings` | Key/value platform flags |
+| Heartbeat | `BitsHost\UpmvcSaas\Ops\SchedulerHeartbeat` | Cron liveness |
+| i18n catalog | `BitsHost\UpmvcSaas\I18n\Catalog` | File + DB overlays |
+| Migration | `002_saas_ops_layer.sql` | Tables for the above |
+| API | `Api/Modules/PlatformOps`, `Api/Modules/I18n` | Thin routes apps can override |
+| Shell i18n seed | `public/lang/{en,ro}.json` | Minimal chrome keys |
+
+## App wiring
+
+```php
+use BitsHost\UpmvcSaas\Notify\NotifyRegistry;
+use BitsHost\UpmvcSaas\Notify\Notify;
+
+// boot / service provider
+NotifyRegistry::registerMany([
+ ['key' => 'invoice_paid', 'zone' => 'tenant', 'kind' => 'instant', 'enabled' => true],
+]);
+
+// domain code
+Notify::fire($tenantId, 'invoice_paid', [['id' => 1, 'email' => $to]], function (array $u) {
+ return ['subject' => 'Paid', 'html' => '
Thanks
'];
+});
+```
+
+Override `App\Modules\Api\Modules\PlatformOps` in the consuming app to add domain Attention items (plan limits, trials, CMMS metrics, …).
+
+Harvest kernel classes are **not** `final` — products may thin-extend (e.g. `App\Cmms\MailGateway extends BitsHost\…`) to keep call-site namespaces stable.
+
+## Seal vs unseal (dual-unseal continuity)
+
+When secrets moved from myCMMS into the pack, only the **pepper string** changed. Existing DB rows still use CMMS peppers.
+
+| | Write (`seal`) | Read (`unseal`) |
+|---|---|---|
+| **SecretBox** | `{APP_KEY\|JWT}\|saas-secret-v1` only | also `|smtp-password-v1` (+ historical insecure-dev candidates) |
+| **Totp** | `upmvc-saas-totp\|{JWT_SECRET}` only | also `mycmms-totp\|{JWT_SECRET}` |
+
+Dual-unseal is a **read bridge**, not dual-write. After a successful unseal + re-save, the next `seal()` stores the pack pepper only. Keep legacy peppers until every known consumer has re-saved; drop them only in a deliberate major with a migration plan.
+
+## Deliberately not in this harvest
+
+- Domain notify types (WO/PM/stock, …)
+- Payments / billing (optional future pack)
+- Full PlatformAdmin UI tabs (apps override shells)
+- AI translate (after overlays are used in production)
+
+## Rule of thumb
+
+If car-rental and ContaPlus both need it before domain logic → saas pack.
+If it pulls a heavy vendor SDK or is vertical-only → new pack or app module.
diff --git a/public/lang/en.json b/public/lang/en.json
new file mode 100644
index 0000000..606bb75
--- /dev/null
+++ b/public/lang/en.json
@@ -0,0 +1,22 @@
+{
+ "common": {
+ "loading": "Loading…",
+ "save": "Save",
+ "cancel": "Cancel",
+ "name": "Name",
+ "email": "Email",
+ "username": "Username",
+ "status": "Status",
+ "language": "Language",
+ "saved": "Saved",
+ "failed_load": "Failed to load data.",
+ "sign_out": "Sign out",
+ "done": "Done"
+ },
+ "platform": {
+ "ops_tab": "Ops",
+ "attention_title": "Needs attention",
+ "attention_clear": "Nothing needs attention.",
+ "languages_tab": "Languages"
+ }
+}
diff --git a/public/lang/ro.json b/public/lang/ro.json
new file mode 100644
index 0000000..7c25f22
--- /dev/null
+++ b/public/lang/ro.json
@@ -0,0 +1,22 @@
+{
+ "common": {
+ "loading": "Se încarcă…",
+ "save": "Salvează",
+ "cancel": "Anulează",
+ "name": "Nume",
+ "email": "Email",
+ "username": "Utilizator",
+ "status": "Status",
+ "language": "Limbă",
+ "saved": "Salvat",
+ "failed_load": "Nu s-au putut încărca datele.",
+ "sign_out": "Deconectare",
+ "done": "Gata"
+ },
+ "platform": {
+ "ops_tab": "Ops",
+ "attention_title": "Necesită atenție",
+ "attention_clear": "Nimic de rezolvat.",
+ "languages_tab": "Limbi"
+ }
+}
diff --git a/src/Modules/Api/Modules/I18n/Controller.php b/src/Modules/Api/Modules/I18n/Controller.php
new file mode 100644
index 0000000..f275c17
--- /dev/null
+++ b/src/Modules/Api/Modules/I18n/Controller.php
@@ -0,0 +1,71 @@
+ */
+ private const LOCALES = ['en', 'ro'];
+
+ /** GET /api/i18n */
+ public function index(): never
+ {
+ $this->success([
+ 'locales' => self::LOCALES,
+ 'default' => PlatformSettings::get('default_locale', 'en') ?: 'en',
+ 'source' => 'file+overlays',
+ ]);
+ }
+
+ /** GET /api/i18n/{locale} */
+ public function show(): never
+ {
+ $locale = strtolower(trim((string) ($_GET['locale'] ?? '')));
+ try {
+ $loaded = null;
+ $last = null;
+ foreach ($this->langDirs() as $dir) {
+ try {
+ $loaded = Catalog::load($locale, $dir, self::LOCALES);
+ break;
+ } catch (\Throwable $e) {
+ $last = $e;
+ }
+ }
+ if ($loaded === null) {
+ throw $last ?? new \RuntimeException('Language file not found');
+ }
+ } catch (\InvalidArgumentException $e) {
+ $this->error($e->getMessage(), 404);
+ } catch (\Throwable $e) {
+ $this->error($e->getMessage(), 404);
+ }
+
+ $this->success(array_merge($loaded, [
+ 'locales' => self::LOCALES,
+ 'default' => PlatformSettings::get('default_locale', 'en') ?: 'en',
+ ]));
+ }
+
+ /** @return list */
+ private function langDirs(): array
+ {
+ $dirs = [];
+ if (defined('SITEPATH')) {
+ $dirs[] = rtrim((string) SITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'lang';
+ }
+ // Pack root: …/src/Modules/Api/Modules/I18n → 5 levels up
+ $packRoot = dirname(__DIR__, 5);
+ $dirs[] = $packRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang';
+ // When pack lives under vendor/, prefer the consuming app's public/lang
+ $vendorParent = dirname($packRoot, 3); // …/vendor/bitshost/upmvc-saas-pack → project
+ if (is_dir($vendorParent . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang')) {
+ array_unshift($dirs, $vendorParent . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'lang');
+ }
+ return array_values(array_unique(array_filter($dirs, 'is_dir')));
+ }
+}
diff --git a/src/Modules/Api/Modules/I18n/Routes/Routes.php b/src/Modules/Api/Modules/I18n/Routes/Routes.php
new file mode 100644
index 0000000..38494d0
--- /dev/null
+++ b/src/Modules/Api/Modules/I18n/Routes/Routes.php
@@ -0,0 +1,15 @@
+addRoute('/api/i18n', Controller::class, 'index', $public, ['GET']);
+ $router->addParamRoute('/api/i18n/{locale}', Controller::class, 'show', $public, [], ['GET']);
+ }
+}
diff --git a/src/Modules/Api/Modules/PlatformOps/Controller.php b/src/Modules/Api/Modules/PlatformOps/Controller.php
new file mode 100644
index 0000000..e5062ba
--- /dev/null
+++ b/src/Modules/Api/Modules/PlatformOps/Controller.php
@@ -0,0 +1,123 @@
+user['role'] ?? '') !== 'platform_admin') {
+ $this->error('Platform admin access required', 403);
+ }
+ }
+
+ /** GET /api/admin/ops/attention */
+ public function attention(): never
+ {
+ $this->requirePlatformAdmin();
+ $this->success(['attention' => Attention::collect()]);
+ }
+
+ /** GET /api/admin/ops/flags */
+ public function getFlags(): never
+ {
+ $this->requirePlatformAdmin();
+ $this->success(['flags' => PlatformSettings::flags()]);
+ }
+
+ /** POST /api/admin/ops/flags/save */
+ public function saveFlags(): never
+ {
+ $this->requirePlatformAdmin();
+ $body = json_decode(file_get_contents('php://input'), true) ?? [];
+ $this->success(['flags' => PlatformSettings::saveFlags($body)], 'Flags saved');
+ }
+
+ /** POST /api/admin/ops/scheduler/beat */
+ public function schedulerBeat(): never
+ {
+ $this->requirePlatformAdmin();
+ $this->success(['heartbeat' => SchedulerHeartbeat::beat(), 'health' => SchedulerHeartbeat::health()]);
+ }
+
+ /** GET /api/admin/ops/mfa */
+ public function mfaStatus(): never
+ {
+ $this->requirePlatformAdmin();
+ $uid = (int) ($this->user['sub'] ?? $this->user['id'] ?? 0);
+ $this->success((new Model())->mfaStatus($uid));
+ }
+
+ /** POST /api/admin/ops/mfa/setup */
+ public function mfaSetup(): never
+ {
+ $this->requirePlatformAdmin();
+ $uid = (int) ($this->user['sub'] ?? $this->user['id'] ?? 0);
+ try {
+ $this->success((new Model())->mfaSetup($uid));
+ } catch (\InvalidArgumentException $e) {
+ $this->error($e->getMessage(), 400);
+ }
+ }
+
+ /** POST /api/admin/ops/mfa/enable */
+ public function mfaEnable(): never
+ {
+ $this->requirePlatformAdmin();
+ $uid = (int) ($this->user['sub'] ?? $this->user['id'] ?? 0);
+ $body = $this->requireFields(['code']);
+ try {
+ (new Model())->mfaEnable($uid, (string) $body['code']);
+ } catch (\InvalidArgumentException $e) {
+ $this->error($e->getMessage(), 400);
+ }
+ $this->success(['enabled' => true], 'MFA enabled');
+ }
+
+ /** POST /api/admin/ops/mfa/disable */
+ public function mfaDisable(): never
+ {
+ $this->requirePlatformAdmin();
+ $uid = (int) ($this->user['sub'] ?? $this->user['id'] ?? 0);
+ $body = $this->requireFields(['password', 'code']);
+ try {
+ (new Model())->mfaDisable($uid, (string) $body['password'], (string) $body['code']);
+ } catch (\InvalidArgumentException $e) {
+ $this->error($e->getMessage(), 400);
+ }
+ $this->success(['enabled' => false], 'MFA disabled');
+ }
+
+ /** GET /api/admin/ops/smtp — platform SMTP public view */
+ public function getSmtp(): never
+ {
+ $this->requirePlatformAdmin();
+ $gw = new MailGateway();
+ $this->success([
+ 'smtp' => $gw->getPublicPlatform(),
+ 'route' => $gw->describeRoute(null),
+ ]);
+ }
+
+ /** POST /api/admin/ops/smtp/save */
+ public function saveSmtp(): never
+ {
+ $this->requirePlatformAdmin();
+ $body = json_decode(file_get_contents('php://input'), true) ?? [];
+ try {
+ $smtp = (new MailGateway())->savePlatform($body);
+ } catch (\InvalidArgumentException $e) {
+ $this->error($e->getMessage(), 400);
+ }
+ $this->success(['smtp' => $smtp], 'SMTP saved');
+ }
+}
diff --git a/src/Modules/Api/Modules/PlatformOps/Model.php b/src/Modules/Api/Modules/PlatformOps/Model.php
new file mode 100644
index 0000000..78632de
--- /dev/null
+++ b/src/Modules/Api/Modules/PlatformOps/Model.php
@@ -0,0 +1,105 @@
+findAdminMfa($userId);
+ return [
+ 'enabled' => !empty($row['totp_enabled']),
+ 'pending_setup' => empty($row['totp_enabled']) && !empty($row['totp_secret_enc']),
+ ];
+ }
+
+ public function mfaSetup(int $userId): array
+ {
+ $row = $this->findAdminMfa($userId);
+ if (!$row) {
+ throw new \InvalidArgumentException('User not found');
+ }
+ if (!empty($row['totp_enabled'])) {
+ throw new \InvalidArgumentException('Sign-in codes are already on. Turn them off first to move to a new device.');
+ }
+ $secret = Totp::newSecret();
+ $this->conn->prepare(
+ "UPDATE users SET totp_secret_enc = :s, totp_last_step = NULL
+ WHERE id = :id AND role = 'platform_admin'"
+ )->execute([':s' => Totp::seal($secret), ':id' => $userId]);
+ $account = (string) ($row['email'] ?: $row['username']);
+ return [
+ 'secret' => $secret,
+ 'uri' => Totp::otpauthUri($secret, $account, 'upMVC SaaS'),
+ ];
+ }
+
+ public function mfaEnable(int $userId, string $code): void
+ {
+ $row = $this->findAdminMfa($userId);
+ if (!$row) {
+ throw new \InvalidArgumentException('User not found');
+ }
+ if (!empty($row['totp_enabled'])) {
+ return;
+ }
+ if (empty($row['totp_secret_enc'])) {
+ throw new \InvalidArgumentException('Start the setup again');
+ }
+ $secret = Totp::unseal((string) $row['totp_secret_enc']);
+ if ($secret === null) {
+ throw new \InvalidArgumentException('Start the setup again');
+ }
+ $last = $row['totp_last_step'] !== null ? (int) $row['totp_last_step'] : null;
+ $step = Totp::verify($secret, $code, $last);
+ if ($step === null) {
+ throw new \InvalidArgumentException('That code is not valid');
+ }
+ $this->conn->prepare(
+ 'UPDATE users SET totp_enabled = 1, totp_last_step = :step WHERE id = :id'
+ )->execute([':step' => $step, ':id' => $userId]);
+ AuditLog::write(null, $userId, 'user', $userId, 'mfa_enabled', []);
+ }
+
+ public function mfaDisable(int $userId, string $password, string $code = ''): void
+ {
+ $row = $this->findAdminMfa($userId);
+ if (!$row) {
+ throw new \InvalidArgumentException('User not found');
+ }
+ if (empty($row['totp_enabled'])) {
+ return;
+ }
+ if (!password_verify($password, (string) $row['password'])) {
+ throw new \InvalidArgumentException('Current password is incorrect');
+ }
+ $secret = Totp::unseal((string) ($row['totp_secret_enc'] ?? ''));
+ $last = $row['totp_last_step'] !== null ? (int) $row['totp_last_step'] : null;
+ if ($secret === null || Totp::verify($secret, $code, $last) === null) {
+ throw new \InvalidArgumentException('That code is not valid');
+ }
+ $this->conn->prepare(
+ 'UPDATE users SET totp_enabled = 0, totp_secret_enc = NULL, totp_last_step = NULL WHERE id = :id'
+ )->execute([':id' => $userId]);
+ AuditLog::write(null, $userId, 'user', $userId, 'mfa_disabled', []);
+ }
+
+ /** @return array|false */
+ public function findAdminMfa(int $userId): array|false
+ {
+ try {
+ $stmt = $this->conn->prepare(
+ "SELECT id, username, email, password, totp_enabled, totp_secret_enc, totp_last_step
+ FROM users WHERE id = :id AND role = 'platform_admin' LIMIT 1"
+ );
+ $stmt->execute([':id' => $userId]);
+ return $stmt->fetch(\PDO::FETCH_ASSOC) ?: false;
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+}
diff --git a/src/Modules/Api/Modules/PlatformOps/Routes/Routes.php b/src/Modules/Api/Modules/PlatformOps/Routes/Routes.php
new file mode 100644
index 0000000..4decf3d
--- /dev/null
+++ b/src/Modules/Api/Modules/PlatformOps/Routes/Routes.php
@@ -0,0 +1,23 @@
+addRoute('/api/admin/ops/attention', Controller::class, 'attention', $auth, ['GET']);
+ $router->addRoute('/api/admin/ops/flags', Controller::class, 'getFlags', $auth, ['GET']);
+ $router->addRoute('/api/admin/ops/flags/save', Controller::class, 'saveFlags', $auth, ['POST']);
+ $router->addRoute('/api/admin/ops/scheduler/beat', Controller::class, 'schedulerBeat', $auth, ['POST']);
+ $router->addRoute('/api/admin/ops/mfa', Controller::class, 'mfaStatus', $auth, ['GET']);
+ $router->addRoute('/api/admin/ops/mfa/setup', Controller::class, 'mfaSetup', $auth, ['POST']);
+ $router->addRoute('/api/admin/ops/mfa/enable', Controller::class, 'mfaEnable', $auth, ['POST']);
+ $router->addRoute('/api/admin/ops/mfa/disable', Controller::class, 'mfaDisable', $auth, ['POST']);
+ $router->addRoute('/api/admin/ops/smtp', Controller::class, 'getSmtp', $auth, ['GET']);
+ $router->addRoute('/api/admin/ops/smtp/save', Controller::class, 'saveSmtp', $auth, ['POST']);
+ }
+}
diff --git a/src/Modules/Mail/MailController.php b/src/Modules/Mail/MailController.php
index 60c50da..3bcc283 100644
--- a/src/Modules/Mail/MailController.php
+++ b/src/Modules/Mail/MailController.php
@@ -2,58 +2,48 @@
namespace App\Modules\Mail;
-use PHPMailer\PHPMailer\PHPMailer;
-use PHPMailer\PHPMailer\Exception;
+use BitsHost\UpmvcSaas\Mail\MailGateway;
+/**
+ * Thin mail helpers — prefer MailGateway for product sends (logged + routed).
+ * Kept for backward-compatible module discovery.
+ */
class MailController
{
- private function createMailer(): PHPMailer
- {
- $mail = new PHPMailer(true);
- $mail->CharSet = 'UTF-8';
- $mail->isSMTP();
- $mail->Host = $_ENV['MAIL_HOST'] ?? '';
- $mail->SMTPAuth = true;
- $mail->Username = $_ENV['MAIL_USERNAME'] ?? '';
- $mail->Password = $_ENV['MAIL_PASSWORD'] ?? '';
- $mail->SMTPSecure = $_ENV['MAIL_ENCRYPTION'] ?? 'ssl';
- $mail->Port = (int) ($_ENV['MAIL_PORT'] ?? 465);
- $mail->isHTML(true);
- return $mail;
+ /**
+ * @return array{status:string,transport?:string,error?:string,engine?:string}
+ */
+ public function send(
+ string $to,
+ string $subject,
+ string $html,
+ string $template = 'generic',
+ ?int $tenantId = null,
+ ?string $type = null
+ ): array {
+ return (new MailGateway())->send($to, $subject, $html, $template, $tenantId, $type);
}
+ /** @deprecated Use send() / MailGateway — kept for older call sites */
public function sendMailByPHPMailer($to, $from, $subject, $message): bool
{
- $mail = $this->createMailer();
- $mail->setFrom($from);
- $mail->addAddress($to);
- $mail->addReplyTo($from);
- $mail->Subject = $subject;
- $mail->Body = $message;
-
- if (!$mail->send()) {
- echo json_encode(['error' => 'Mailer Error: ' . $mail->ErrorInfo]);
- exit;
- }
-
- return true;
+ $result = (new MailGateway())->send(
+ (string) $to,
+ (string) $subject,
+ (string) $message,
+ 'legacy',
+ null,
+ null,
+ null,
+ true
+ );
+ return ($result['status'] ?? '') === 'sent';
}
+ /** @deprecated Use MailGateway */
public function sendMailByPHPMailerAddAttachment($to, $from, $subject, $message, $attachmentroute): bool
{
- $mail = $this->createMailer();
- $mail->setFrom($from);
- $mail->addAddress($to);
- $mail->addReplyTo($from);
- $mail->Subject = $subject;
- $mail->Body = $message;
- $mail->addAttachment($attachmentroute);
-
- if (!$mail->send()) {
- echo json_encode(['error' => 'Mailer Error: ' . $mail->ErrorInfo]);
- exit;
- }
-
- return true;
+ // Attachments: use PHPMailer directly in the app for now; gateway is HTML body focused.
+ return $this->sendMailByPHPMailer($to, $from, $subject, $message);
}
}
diff --git a/src/Saas/Audit/AuditLog.php b/src/Saas/Audit/AuditLog.php
new file mode 100644
index 0000000..93efe46
--- /dev/null
+++ b/src/Saas/Audit/AuditLog.php
@@ -0,0 +1,78 @@
+ $user JWT / session user claims
+ * @return array{userId:?int,impersonatorId:?int}
+ */
+ public static function actorsFromUser(array $user): array
+ {
+ $uid = (int) ($user['sub'] ?? $user['id'] ?? 0);
+ $imp = 0;
+ if (!empty($user['impersonated']) || !empty($user['impersonator_id'])) {
+ $imp = (int) ($user['impersonator_id'] ?? 0);
+ }
+ return [
+ 'userId' => $uid > 0 ? $uid : null,
+ 'impersonatorId' => $imp > 0 ? $imp : null,
+ ];
+ }
+
+ /**
+ * @param array $details
+ */
+ public static function write(
+ ?int $tenantId,
+ ?int $userId,
+ string $entity,
+ ?int $entityId,
+ string $action,
+ array $details = [],
+ ?int $impersonatorId = null
+ ): void {
+ if ($entity === '' || $action === '') {
+ return;
+ }
+ $tid = ($tenantId !== null && $tenantId > 0) ? $tenantId : null;
+ try {
+ $pdo = (new Database())->getConnection();
+ $stmt = $pdo->prepare(
+ 'INSERT INTO audit_log (tenant_id, user_id, impersonator_id, entity, entity_id, action, details)
+ VALUES (:tid, :uid, :imp, :entity, :entity_id, :action, :details)'
+ );
+ $stmt->execute([
+ ':tid' => $tid,
+ ':uid' => $userId ?: null,
+ ':imp' => $impersonatorId ?: null,
+ ':entity' => mb_substr($entity, 0, 64),
+ ':entity_id' => $entityId,
+ ':action' => mb_substr($action, 0, 64),
+ ':details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ ]);
+ } catch (\Throwable) {
+ // Never break the primary action for audit failures
+ }
+ }
+
+ /** Convenience: stamp impersonator from JWT claims when present. */
+ public static function writeAs(
+ array $user,
+ ?int $tenantId,
+ string $entity,
+ ?int $entityId,
+ string $action,
+ array $details = []
+ ): void {
+ $a = self::actorsFromUser($user);
+ self::write($tenantId, $a['userId'], $entity, $entityId, $action, $details, $a['impersonatorId']);
+ }
+}
diff --git a/src/Saas/I18n/Catalog.php b/src/Saas/I18n/Catalog.php
new file mode 100644
index 0000000..3b38544
--- /dev/null
+++ b/src/Saas/I18n/Catalog.php
@@ -0,0 +1,88 @@
+ $catalog
+ * @param array $overlays
+ * @return array
+ */
+ public static function applyOverlays(array $catalog, array $overlays): array
+ {
+ foreach ($overlays as $path => $value) {
+ $parts = explode('.', (string) $path);
+ if ($parts === [] || $parts[0] === '') {
+ continue;
+ }
+ $ref = &$catalog;
+ $last = count($parts) - 1;
+ foreach ($parts as $i => $p) {
+ if ($i === $last) {
+ $ref[$p] = $value;
+ } else {
+ if (!isset($ref[$p]) || !is_array($ref[$p])) {
+ $ref[$p] = [];
+ }
+ $ref = &$ref[$p];
+ }
+ }
+ unset($ref);
+ }
+ return $catalog;
+ }
+
+ /** @return array */
+ public static function overlayMap(string $locale): array
+ {
+ try {
+ $pdo = (new Database())->getConnection();
+ $stmt = $pdo->prepare(
+ 'SELECT msg_key, msg_value FROM i18n_overlays WHERE locale = :l'
+ );
+ $stmt->execute([':l' => $locale]);
+ $map = [];
+ foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
+ $map[(string) $row['msg_key']] = (string) $row['msg_value'];
+ }
+ return $map;
+ } catch (\Throwable) {
+ return [];
+ }
+ }
+
+ /**
+ * @param list $locales
+ * @return array{locale:string,source:string,overlays:int,catalog:array,version:string}
+ */
+ public static function load(string $locale, string $langDir, array $locales = ['en', 'ro'], bool $mergeOverlays = true): array
+ {
+ $locale = strtolower(trim($locale));
+ if (!in_array($locale, $locales, true)) {
+ throw new \InvalidArgumentException('Unsupported locale');
+ }
+ $path = rtrim($langDir, '/\\') . DIRECTORY_SEPARATOR . $locale . '.json';
+ if (!is_readable($path)) {
+ throw new \RuntimeException('Language file not found');
+ }
+ $catalog = json_decode((string) file_get_contents($path), true);
+ if (!is_array($catalog)) {
+ throw new \RuntimeException('Invalid language catalog');
+ }
+ $overlays = $mergeOverlays ? self::overlayMap($locale) : [];
+ if ($overlays !== []) {
+ $catalog = self::applyOverlays($catalog, $overlays);
+ }
+ return [
+ 'locale' => $locale,
+ 'source' => $overlays === [] ? 'file' : 'file+overlays',
+ 'overlays' => count($overlays),
+ 'catalog' => $catalog,
+ 'version' => (string) filemtime($path) . ':' . count($overlays),
+ ];
+ }
+}
diff --git a/src/Saas/Mail/MailGateway.php b/src/Saas/Mail/MailGateway.php
new file mode 100644
index 0000000..b56fcbc
--- /dev/null
+++ b/src/Saas/Mail/MailGateway.php
@@ -0,0 +1,649 @@
+resolveConfig($tenantId) !== null;
+ }
+
+ public function isNetworkConfigured(): bool
+ {
+ return $this->resolveConfig(null, false) !== null;
+ }
+
+ /**
+ * Active delivery route for UI / diagnostics.
+ *
+ * @return array{engine:string,mode:string,allowed_custom:bool,network:bool,from_email:?string,from_name:?string}
+ */
+ public function describeRoute(?int $tenantId = null): array
+ {
+ $allowed = $tenantId ? $this->tenantHasCustomSmtp($tenantId) : false;
+ $cfg = $this->resolveConfig($tenantId);
+ $network = $this->isNetworkConfigured();
+ $mode = 'none';
+ if ($cfg) {
+ $mode = ($cfg['transport'] ?? '') === 'tenant' ? 'tenant' : 'platform';
+ if (($cfg['transport'] ?? '') === 'platform' && !empty($cfg['from_env'])) {
+ $mode = 'env';
+ }
+ }
+ return [
+ 'engine' => 'phpmailer',
+ 'mode' => $mode,
+ 'allowed_custom' => $allowed,
+ 'network' => $network,
+ 'from_email' => $cfg['from_email'] ?? null,
+ 'from_name' => $cfg['from_name'] ?? null,
+ ];
+ }
+
+ /**
+ * @return array{status:string,transport?:string,error?:string,engine?:string}
+ */
+ public function send(
+ string $to,
+ string $subject,
+ string $html,
+ string $template = 'generic',
+ ?int $tenantId = null,
+ ?string $type = null,
+ ?string $text = null,
+ bool $forcePlatform = false
+ ): array {
+ $to = trim($to);
+ if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
+ return $this->finish($tenantId, $to, $subject, $template, $type, 'none', 'failed', 'Invalid recipient');
+ }
+ if (self::isTestAddress($to)) {
+ return $this->finish($tenantId, $to, $subject, $template, $type, 'none', 'skipped', 'Reserved test address');
+ }
+
+ $cfg = $this->resolveConfig($forcePlatform ? null : $tenantId, !$forcePlatform);
+ if ($cfg === null) {
+ return $this->finish($tenantId, $to, $subject, $template, $type, 'none', 'skipped', 'No SMTP server configured');
+ }
+
+ try {
+ $mail = $this->buildMailer($cfg);
+ $mail->setFrom($cfg['from_email'], $cfg['from_name'] ?: 'App');
+ $mail->addAddress($to);
+ if (!empty($cfg['reply_to'])) {
+ $mail->addReplyTo($cfg['reply_to']);
+ }
+ $mail->Subject = $subject;
+ $mail->Body = $html;
+ $mail->AltBody = $text ?? strip_tags($html);
+ $mail->send();
+ $out = $this->finish($tenantId, $to, $subject, $template, $type, $cfg['transport'], 'sent', null);
+ $out['engine'] = 'phpmailer';
+ return $out;
+ } catch (\Throwable $e) {
+ $out = $this->finish(
+ $tenantId,
+ $to,
+ $subject,
+ $template,
+ $type,
+ $cfg['transport'] ?? 'none',
+ 'failed',
+ mb_substr($e->getMessage(), 0, 500)
+ );
+ $out['engine'] = 'phpmailer';
+ return $out;
+ }
+ }
+
+ /**
+ * Test SMTP: uses form override when host present, else saved row (platform or tenant).
+ * @param array|null $override
+ * @return array{ok:bool,to:string,error?:string}
+ */
+ public function test(?array $override, string $to, bool $updateSavedMeta = true, ?int $tenantId = null): array
+ {
+ $to = trim($to);
+ if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
+ return ['ok' => false, 'to' => $to, 'error' => 'Invalid test recipient'];
+ }
+ if ($tenantId && !$this->tenantHasCustomSmtp($tenantId)) {
+ return ['ok' => false, 'to' => $to, 'error' => 'Custom SMTP is not included in your plan'];
+ }
+
+ $saved = $tenantId ? $this->tenantRow($tenantId) : $this->platformRow();
+ $transport = $tenantId ? 'tenant' : 'platform';
+ try {
+ $cfg = $override && !empty($override['host'])
+ ? $this->normalizeInput($override, $saved)
+ : $this->rowToConfig($saved);
+ if ($cfg === null) {
+ return ['ok' => false, 'to' => $to, 'error' => 'Enter SMTP settings first'];
+ }
+ $cfg['transport'] = $transport;
+
+ $mail = $this->buildMailer($cfg);
+ $mail->smtpConnect();
+ $mail->smtpClose();
+
+ if (!self::isTestAddress($to)) {
+ $mail = $this->buildMailer($cfg);
+ $mail->setFrom($cfg['from_email'], $cfg['from_name'] ?: 'App');
+ $mail->addAddress($to);
+ $mail->Subject = 'SMTP test';
+ $scope = $tenantId ? 'workspace' : 'platform';
+ $mail->Body = 'SMTP test from App ' . $scope . ' gateway.
Server: '
+ . htmlspecialchars($cfg['host'] . ':' . $cfg['port']) . '
';
+ $mail->AltBody = 'SMTP test from App ' . $scope . ' gateway.';
+ $mail->send();
+ }
+
+ if ($updateSavedMeta && $saved && empty($override['host'])) {
+ $this->markTest((int) $saved['id'], true, null);
+ }
+ $this->writeLog($tenantId, $to, 'SMTP test', 'smtp_test', null, $transport, 'sent', null);
+ return ['ok' => true, 'to' => $to];
+ } catch (\Throwable $e) {
+ $err = mb_substr($e->getMessage(), 0, 500);
+ if ($tenantId) {
+ // Don't leak raw SMTP banners to tenant admins
+ $err = 'Connection or authentication failed. Check host, port, and credentials.';
+ }
+ if ($updateSavedMeta && $saved && empty($override['host'] ?? null)) {
+ $this->markTest((int) $saved['id'], false, $err);
+ }
+ $this->writeLog($tenantId, $to, 'SMTP test', 'smtp_test', null, $transport, 'failed', $err);
+ return ['ok' => false, 'to' => $to, 'error' => $err];
+ }
+ }
+
+ /** @return array|null public view */
+ public function getPublicPlatform(): ?array
+ {
+ $row = $this->platformRow();
+ return $row ? $this->toPublic($row) : null;
+ }
+
+ /** @param array $body */
+ public function savePlatform(array $body): array
+ {
+ $cur = $this->platformRow();
+ $v = $this->normalizeInput($body, $cur);
+ if ($cur) {
+ $stmt = $this->conn->prepare(
+ "UPDATE smtp_settings SET
+ host=:host, port=:port, secure=:secure, username=:username, password_enc=:password_enc,
+ from_email=:from_email, from_name=:from_name, reply_to=:reply_to, enabled=:enabled,
+ last_test_at=NULL, last_test_ok=NULL, last_error=NULL
+ WHERE id=:id AND tenant_id IS NULL"
+ );
+ $stmt->execute([
+ ':id' => $cur['id'],
+ ':host' => $v['host'],
+ ':port' => $v['port'],
+ ':secure' => $v['secure'],
+ ':username' => $v['username'],
+ ':password_enc' => $v['password_enc'],
+ ':from_email' => $v['from_email'],
+ ':from_name' => $v['from_name'],
+ ':reply_to' => $v['reply_to'],
+ ':enabled' => $v['enabled'],
+ ]);
+ } else {
+ $stmt = $this->conn->prepare(
+ "INSERT INTO smtp_settings
+ (tenant_id, host, port, secure, username, password_enc, from_email, from_name, reply_to, enabled)
+ VALUES
+ (NULL, :host, :port, :secure, :username, :password_enc, :from_email, :from_name, :reply_to, :enabled)"
+ );
+ $stmt->execute([
+ ':host' => $v['host'],
+ ':port' => $v['port'],
+ ':secure' => $v['secure'],
+ ':username' => $v['username'],
+ ':password_enc' => $v['password_enc'],
+ ':from_email' => $v['from_email'],
+ ':from_name' => $v['from_name'],
+ ':reply_to' => $v['reply_to'],
+ ':enabled' => $v['enabled'],
+ ]);
+ }
+ return $this->getPublicPlatform() ?? [];
+ }
+
+ public function deletePlatform(): bool
+ {
+ return $this->conn->exec('DELETE FROM smtp_settings WHERE tenant_id IS NULL') !== false;
+ }
+
+ public function getPublicTenant(int $tenantId): ?array
+ {
+ $row = $this->tenantRow($tenantId);
+ return $row ? $this->toPublic($row) : null;
+ }
+
+ public function saveTenant(int $tenantId, array $body): array
+ {
+ if (!$this->tenantHasCustomSmtp($tenantId)) {
+ throw new \InvalidArgumentException('Custom SMTP is not included in your plan');
+ }
+ $cur = $this->tenantRow($tenantId);
+ $v = $this->normalizeInput($body, $cur);
+ if ($cur) {
+ $stmt = $this->conn->prepare(
+ "UPDATE smtp_settings SET
+ host=:host, port=:port, secure=:secure, username=:username, password_enc=:password_enc,
+ from_email=:from_email, from_name=:from_name, reply_to=:reply_to, enabled=:enabled,
+ last_test_at=NULL, last_test_ok=NULL, last_error=NULL
+ WHERE id=:id AND tenant_id=:tid"
+ );
+ $stmt->execute([
+ ':id' => $cur['id'],
+ ':tid' => $tenantId,
+ ':host' => $v['host'],
+ ':port' => $v['port'],
+ ':secure' => $v['secure'],
+ ':username' => $v['username'],
+ ':password_enc' => $v['password_enc'],
+ ':from_email' => $v['from_email'],
+ ':from_name' => $v['from_name'],
+ ':reply_to' => $v['reply_to'],
+ ':enabled' => $v['enabled'],
+ ]);
+ } else {
+ $stmt = $this->conn->prepare(
+ "INSERT INTO smtp_settings
+ (tenant_id, host, port, secure, username, password_enc, from_email, from_name, reply_to, enabled)
+ VALUES
+ (:tid, :host, :port, :secure, :username, :password_enc, :from_email, :from_name, :reply_to, :enabled)"
+ );
+ $stmt->execute([
+ ':tid' => $tenantId,
+ ':host' => $v['host'],
+ ':port' => $v['port'],
+ ':secure' => $v['secure'],
+ ':username' => $v['username'],
+ ':password_enc' => $v['password_enc'],
+ ':from_email' => $v['from_email'],
+ ':from_name' => $v['from_name'],
+ ':reply_to' => $v['reply_to'],
+ ':enabled' => $v['enabled'],
+ ]);
+ }
+ return $this->getPublicTenant($tenantId) ?? [];
+ }
+
+ public function deleteTenant(int $tenantId): bool
+ {
+ $stmt = $this->conn->prepare('DELETE FROM smtp_settings WHERE tenant_id = :tid');
+ return $stmt->execute([':tid' => $tenantId]);
+ }
+
+ /** Tenant email page payload — network + optional custom SMTP. */
+ public function tenantEmailBundle(int $tenantId): array
+ {
+ $platform = $this->platformRow();
+ $emailsStmt = $this->conn->prepare(
+ "SELECT id, to_email, subject, template, transport, status, error, created_at
+ FROM email_log WHERE tenant_id = :tid
+ ORDER BY created_at DESC LIMIT 25"
+ );
+ $emailsStmt->execute([':tid' => $tenantId]);
+ $route = $this->describeRoute($tenantId);
+ $tenantSmtp = $this->getPublicTenant($tenantId);
+ // Hide stored custom SMTP from UI when plan does not allow it
+ if (!$route['allowed_custom']) {
+ $tenantSmtp = null;
+ }
+ return [
+ 'engine' => 'phpmailer',
+ 'route' => $route,
+ 'allowed' => $route['allowed_custom'],
+ 'smtp' => $tenantSmtp,
+ 'platform' => ($platform && (int) $platform['enabled'] === 1)
+ ? [
+ 'from_email' => $platform['from_email'],
+ 'from_name' => $platform['from_name'],
+ 'enabled' => true,
+ ]
+ : ($this->isNetworkConfigured()
+ ? ['from_email' => (string) Environment::get('MAIL_FROM_ADDRESS', ''), 'from_name' => (string) Environment::get('MAIL_FROM_NAME', 'App'), 'enabled' => true, 'via_env' => true]
+ : null),
+ 'emails' => $emailsStmt->fetchAll(\PDO::FETCH_ASSOC),
+ ];
+ }
+
+ public function tenantHasCustomSmtp(int $tenantId): bool
+ {
+ $stmt = $this->conn->prepare(
+ "SELECT t.features AS tenant_features, p.features AS plan_features
+ FROM tenants t
+ LEFT JOIN plans p ON p.id = t.plan_id
+ WHERE t.id = :tid AND t.deleted_at IS NULL LIMIT 1"
+ );
+ $stmt->execute([':tid' => $tenantId]);
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ if (!$row) {
+ return false;
+ }
+ $tf = json_decode($row['tenant_features'] ?? '{}', true) ?: [];
+ $pf = json_decode($row['plan_features'] ?? '{}', true) ?: [];
+ if (array_key_exists('custom_smtp', $tf)) {
+ return !empty($tf['custom_smtp']);
+ }
+ return !empty($pf['custom_smtp']);
+ }
+
+ /** @return array{emails:list,summary:array} */
+ public function listLog(?string $status = 'all', int $limit = 100, ?int $tenantId = null): array
+ {
+ $limit = max(1, min(500, $limit));
+ $sql = "SELECT e.*, t.name AS tenant_name
+ FROM email_log e
+ LEFT JOIN tenants t ON t.id = e.tenant_id
+ WHERE 1=1";
+ $params = [];
+ if ($tenantId !== null) {
+ $sql .= ' AND e.tenant_id = :tid';
+ $params[':tid'] = $tenantId;
+ }
+ if ($status && $status !== 'all') {
+ $sql .= ' AND e.status = :st';
+ $params[':st'] = $status;
+ }
+ $sql .= " ORDER BY e.created_at DESC LIMIT {$limit}";
+ $stmt = $this->conn->prepare($sql);
+ $stmt->execute($params);
+ $emails = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+
+ if ($tenantId !== null) {
+ $sumStmt = $this->conn->prepare(
+ "SELECT
+ SUM(status='sent') AS sent,
+ SUM(status='failed') AS failed,
+ SUM(status='skipped') AS skipped,
+ SUM(transport='platform') AS via_platform,
+ SUM(transport='tenant') AS via_tenant
+ FROM email_log
+ WHERE tenant_id = :tid AND created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)"
+ );
+ $sumStmt->execute([':tid' => $tenantId]);
+ $sum = $sumStmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+ } else {
+ $sum = $this->conn->query(
+ "SELECT
+ SUM(status='sent') AS sent,
+ SUM(status='failed') AS failed,
+ SUM(status='skipped') AS skipped,
+ SUM(transport='platform') AS via_platform,
+ SUM(transport='tenant') AS via_tenant
+ FROM email_log
+ WHERE created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)"
+ )->fetch(\PDO::FETCH_ASSOC) ?: [];
+ }
+
+ return [
+ 'emails' => $emails,
+ 'summary' => [
+ 'sent' => (int) ($sum['sent'] ?? 0),
+ 'failed' => (int) ($sum['failed'] ?? 0),
+ 'skipped' => (int) ($sum['skipped'] ?? 0),
+ 'via_platform' => (int) ($sum['via_platform'] ?? 0),
+ 'via_tenant' => (int) ($sum['via_tenant'] ?? 0),
+ 'smtp_configured' => $this->isConfigured($tenantId),
+ ],
+ ];
+ }
+
+ /** @param array $body @param array|false|null $cur */
+ private function normalizeInput(array $body, $cur): array
+ {
+ $host = strtolower(trim((string) ($body['host'] ?? '')));
+ if ($host === '' || !preg_match('/^[a-z0-9.-]+$/', $host) || strlen($host) > 253) {
+ throw new \InvalidArgumentException('SMTP host is invalid');
+ }
+ $port = (int) ($body['port'] ?? 587);
+ if (!in_array($port, self::PORTS, true)) {
+ throw new \InvalidArgumentException('Port must be one of ' . implode(', ', self::PORTS));
+ }
+ $from = trim((string) ($body['from_email'] ?? $body['fromEmail'] ?? ''));
+ if ($from === '' || !filter_var($from, FILTER_VALIDATE_EMAIL)) {
+ throw new \InvalidArgumentException('From email is invalid');
+ }
+ $username = trim((string) ($body['username'] ?? ''));
+ $password = array_key_exists('password', $body) ? (string) $body['password'] : '';
+ $enc = $cur['password_enc'] ?? null;
+ if ($password !== '') {
+ if (strlen($password) > 500) {
+ throw new \InvalidArgumentException('Password is too long');
+ }
+ $enc = \BitsHost\UpmvcSaas\Security\SecretBox::seal($password);
+ } elseif ($username === '') {
+ $enc = null;
+ }
+ $reply = trim((string) ($body['reply_to'] ?? $body['replyTo'] ?? ''));
+ if ($reply !== '' && !filter_var($reply, FILTER_VALIDATE_EMAIL)) {
+ throw new \InvalidArgumentException('Reply-To is invalid');
+ }
+ $secure = array_key_exists('secure', $body)
+ ? (!empty($body['secure']) ? 1 : 0)
+ : ($port === 465 ? 1 : 0);
+
+ return [
+ 'host' => $host,
+ 'port' => $port,
+ 'secure' => $secure,
+ 'username' => $username !== '' ? $username : null,
+ 'password_enc' => $enc,
+ 'from_email' => $from,
+ 'from_name' => mb_substr(trim((string) ($body['from_name'] ?? $body['fromName'] ?? '')), 0, 120) ?: null,
+ 'reply_to' => $reply !== '' ? $reply : null,
+ 'enabled' => array_key_exists('enabled', $body) ? (!empty($body['enabled']) ? 1 : 0) : 1,
+ ];
+ }
+
+ private function platformRow(): array|false
+ {
+ return $this->conn->query(
+ 'SELECT * FROM smtp_settings WHERE tenant_id IS NULL LIMIT 1'
+ )->fetch(\PDO::FETCH_ASSOC);
+ }
+
+ private function tenantRow(int $tenantId): array|false
+ {
+ $stmt = $this->conn->prepare(
+ 'SELECT * FROM smtp_settings WHERE tenant_id = :tid LIMIT 1'
+ );
+ $stmt->execute([':tid' => $tenantId]);
+ return $stmt->fetch(\PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * @param bool $allowTenantSmtp when false, skip tenant custom SMTP (network-only)
+ * @return array|null
+ */
+ private function resolveConfig(?int $tenantId = null, bool $allowTenantSmtp = true): ?array
+ {
+ // Custom SMTP only when plan includes custom_smtp and settings are enabled
+ if ($allowTenantSmtp && $tenantId && $this->tenantHasCustomSmtp($tenantId)) {
+ $t = $this->tenantRow($tenantId);
+ if ($t && (int) $t['enabled'] === 1) {
+ $cfg = $this->rowToConfig($t);
+ if ($cfg) {
+ $cfg['transport'] = 'tenant';
+ return $cfg;
+ }
+ }
+ }
+
+ // Network / platform gateway (shared for all tenants)
+ $row = $this->platformRow();
+ if ($row && (int) $row['enabled'] === 1) {
+ $cfg = $this->rowToConfig($row);
+ if ($cfg) {
+ $cfg['transport'] = 'platform';
+ return $cfg;
+ }
+ }
+
+ // .env fallback (still "network" for tenants)
+ $host = (string) Environment::get('MAIL_HOST', '');
+ if ($host === '') {
+ return null;
+ }
+ return [
+ 'host' => $host,
+ 'port' => (int) Environment::get('MAIL_PORT', 587),
+ 'secure' => strtolower((string) Environment::get('MAIL_ENCRYPTION', 'tls')) === 'ssl',
+ 'username' => (string) Environment::get('MAIL_USERNAME', ''),
+ 'password' => (string) Environment::get('MAIL_PASSWORD', ''),
+ 'from_email' => (string) Environment::get('MAIL_FROM_ADDRESS', 'noreply@example.com'),
+ 'from_name' => (string) Environment::get('MAIL_FROM_NAME', 'App'),
+ 'reply_to' => null,
+ 'transport' => 'platform',
+ 'from_env' => true,
+ 'encryption' => (string) Environment::get('MAIL_ENCRYPTION', 'tls'),
+ ];
+ }
+
+ /** @param array|false|null $row */
+ private function rowToConfig($row): ?array
+ {
+ if (!$row || empty($row['host'])) {
+ return null;
+ }
+ return [
+ 'host' => $row['host'],
+ 'port' => (int) $row['port'],
+ 'secure' => (int) $row['secure'] === 1,
+ 'username' => (string) ($row['username'] ?? ''),
+ 'password' => (string) (\BitsHost\UpmvcSaas\Security\SecretBox::unseal($row['password_enc'] ?? null) ?? ''),
+ 'from_email' => $row['from_email'],
+ 'from_name' => $row['from_name'] ?? '',
+ 'reply_to' => $row['reply_to'] ?? null,
+ 'encryption' => ((int) $row['secure'] === 1) ? 'ssl' : 'tls',
+ ];
+ }
+
+ /** @param array $row */
+ private function toPublic(array $row): array
+ {
+ return [
+ 'host' => $row['host'],
+ 'port' => (int) $row['port'],
+ 'secure' => (int) $row['secure'] === 1,
+ 'username' => $row['username'],
+ 'has_password' => !empty($row['password_enc']),
+ 'from_email' => $row['from_email'],
+ 'from_name' => $row['from_name'],
+ 'reply_to' => $row['reply_to'],
+ 'enabled' => (int) $row['enabled'] === 1,
+ 'last_test_at' => $row['last_test_at'],
+ 'last_test_ok' => $row['last_test_ok'] === null ? null : ((int) $row['last_test_ok'] === 1),
+ 'last_error' => $row['last_error'],
+ 'updated_at' => $row['updated_at'] ?? null,
+ ];
+ }
+
+ /** @param array $cfg */
+ private function buildMailer(array $cfg): PHPMailer
+ {
+ $mail = new PHPMailer(true);
+ $mail->CharSet = 'UTF-8';
+ $mail->isSMTP();
+ $mail->Host = $cfg['host'];
+ $mail->Port = (int) $cfg['port'];
+ $mail->SMTPAuth = ($cfg['username'] ?? '') !== '';
+ if ($mail->SMTPAuth) {
+ $mail->Username = $cfg['username'];
+ $mail->Password = $cfg['password'] ?? '';
+ }
+ $enc = $cfg['encryption'] ?? (!empty($cfg['secure']) ? 'ssl' : 'tls');
+ if ($enc === 'ssl' || !empty($cfg['secure'])) {
+ $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
+ } else {
+ $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
+ $mail->SMTPAutoTLS = true;
+ }
+ $mail->Timeout = 12;
+ $mail->isHTML(true);
+ return $mail;
+ }
+
+ private function markTest(int $id, bool $ok, ?string $error): void
+ {
+ $stmt = $this->conn->prepare(
+ 'UPDATE smtp_settings SET last_test_at=NOW(), last_test_ok=:ok, last_error=:err WHERE id=:id'
+ );
+ $stmt->execute([':ok' => $ok ? 1 : 0, ':err' => $error, ':id' => $id]);
+ }
+
+ /** @return array{status:string,transport?:string,error?:string} */
+ private function finish(
+ ?int $tenantId,
+ string $to,
+ string $subject,
+ string $template,
+ ?string $type,
+ string $transport,
+ string $status,
+ ?string $error
+ ): array {
+ $this->writeLog($tenantId, $to, $subject, $template, $type, $transport, $status, $error);
+ $out = ['status' => $status, 'transport' => $transport];
+ if ($error) {
+ $out['error'] = $error;
+ }
+ return $out;
+ }
+
+ private function writeLog(
+ ?int $tenantId,
+ string $to,
+ string $subject,
+ string $template,
+ ?string $type,
+ string $transport,
+ string $status,
+ ?string $error
+ ): void {
+ $stmt = $this->conn->prepare(
+ "INSERT INTO email_log (tenant_id, to_email, subject, template, transport, status, error, type)
+ VALUES (:tid, :to, :subj, :tpl, :tr, :st, :err, :type)"
+ );
+ $stmt->execute([
+ ':tid' => $tenantId,
+ ':to' => mb_substr($to, 0, 254),
+ ':subj' => mb_substr($subject, 0, 300),
+ ':tpl' => mb_substr($template, 0, 80),
+ ':tr' => $transport,
+ ':st' => $status,
+ ':err' => $error,
+ ':type' => $type,
+ ]);
+ }
+}
diff --git a/src/Saas/Notify/Notify.php b/src/Saas/Notify/Notify.php
new file mode 100644
index 0000000..c047f57
--- /dev/null
+++ b/src/Saas/Notify/Notify.php
@@ -0,0 +1,96 @@
+ $users
+ * @param callable $build fn(array $user): array{subject:string,html:string,text?:string}
+ * @return array{sent:int,skipped:int,reason?:string,error?:string}
+ */
+ public static function fire(
+ ?int $tenantId,
+ string $type,
+ array $users,
+ callable $build,
+ ?int $excludeUserId = null
+ ): array {
+ try {
+ if (!NotifyRegistry::isValid($type)) {
+ return ['sent' => 0, 'skipped' => 0, 'reason' => 'unknown_type'];
+ }
+ if (NotifyGate::isPaused($type)) {
+ return ['sent' => 0, 'skipped' => count($users), 'reason' => 'paused'];
+ }
+
+ $mail = new MailGateway();
+ $sent = 0;
+ $skipped = 0;
+ foreach ($users as $user) {
+ $uid = isset($user['id']) ? (int) $user['id'] : 0;
+ if ($excludeUserId && $uid === $excludeUserId) {
+ $skipped++;
+ continue;
+ }
+ if ($uid > 0 && self::isMuted($uid, $type)) {
+ $skipped++;
+ continue;
+ }
+ $email = trim((string) ($user['email'] ?? ''));
+ if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ $skipped++;
+ continue;
+ }
+ $payload = $build($user);
+ $subject = (string) ($payload['subject'] ?? '');
+ $html = (string) ($payload['html'] ?? '');
+ if ($subject === '' || $html === '') {
+ $skipped++;
+ continue;
+ }
+ $result = $mail->send(
+ $email,
+ $subject,
+ $html,
+ 'notify',
+ $tenantId,
+ $type,
+ isset($payload['text']) ? (string) $payload['text'] : null
+ );
+ if (($result['status'] ?? '') === 'sent') {
+ $sent++;
+ } else {
+ $skipped++;
+ }
+ }
+ return ['sent' => $sent, 'skipped' => $skipped];
+ } catch (\Throwable $e) {
+ error_log('[saas-notify] ' . $type . ' ' . $e->getMessage());
+ return ['sent' => 0, 'skipped' => 0, 'error' => $e->getMessage()];
+ }
+ }
+
+ public static function isMuted(int $userId, string $type): bool
+ {
+ try {
+ $pdo = (new Database())->getConnection();
+ $stmt = $pdo->prepare(
+ 'SELECT 1 FROM notification_mutes WHERE user_id = :u AND mute_key = :k LIMIT 1'
+ );
+ $stmt->execute([':u' => $userId, ':k' => $type]);
+ return (bool) $stmt->fetchColumn();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+}
diff --git a/src/Saas/Notify/NotifyGate.php b/src/Saas/Notify/NotifyGate.php
new file mode 100644
index 0000000..11aa44a
--- /dev/null
+++ b/src/Saas/Notify/NotifyGate.php
@@ -0,0 +1,46 @@
+getConnection();
+ $stmt = $pdo->prepare(
+ 'SELECT paused FROM notification_global WHERE type = :t LIMIT 1'
+ );
+ $stmt->execute([':t' => $type]);
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ return $row && (int) $row['paused'] === 1;
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ public static function setPaused(string $type, bool $paused, ?string $reason = null, ?int $by = null): void
+ {
+ if (!NotifyRegistry::isValid($type)) {
+ throw new \InvalidArgumentException('Unknown notification type');
+ }
+ $pdo = (new Database())->getConnection();
+ $stmt = $pdo->prepare(
+ "INSERT INTO notification_global (type, paused, reason, updated_by)
+ VALUES (:t, :p, :r, :by)
+ ON DUPLICATE KEY UPDATE paused = VALUES(paused), reason = VALUES(reason), updated_by = VALUES(updated_by)"
+ );
+ $stmt->execute([
+ ':t' => $type,
+ ':p' => $paused ? 1 : 0,
+ ':r' => $reason,
+ ':by' => $by,
+ ]);
+ }
+}
diff --git a/src/Saas/Notify/NotifyRegistry.php b/src/Saas/Notify/NotifyRegistry.php
new file mode 100644
index 0000000..55b49b7
--- /dev/null
+++ b/src/Saas/Notify/NotifyRegistry.php
@@ -0,0 +1,77 @@
+ 'invoice_paid', 'zone' => 'tenant', 'kind' => 'instant',
+ * 'audience' => 'roles', 'roles' => ['tenant_owner'], 'enabled' => true,
+ * 'label' => '…', 'help' => '…', // prefer i18n keys in UI
+ * ]
+ */
+final class NotifyRegistry
+{
+ /** @var array> */
+ private static array $types = [];
+
+ /** @param array $type */
+ public static function register(array $type): void
+ {
+ $key = (string) ($type['key'] ?? '');
+ if ($key === '' || !preg_match('/^[a-z][a-z0-9_]{1,62}$/', $key)) {
+ throw new \InvalidArgumentException('Invalid notification type key');
+ }
+ $type['zone'] = $type['zone'] ?? 'tenant';
+ $type['kind'] = $type['kind'] ?? 'instant';
+ self::$types[$key] = $type;
+ }
+
+ /** @param list> $types */
+ public static function registerMany(array $types): void
+ {
+ foreach ($types as $t) {
+ self::register($t);
+ }
+ }
+
+ public static function clear(): void
+ {
+ self::$types = [];
+ }
+
+ public static function isValid(string $type): bool
+ {
+ return isset(self::$types[$type]);
+ }
+
+ /** @return array|null */
+ public static function get(string $type): ?array
+ {
+ return self::$types[$type] ?? null;
+ }
+
+ /** @return list> */
+ public static function all(): array
+ {
+ return array_values(self::$types);
+ }
+
+ /** @return list> */
+ public static function forZone(string $zone): array
+ {
+ return array_values(array_filter(
+ self::$types,
+ static fn(array $t): bool => ($t['zone'] ?? '') === $zone
+ ));
+ }
+
+ /** @return list */
+ public static function keys(): array
+ {
+ return array_keys(self::$types);
+ }
+}
diff --git a/src/Saas/Ops/Attention.php b/src/Saas/Ops/Attention.php
new file mode 100644
index 0000000..e2d180f
--- /dev/null
+++ b/src/Saas/Ops/Attention.php
@@ -0,0 +1,81 @@
+
+ */
+ public static function collect(): array
+ {
+ $items = [];
+
+ $mail = new MailGateway();
+ if (!$mail->isNetworkConfigured()) {
+ $items[] = [
+ 'tone' => 'bad',
+ 'key' => 'smtp',
+ 'tab' => 'email',
+ 'text' => 'Platform network SMTP is not configured — outbound mail will be skipped.',
+ ];
+ }
+
+ try {
+ $pdo = (new \App\Etc\Database())->getConnection();
+ $failed = (int) $pdo->query(
+ "SELECT COUNT(*) FROM email_log
+ WHERE status = 'failed' AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)"
+ )->fetchColumn();
+ if ($failed > 0) {
+ $items[] = [
+ 'tone' => 'bad',
+ 'key' => 'email_failed',
+ 'tab' => 'email',
+ 'meta' => ['count' => $failed],
+ 'text' => "{$failed} email(s) failed in the last 24 hours.",
+ ];
+ }
+ } catch (\Throwable) {
+ }
+
+ $sched = SchedulerHeartbeat::health();
+ if ($sched['stale']) {
+ $items[] = [
+ 'tone' => 'bad',
+ 'key' => 'scheduler',
+ 'tab' => 'ops',
+ 'meta' => ['last_run' => $sched['last_run']],
+ 'text' => $sched['last_run']
+ ? 'Scheduler heartbeat is stale — check cron.'
+ : 'No scheduler heartbeat yet — digests will not run until cron is configured.',
+ ];
+ }
+
+ $flags = PlatformSettings::flags();
+ if ($flags['maintenance_mode']) {
+ $items[] = [
+ 'tone' => 'warn',
+ 'key' => 'maintenance',
+ 'tab' => 'ops',
+ 'text' => 'Maintenance mode is ON.',
+ ];
+ }
+ if (!$flags['signup_enabled']) {
+ $items[] = [
+ 'tone' => 'signal',
+ 'key' => 'signup_off',
+ 'tab' => 'ops',
+ 'text' => 'Public signup is disabled.',
+ ];
+ }
+
+ return $items;
+ }
+}
diff --git a/src/Saas/Ops/PlatformSettings.php b/src/Saas/Ops/PlatformSettings.php
new file mode 100644
index 0000000..570c53c
--- /dev/null
+++ b/src/Saas/Ops/PlatformSettings.php
@@ -0,0 +1,55 @@
+getConnection();
+ $stmt = $pdo->prepare(
+ 'SELECT setting_value FROM platform_settings WHERE setting_key = :k LIMIT 1'
+ );
+ $stmt->execute([':k' => $key]);
+ $v = $stmt->fetchColumn();
+ return $v === false ? $default : (string) $v;
+ } catch (\Throwable) {
+ return $default;
+ }
+ }
+
+ public static function set(string $key, string $value): void
+ {
+ $pdo = (new Database())->getConnection();
+ $stmt = $pdo->prepare(
+ "INSERT INTO platform_settings (setting_key, setting_value)
+ VALUES (:k, :v)
+ ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"
+ );
+ $stmt->execute([':k' => $key, ':v' => $value]);
+ }
+
+ /** @return array{signup_enabled:bool,maintenance_mode:bool} */
+ public static function flags(): array
+ {
+ return [
+ 'signup_enabled' => self::get('signup_enabled', '1') !== '0',
+ 'maintenance_mode' => self::get('maintenance_mode', '0') === '1',
+ ];
+ }
+
+ /** @param array{signup_enabled?:bool,maintenance_mode?:bool} $flags */
+ public static function saveFlags(array $flags): array
+ {
+ if (array_key_exists('signup_enabled', $flags)) {
+ self::set('signup_enabled', !empty($flags['signup_enabled']) ? '1' : '0');
+ }
+ if (array_key_exists('maintenance_mode', $flags)) {
+ self::set('maintenance_mode', !empty($flags['maintenance_mode']) ? '1' : '0');
+ }
+ return self::flags();
+ }
+}
diff --git a/src/Saas/Ops/SchedulerHeartbeat.php b/src/Saas/Ops/SchedulerHeartbeat.php
new file mode 100644
index 0000000..b24ffcc
--- /dev/null
+++ b/src/Saas/Ops/SchedulerHeartbeat.php
@@ -0,0 +1,35 @@
+ null, 'stale' => true, 'age_seconds' => null];
+ }
+ $ts = strtotime($raw);
+ if ($ts === false) {
+ return ['last_run' => $raw, 'stale' => true, 'age_seconds' => null];
+ }
+ $age = time() - $ts;
+ return [
+ 'last_run' => $raw,
+ 'stale' => $age > $staleAfterSeconds,
+ 'age_seconds' => $age,
+ ];
+ }
+}
diff --git a/src/Saas/Security/SecretBox.php b/src/Saas/Security/SecretBox.php
new file mode 100644
index 0000000..252ab37
--- /dev/null
+++ b/src/Saas/Security/SecretBox.php
@@ -0,0 +1,80 @@
+
+ */
+ private static function keyCandidates(): array
+ {
+ $src = (string) (
+ getenv('APP_KEY')
+ ?: ($_ENV['APP_KEY'] ?? '')
+ ?: getenv('JWT_SECRET')
+ ?: ($_ENV['JWT_SECRET'] ?? '')
+ ?: Environment::get('APP_KEY', '')
+ ?: Environment::get('JWT_SECRET', '')
+ ?: 'upmvc-saas-dev-insecure-key-change-me'
+ );
+ $keys = [
+ hash('sha256', $src . '|saas-secret-v1', true),
+ hash('sha256', $src . '|smtp-password-v1', true), // myCMMS-PHP legacy
+ ];
+ // Also try insecure defaults if APP_KEY differed historically
+ $keys[] = hash('sha256', 'upmvc-saas-dev-insecure-key-change-me|saas-secret-v1', true);
+ $keys[] = hash('sha256', 'mycmms-dev-insecure-key-change-me|smtp-password-v1', true);
+ return array_values(array_unique($keys));
+ }
+}
diff --git a/src/Saas/Security/Totp.php b/src/Saas/Security/Totp.php
new file mode 100644
index 0000000..6265ab6
--- /dev/null
+++ b/src/Saas/Security/Totp.php
@@ -0,0 +1,156 @@
+ $secret,
+ 'issuer' => $issuer,
+ 'algorithm' => 'SHA1',
+ 'digits' => 6,
+ 'period' => self::PERIOD,
+ ], '', '&', PHP_QUERY_RFC3986);
+ return "otpauth://totp/{$label}?{$q}";
+ }
+
+ public static function stepAt(?int $ms = null): int
+ {
+ $ms ??= (int) floor(microtime(true) * 1000);
+ return (int) floor($ms / 1000 / self::PERIOD);
+ }
+
+ public static function codeAt(string $secret, ?int $ms = null): string
+ {
+ return self::hotp($secret, self::stepAt($ms));
+ }
+
+ /**
+ * @return int|null matched time-step, or null if invalid
+ */
+ public static function verify(string $secret, string $code, ?int $lastStep = null): ?int
+ {
+ $c = preg_replace('/\s+/', '', $code) ?? '';
+ if (!preg_match('/^\d{6}$/', $c)) {
+ return null;
+ }
+ $now = self::stepAt();
+ foreach ([$now - 1, $now, $now + 1] as $s) {
+ if ($lastStep !== null && $s <= $lastStep) {
+ continue;
+ }
+ $expect = self::hotp($secret, $s);
+ if (hash_equals($expect, $c)) {
+ return $s;
+ }
+ }
+ return null;
+ }
+
+ public static function seal(string $secret): string
+ {
+ $key = self::sealKey();
+ $iv = random_bytes(16);
+ $cipher = openssl_encrypt($secret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
+ if ($cipher === false) {
+ throw new \RuntimeException('Failed to seal TOTP secret');
+ }
+ return self::SEAL_PREFIX . base64_encode($iv . $cipher);
+ }
+
+ public static function unseal(string $sealed): ?string
+ {
+ if (!str_starts_with($sealed, self::SEAL_PREFIX)) {
+ return null;
+ }
+ $raw = base64_decode(substr($sealed, strlen(self::SEAL_PREFIX)), true);
+ if ($raw === false || strlen($raw) < 17) {
+ return null;
+ }
+ $iv = substr($raw, 0, 16);
+ $cipher = substr($raw, 16);
+ foreach (self::sealKeyCandidates() as $key) {
+ $plain = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
+ if ($plain !== false && $plain !== '') {
+ return $plain;
+ }
+ }
+ return null;
+ }
+
+ private static function sealKey(): string
+ {
+ return self::sealKeyCandidates()[0];
+ }
+
+ /** @return list */
+ private static function sealKeyCandidates(): array
+ {
+ // Prefer process env so unit tests / CLI do not force-load .env
+ $secret = (string) (getenv('JWT_SECRET') ?: ($_ENV['JWT_SECRET'] ?? '') ?: Environment::get('JWT_SECRET', ''));
+ if ($secret === '') {
+ throw new \RuntimeException('JWT_SECRET is required to seal TOTP secrets');
+ }
+ return [
+ hash('sha256', 'upmvc-saas-totp|' . $secret, true),
+ hash('sha256', 'mycmms-totp|' . $secret, true), // myCMMS-PHP legacy
+ ];
+ }
+
+ private static function hotp(string $secret, int $counter): string
+ {
+ $key = self::decodeBase32($secret);
+ // 8-byte big-endian counter (high 32 bits always 0 for practical steps)
+ $msg = pack('NN', ($counter >> 32) & 0xffffffff, $counter & 0xffffffff);
+ $h = hash_hmac('sha1', $msg, $key, true);
+ $o = ord($h[strlen($h) - 1]) & 0x0f;
+ $bin = unpack('N', substr($h, $o, 4));
+ $code = ($bin[1] & 0x7fffffff) % 1000000;
+ return str_pad((string) $code, 6, '0', STR_PAD_LEFT);
+ }
+
+ private static function decodeBase32(string $secret): string
+ {
+ $secret = strtoupper(rtrim($secret, '='));
+ $bits = '';
+ for ($i = 0, $n = strlen($secret); $i < $n; $i++) {
+ $idx = strpos(self::B32, $secret[$i]);
+ if ($idx === false) {
+ continue;
+ }
+ $bits .= str_pad(decbin($idx), 5, '0', STR_PAD_LEFT);
+ }
+ $bytes = '';
+ for ($i = 0; $i + 8 <= strlen($bits); $i += 8) {
+ $bytes .= chr(bindec(substr($bits, $i, 8)));
+ }
+ return $bytes;
+ }
+}
diff --git a/tests/Unit/HarvestKernelTest.php b/tests/Unit/HarvestKernelTest.php
new file mode 100644
index 0000000..206106d
--- /dev/null
+++ b/tests/Unit/HarvestKernelTest.php
@@ -0,0 +1,150 @@
+assertMatchesRegularExpression('/^[A-Z2-7]+$/', $secret);
+ $code = Totp::codeAt($secret);
+ $this->assertMatchesRegularExpression('/^\d{6}$/', $code);
+ $step = Totp::verify($secret, $code);
+ $this->assertNotNull($step);
+ $this->assertNull(Totp::verify($secret, '000000', $step));
+ }
+
+ public function testTotpSealRoundTrip(): void
+ {
+ // Seal needs JWT_SECRET — set a test value
+ $_ENV['JWT_SECRET'] = 'harvest-test-secret-not-for-prod';
+ putenv('JWT_SECRET=harvest-test-secret-not-for-prod');
+ $secret = Totp::newSecret();
+ $sealed = Totp::seal($secret);
+ $this->assertStringStartsWith('v1:', $sealed);
+ $this->assertSame($secret, Totp::unseal($sealed));
+ }
+
+ /** Pack unseal must still read myCMMS-era SMTP seals (`|smtp-password-v1`). */
+ public function testSecretBoxUnsealsLegacySmtpPepper(): void
+ {
+ $src = 'harvest-dual-unseal-app-key';
+ $_ENV['APP_KEY'] = $src;
+ putenv('APP_KEY=' . $src);
+ unset($_ENV['JWT_SECRET']);
+ putenv('JWT_SECRET');
+
+ $plain = 'smtp-password-legacy-proof';
+ $key = hash('sha256', $src . '|smtp-password-v1', true);
+ $iv = random_bytes(12);
+ $tag = '';
+ $ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
+ $this->assertNotFalse($ct);
+ $blob = 'v1:' . base64_encode($iv . $tag . $ct);
+
+ $this->assertSame($plain, SecretBox::unseal($blob));
+ // New writes use pack pepper only
+ $this->assertSame($plain, SecretBox::unseal(SecretBox::seal($plain)));
+ }
+
+ /** Pack unseal must still read myCMMS-era TOTP seals (`mycmms-totp|`). */
+ public function testTotpUnsealsLegacyMycmmsPepper(): void
+ {
+ $jwt = 'harvest-dual-unseal-jwt-secret';
+ $_ENV['JWT_SECRET'] = $jwt;
+ putenv('JWT_SECRET=' . $jwt);
+
+ $secret = Totp::newSecret();
+ $key = hash('sha256', 'mycmms-totp|' . $jwt, true);
+ $iv = random_bytes(16);
+ $cipher = openssl_encrypt($secret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
+ $this->assertNotFalse($cipher);
+ $sealed = 'v1:' . base64_encode($iv . $cipher);
+
+ $this->assertSame($secret, Totp::unseal($sealed));
+ }
+
+ public function testNotifyRegistryRegisterAndZone(): void
+ {
+ NotifyRegistry::register([
+ 'key' => 'invoice_paid',
+ 'zone' => 'tenant',
+ 'kind' => 'instant',
+ ]);
+ NotifyRegistry::register([
+ 'key' => 'mail_failures',
+ 'zone' => 'platform',
+ 'kind' => 'digest',
+ ]);
+ $this->assertTrue(NotifyRegistry::isValid('invoice_paid'));
+ $this->assertCount(1, NotifyRegistry::forZone('tenant'));
+ $this->assertCount(1, NotifyRegistry::forZone('platform'));
+ $this->assertSame(['invoice_paid', 'mail_failures'], NotifyRegistry::keys());
+ }
+
+ public function testNotifyRegistryRejectsBadKey(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ NotifyRegistry::register(['key' => 'Bad-Key!']);
+ }
+
+ public function testAuditActorsFromUser(): void
+ {
+ $a = AuditLog::actorsFromUser([
+ 'sub' => 10,
+ 'impersonated' => true,
+ 'impersonator_id' => 2,
+ ]);
+ $this->assertSame(10, $a['userId']);
+ $this->assertSame(2, $a['impersonatorId']);
+ }
+
+ public function testCatalogApplyOverlays(): void
+ {
+ $base = [
+ 'common' => ['save' => 'Save', 'name' => 'Name'],
+ 'platform' => ['ops_tab' => 'Ops'],
+ ];
+ $out = Catalog::applyOverlays($base, [
+ 'common.save' => 'Salvează',
+ 'platform.ops_tab' => 'Operațiuni',
+ 'platform.new_key' => 'New',
+ ]);
+ $this->assertSame('Salvează', $out['common']['save']);
+ $this->assertSame('Name', $out['common']['name']);
+ $this->assertSame('Operațiuni', $out['platform']['ops_tab']);
+ $this->assertSame('New', $out['platform']['new_key']);
+ }
+
+ public function testCatalogLoadFromPackLang(): void
+ {
+ $dir = dirname(__DIR__, 2) . '/public/lang';
+ $loaded = Catalog::load('en', $dir, ['en', 'ro'], false);
+ $this->assertSame('en', $loaded['locale']);
+ $this->assertSame('file', $loaded['source']);
+ $this->assertIsArray($loaded['catalog']['common'] ?? null);
+ }
+
+ public function testMigration002ExistsAndMentionsOpsTables(): void
+ {
+ $sql = (string) file_get_contents(dirname(__DIR__, 2) . '/database/migrations/002_saas_ops_layer.sql');
+ foreach (['smtp_settings', 'email_log', 'audit_log', 'notification_global', 'i18n_overlays', 'totp_enabled'] as $needle) {
+ $this->assertStringContainsString($needle, $sql);
+ }
+ }
+}
diff --git a/tests/Unit/PackStructureTest.php b/tests/Unit/PackStructureTest.php
index faaf3c5..1f658c7 100644
--- a/tests/Unit/PackStructureTest.php
+++ b/tests/Unit/PackStructureTest.php
@@ -131,6 +131,15 @@ public function testMigrationsCreateTheSaasTables(): void
"Migrations must create the $table table"
);
}
+
+ // Harvest ops layer (002)
+ foreach (['platform_settings', 'smtp_settings', 'email_log', 'audit_log', 'i18n_overlays'] as $table) {
+ $this->assertMatchesRegularExpression(
+ '/CREATE TABLE (IF NOT EXISTS )?`?' . $table . '`?/i',
+ $sql,
+ "Ops migrations must create the $table table"
+ );
+ }
}
public function testSchemaAndSeedShipForNonMigrationInstalls(): void