diff --git a/CLAUDE.md b/CLAUDE.md
index b721f34..4635ed7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -134,6 +134,7 @@ Environment variables consumed by the `picloud` binary:
| `PICLOUD_DB_MAX_CONNECTIONS` | `32` | Postgres pool size. Matched to `PICLOUD_MAX_CONCURRENT_EXECUTIONS` so the data plane can't starve background workers. |
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window admin session lifetime (idle timeout). |
| `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS` | `720` (30 days) | Absolute hard cap on an admin session's lifetime (audit 2026-07-11 C1). The sliding `touch` bump is clamped at `login_time + this`, so even a continuously-used or stolen-but-warm admin token self-expires. Mirrors the data-plane app-user session cap. |
+| `PICLOUD_SMTP_BIND` | — (off) | A3 native SMTP ingress. When set (e.g. `0.0.0.0:2525`), a receive-only SMTP listener binds alongside the HTTP server: it resolves each `RCPT TO` mailbox to the app-owned email trigger that claims it (`email_trigger_details.inbound_address`, unique) and writes an `Email` outbox row the dispatcher fires — same tail as the HMAC webhook. **No AUTH/STARTTLS at the listener** (TLS terminates upstream — Caddy/relay); the recipient address + per-app isolation are the boundary. `DATA` is size-capped. Unset = the webhook path only. Distinct from the outbound relay `PICLOUD_SMTP_*` vars (`SmtpConfig`). |
| `PICLOUD_INTERCEPTOR_TIMEOUT_MS` | `5000` (5s) | §9.4 M5 default per-interceptor wall-clock timeout when a `[[interceptors]]` marker sets no `timeout_ms`. Bounds ONE hook run so a runaway guard (`loop {}`) is denied (fail closed) rather than hanging the guarded write. The effective deadline is always tightened to at most the caller's remaining budget — a hook can never EXTEND its caller's deadline. |
| `PICLOUD_OUTBOX_CLAIM_TIMEOUT_SEC` | `600` (10 min) | How long a dispatcher may hold an OUTBOX claim before the reclaim task returns the row to the queue. **Without this a crash or restart mid-dispatch stranded every in-flight row permanently** — the dispatcher claims a row and only clears the claim on success (delete) or failure (reschedule), and `claim_due` selects `claimed_at IS NULL`, so a process that died in between left rows nothing would ever pick up again. Every other claim-based store (queue, group queue, workflow steps) already had a reclaimer; the outbox was the gap, and since it is the universal trigger path, the loss covered kv/docs/files/cron/pubsub/email/`invoke_async`/dead-letter alike. The default is deliberately generous: a script may run 300s, so a claim older than twice that is abandoned rather than slow. A reclaim does **not** bump `attempt_count` (the handler never ran — same reasoning as the transient queue `release`). Runs on the existing `PICLOUD_QUEUE_RECLAIM_INTERVAL_MS` ticker. |
| `PICLOUD_REALTIME_BROADCAST_CAPACITY` | `64` | Per-channel SSE broadcast buffer depth (a slow consumer sees oldest events dropped). |
@@ -156,6 +157,6 @@ Environment variables consumed by the `picloud` binary:
## Out of MVP
-This section captured the original MVP cut. Most of it has since **shipped in v1.1.x**: queue triggers, cron triggers, inbound email (`email:receive`, HMAC-webhook model), KV / docs / email / users / HTTP SDKs, function-to-function `invoke()`, and secrets are all live (blueprint §12 Phase 4 table). The **Workflows** track (DAG + nested workflows) has since **shipped** (migrations `0071`/`0072`). The **§9.4 service-interceptor** track has since shipped in full (migrations `0073`–`0075`; before/after phases, data-transform, all six services, per-interceptor timeout, resolve cache, `pic interceptors ls`). A read-only **metrics/observability dashboard** has since shipped (A2): `GET /api/v1/admin/apps/{id}/metrics?window=` aggregates the existing `execution_logs` table (counts, error rate, latency avg/p50/p95, an hourly series) via `ExecutionLogRepository::summarize_for_app` + `metrics_api`, surfaced as the dashboard's per-app **Metrics** tab — no hot-path instrumentation. **Still deferred:** a raw SMTP-listener ingress, and **multi-node cluster mode**. Don't pre-build for them — but don't make decisions that close the door on them either.
+This section captured the original MVP cut. Most of it has since **shipped in v1.1.x**: queue triggers, cron triggers, inbound email (`email:receive`, HMAC-webhook model), KV / docs / email / users / HTTP SDKs, function-to-function `invoke()`, and secrets are all live (blueprint §12 Phase 4 table). The **Workflows** track (DAG + nested workflows) has since **shipped** (migrations `0071`/`0072`). The **§9.4 service-interceptor** track has since shipped in full (migrations `0073`–`0075`; before/after phases, data-transform, all six services, per-interceptor timeout, resolve cache, `pic interceptors ls`). A read-only **metrics/observability dashboard** has since shipped (A2): `GET /api/v1/admin/apps/{id}/metrics?window=` aggregates the existing `execution_logs` table (counts, error rate, latency avg/p50/p95, an hourly series) via `ExecutionLogRepository::summarize_for_app` + `metrics_api`, surfaced as the dashboard's per-app **Metrics** tab — no hot-path instrumentation. **Still deferred:** **multi-node cluster mode**. Don't pre-build for them — but don't make decisions that close the door on them either.
**Pulled forward to Phase 3 (pre-v1.1):** admin auth, multi-app scoping. The general cross-app **export/import** sharing model stays at v1.3+; note that v1.2 §11.6 shipped a narrower form — **group-owned shared collections** (KV/docs/files/topics/queues) let apps in one subtree share data through the owning group, with the ancestor-chain walk as the isolation boundary. See blueprint §11.5 + design-doc §11.6.
diff --git a/crates/manager-core/migrations/0076_email_inbound_address.sql b/crates/manager-core/migrations/0076_email_inbound_address.sql
new file mode 100644
index 0000000..6cdb8c9
--- /dev/null
+++ b/crates/manager-core/migrations/0076_email_inbound_address.sql
@@ -0,0 +1,13 @@
+-- A3 raw SMTP ingress — an inbound email ADDRESS an email trigger listens on.
+--
+-- The HMAC-webhook path (v1.1.7) addresses a trigger by its UUID in the URL. A
+-- native SMTP listener instead receives `RCPT TO:
` and must resolve
+-- that address to the bound trigger. `inbound_address` is the mailbox the
+-- trigger claims; NULL for a webhook-only trigger. Case-insensitively unique
+-- among app-owned triggers so an address resolves to exactly one script.
+ALTER TABLE email_trigger_details
+ ADD COLUMN inbound_address TEXT;
+
+CREATE UNIQUE INDEX email_trigger_details_inbound_address_uidx
+ ON email_trigger_details (lower(inbound_address))
+ WHERE inbound_address IS NOT NULL;
diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs
index 49651fe..d602287 100644
--- a/crates/manager-core/src/lib.rs
+++ b/crates/manager-core/src/lib.rs
@@ -269,7 +269,8 @@ pub use trigger_repo::{
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateEmailTrigger,
CreateFilesTrigger, CreateKvTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch,
DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch, KvTriggerMatch, PostgresTriggerRepo,
- Trigger, TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
+ SmtpInboundTarget, Trigger, TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo,
+ TriggerRepoError,
};
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs
index 3358b0b..d3d11d1 100644
--- a/crates/manager-core/src/trigger_repo.rs
+++ b/crates/manager-core/src/trigger_repo.rs
@@ -316,6 +316,21 @@ pub struct CreateEmailTrigger {
/// Envelope version of the sealed inbound secret (0 = legacy no-AAD, 1 =
/// AAD-bound to the sealing owner). `0` when there is no secret.
pub inbound_secret_version: i16,
+ /// A3: the mailbox this trigger receives at over the native SMTP listener.
+ /// `None` = webhook-only (addressed by trigger UUID in the URL).
+ pub inbound_address: Option,
+ pub registered_by_principal: AdminUserId,
+}
+
+/// A3: the SMTP-ingress lookup result — the app-owned email trigger claiming an
+/// `RCPT TO` address, plus what an `Email` outbox row needs. No secret: the
+/// SMTP path has no HMAC (the mailbox address + per-app isolation are the
+/// boundary), unlike the webhook path.
+#[derive(Debug, Clone)]
+pub struct SmtpInboundTarget {
+ pub trigger_id: TriggerId,
+ pub app_id: AppId,
+ pub script_id: ScriptId,
pub registered_by_principal: AdminUserId,
}
@@ -475,6 +490,17 @@ pub trait TriggerRepo: Send + Sync {
trigger_id: TriggerId,
) -> Result