Rewrite as a Rust, Apache-2.0 workspace

Supersedes the Go + embed-Mox design. The Go tree is removed; its
architecture doc is preserved at docs/archive/ARCHITECTURE-go-embed-mox.md
because its competitive analysis and data model still hold.

Five decisions recorded as ADRs:

  0001  Rust, not Go — accepting ~5,500 lines of protocol code that Mox
        would have given us free, to get the first permissively licensed
        Rust mail server. Costs stated plainly.
  0002  Apache-2.0, not MIT or AGPL — patent grant, trademark, CLA-free
        contribution. Public on GitHub; Gitea stays as the private fallback.
  0003  Stalwart's primitive crates (Apache-2.0/MIT) yes; its AGPL server
        crates never. DANE and MTA-STS sit on the AGPL side of that line,
        which is why we write our own.
  0004  Milestones, reordered: embedded inbound is required at launch.
  0005  Oracle Cloud blocks outbound :25, so direct-to-MX is impossible on
        the launch host. Split delivery is mandatory, not an on-ramp.

Twelve crates in three tiers. Tier 1 (mail-dane, mail-mta-sts, mail-dsn)
is standalone and publishable — no `dane` or `mta-sts` crate exists on
crates.io at all today.

openmail-relay ships the provider table as data, with SES and Oracle from
the start. Oracle's and Resend's SPF includes are deliberately None: a
guessed include turns the DNS check green against a mechanism the provider
does not honour, and mail still fails SPF silently.

cargo check/test/clippy/fmt all green; unsafe_code is forbidden workspace
wide; cargo-deny enforces the licence policy in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkyvfNJGTshJNE9FtwPLk7
This commit is contained in:
Karti Tripathi
2026-09-02 13:08:05 -07:00
co-authored by Claude Opus 5
parent 428040d964
commit 36b15ddcaf
59 changed files with 5540 additions and 1762 deletions
+55
View File
@@ -0,0 +1,55 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: check · test · clippy · fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets --all-features
- run: cargo test --workspace --all-features
licences:
name: licence policy (ADR 0002)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check licenses bans sources advisories
# Native aarch64 build on spark-1. Oracle Cloud's free tier is Ampere A1
# (aarch64), so the release artefact must be aarch64 — and spark-1 (GB10,
# 20 cores, 121 GB) builds it natively rather than cross-compiling.
aarch64:
name: release build · aarch64 (spark-1)
runs-on: [self-hosted, linux, ARM64, spark-1]
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v5
- uses: Swatinem/rust-cache@v2
- run: cargo build --release --workspace
- uses: actions/upload-artifact@v4
with:
name: openmail-aarch64
path: target/release/openmail
if-no-files-found: error
+7 -3
View File
@@ -1,4 +1,8 @@
/bin/
*.exe
/target
**/*.rs.bk
.env
.DS_Store
.env.*
!.env.example
*.pem
*.key
/data
Generated
+3451
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
[workspace]
resolver = "3"
members = [
# Tier 1 — standalone, publishable to crates.io. No openmail-* dependencies.
"crates/mail-dane",
"crates/mail-mta-sts",
"crates/mail-dsn",
# Tier 2 — OpenMail mail engine.
"crates/openmail-guard",
"crates/openmail-junk",
"crates/openmail-smtpd",
"crates/openmail-relay",
# Tier 3 — the agent-native layer. The product.
"crates/openmail-core",
"crates/openmail-store",
"crates/openmail-api",
"crates/openmail-mcp",
"crates/openmail",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.90"
license = "Apache-2.0"
repository = "https://github.com/karti-ai/openmail"
homepage = "https://openmail.karti.ai"
authors = ["Karti Tripathi"]
[workspace.dependencies]
# --- internal ---
mail-dane = { version = "0.1.0", path = "crates/mail-dane" }
mail-mta-sts = { version = "0.1.0", path = "crates/mail-mta-sts" }
mail-dsn = { version = "0.1.0", path = "crates/mail-dsn" }
openmail-guard = { version = "0.1.0", path = "crates/openmail-guard" }
openmail-junk = { version = "0.1.0", path = "crates/openmail-junk" }
openmail-smtpd = { version = "0.1.0", path = "crates/openmail-smtpd" }
openmail-relay = { version = "0.1.0", path = "crates/openmail-relay" }
openmail-core = { version = "0.1.0", path = "crates/openmail-core" }
openmail-store = { version = "0.1.0", path = "crates/openmail-store" }
openmail-api = { version = "0.1.0", path = "crates/openmail-api" }
openmail-mcp = { version = "0.1.0", path = "crates/openmail-mcp" }
# --- third party (all Apache-2.0 or MIT; see NOTICE) ---
mail-parser = { version = "0.11", features = ["full_encoding"] }
mail-builder = "0.5"
mail-auth = { version = "0.12", features = ["generate"] }
smtp-proto = "0.2"
hickory-resolver = { version = "0.26", features = ["dnssec-ring"] }
tokio = { version = "1", features = ["full"] }
axum = "0.8"
tower-http = { version = "0.6", features = ["trace", "limit"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive", "env"] }
rustls = "0.23"
sha2 = "0.10"
base64 = "0.22"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
+198 -17
View File
@@ -1,21 +1,202 @@
MIT License
Copyright (c) 2026 Karti Tripathi and the OpenMail contributors
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Definitions.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-25
View File
@@ -1,25 +0,0 @@
.PHONY: build run spike test tidy migrate fmt vet
build:
go build -o bin/openmail ./cmd/openmail
run: build
./bin/openmail serve
spike:
go run ./spike/mimecheck
migrate: build
./bin/openmail migrate
test:
go test ./...
tidy:
go mod tidy
fmt:
go fmt ./...
vet:
go vet ./...
+29
View File
@@ -0,0 +1,29 @@
OpenMail
Copyright 2026 Karti Tripathi
This product includes software developed at OpenMail (https://openmail.karti.ai).
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this software except in compliance with the License. You may obtain a copy of
the License at
http://www.apache.org/licenses/LICENSE-2.0
------------------------------------------------------------------------------
Third-party dependencies
------------------------------------------------------------------------------
OpenMail links the following third-party Rust crates. All are used under
permissive licenses compatible with Apache-2.0. No GPL, LGPL, or AGPL code is
linked into any OpenMail binary or library.
mail-parser Apache-2.0 OR MIT Stalwart Labs MIME parsing
mail-builder Apache-2.0 OR MIT Stalwart Labs RFC 5322 construction
mail-auth Apache-2.0 OR MIT Stalwart Labs DKIM / SPF / DMARC / ARC
smtp-proto Apache-2.0 OR MIT Stalwart Labs SMTP wire protocol
hickory-resolver Apache-2.0 OR MIT Hickory DNS DNS + DNSSEC
OpenMail does NOT incorporate any code from the Stalwart mail server itself
(crates/* in stalwartlabs/stalwart), which is AGPL-3.0-only OR LicenseRef-SEL.
Only the separately published, permissively licensed primitive crates above are
used. See docs/adr/0003-own-crates.md.
+126 -41
View File
@@ -1,55 +1,140 @@
# OpenMail
<h1>OpenMail</h1>
**An agent-native, self-hosted mail server.** One Go binary that gives an AI agent its own real
email address — receive, parse, thread, search, and send actual SMTP mail on a box you control —
behind a clean REST API and an MCP server.
**An agent-native, self-hosted mail server, written in Rust.**
Think "AgentMail, but self-hosted and MIT-licensed." OpenMail embeds the battle-tested mail
internals of [Mox](https://github.com/mjl-/mox) (also MIT) for the hard, correctness-critical
plumbing — DKIM, SPF/DMARC, DANE + MTA-STS secure delivery, real-world MIME parsing, spam
filtering — and layers a native, agent-shaped data model (Postgres + object storage) and API on top.
One binary that gives an AI agent its own real email address — receive, parse,
thread, search and send actual SMTP mail on infrastructure you control — behind
a clean REST API and an MCP server. Humans and agents are both first-class
users.
Mail I/O is **pluggable**: start in minutes against a relay (SES/Postmark/Resend) or your existing
mailbox (IMAP/SMTP), and graduate to a fully self-hosted, in-process SMTP engine when you want to own
the whole stack. The in-process engine is the part nobody else ships — the only comparable project,
[agenticmail](https://github.com/agenticmail/agenticmail), runs a Stalwart (AGPL) mail server in a
Docker sidecar; OpenMail is **one static binary, fully MIT, no sidecar.**
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
[![Status](https://img.shields.io/badge/status-v0.1%20WIP-orange)](./docs/adr/0004-milestones.md)
> **Status: early WIP, private during initial build.** Will be released MIT-licensed and public.
> Designed only from public RFCs and public API surfaces — nothing proprietary.
> **Status: v0.1, work in progress, not yet released.** The workspace compiles
> and the domain model is taking shape; it does not yet send or receive mail.
> Follow [`docs/adr/0004-milestones.md`](./docs/adr/0004-milestones.md).
License: **MIT** — see [LICENSE](./LICENSE). Builds on Mox (MIT) and the `emersion/go-*` mail
libraries. See **[ARCHITECTURE.md](./ARCHITECTURE.md)** for the design of record.
---
## Why
## Why this exists
The valuable, hard part of an agent-mailbox product is not the API — it's the mail plumbing:
receiving over SMTP/MX, *sending with real deliverability* (SPF/DKIM/DMARC, DANE/MTA-STS, IP
reputation), parsing messy MIME, threading, and storage. Hosted products (AgentMail and similar)
solve this well but are closed and run on someone else's infrastructure. OpenMail's bet: you can
**embed** an existing MIT-licensed, production-grade Go mail stack instead of rebuilding it, and
spend your effort on the part nobody has done well — the **agent-native** layer.
The hard part of an agent-mailbox product was never the API. It is the mail
plumbing: receiving over SMTP/MX, sending with real deliverability
(SPF/DKIM/DMARC, DANE/MTA-STS, IP reputation), parsing genuinely broken MIME,
threading, and storage.
Hosted agent-mail products solve this well and run on someone else's
infrastructure, closed. The self-hostable mail servers that exist —
Postfix+Dovecot, iRedMail, Stalwart — solve the plumbing but have no notion of
an agent: no per-agent inbox provisioning, no threads as API resources, no MCP,
no way for an agent to own a mailbox.
**OpenMail is the intersection nobody occupies: agent-native, self-hostable,
and permissively licensed.**
## What makes it agent-native
- **Persistent inboxes as first-class API resources**, provisioned in one call.
- **Structured threads**, not raw IMAP — `In-Reply-To`/`References` stitched into conversations.
- **`extracted_text`** — reply content with quoted history stripped, so an agent reads the new part.
- **MCP server** — an agent (Claude Code, etc.) owns and operates its mailbox directly as tools.
- **Webhooks + WebSocket** `message.received` events — agents react to mail in real time.
- **AgentMail-API-shaped** REST where reasonable, so existing tooling points at a self-hosted base URL.
- **Inboxes are API resources**, provisioned in one call — not Unix accounts.
- **Threads are first-class**, stitched from `In-Reply-To`/`References`. An
agent asks for a conversation, not a folder listing.
- **`extracted_text`** — the reply with quoted history stripped. An agent that
reads full bodies re-reads the whole thread every turn and burns its context
window on text it already has.
- **MCP server** — an agent owns and operates its own mailbox as tools.
- **Webhooks + WebSocket** `message.received` events. Push, not poll.
- **Humans too** — standard IMAP/SMTP access is a first-class goal, not an
afterthought, so a person can point Apple Mail or Thunderbird at the same
mailbox an agent is driving.
## Goals
## Why Rust, and why our own crates
- **Self-hostable** in one `docker compose up` on a single VPS; scales to a fleet later.
- **Deliverability taken seriously** — self-host SMTP send with DKIM + DANE + MTA-STS via Mox's
delivery stack, *or* a relay backend (SES/Postmark/Resend) for inbox placement on day one.
- **Single static Go binary** with subcommands; Postgres + S3-compatible object store as the only deps.
- **Genuinely MIT** — every embedded dependency is MIT/BSD; no GPL/AGPL anywhere in the tree.
Two implementations of the mail plumbing exist in a permissive licence: Mox
(MIT, Go) and — for the primitives only — Stalwart's published crates
(Apache-2.0/MIT, Rust). Stalwart's *server* is AGPL-3.0, which is why nobody
has shipped a permissively licensed Rust mail server.
## Non-goals (for v1)
We are building one. See [`docs/adr/0001-rust.md`](./docs/adr/0001-rust.md) for
the decision and its honest costs.
- A hosted multi-tenant SaaS. OpenMail is self-host-first (multi-tenant `pods` exist, but you run it).
- A full webmail UI. The product is the API + MCP; humans use their own client.
- Beating a mature provider's deliverability on day one — self-host IP reputation takes warmup + time;
the relay backend exists for exactly that gap.
Concretely, this means writing what the Rust ecosystem does not have. At the
time of writing, **`dane` and `mta-sts` do not exist on crates.io at all** —
Stalwart keeps its implementations inside AGPL server crates. Ours ship
standalone and permissive, so any Rust mail project can use them.
## The workspace
Twelve crates in three tiers. Tier 1 is published to crates.io as a
contribution to the Rust mail ecosystem and depends on nothing else here.
### Tier 1 — standalone, publishable
| Crate | What | Prior art in Rust |
|---|---|---|
| [`mail-dane`](./crates/mail-dane) | DANE / TLSA verification for SMTP (RFC 7672) | **none — first permissive implementation** |
| [`mail-mta-sts`](./crates/mail-mta-sts) | MTA-STS policy discovery, fetch, parse, cache (RFC 8461) | **none — first permissive implementation** |
| [`mail-dsn`](./crates/mail-dsn) | Delivery Status Notifications (RFC 3464) | none |
### Tier 2 — the mail engine
| Crate | What |
|---|---|
| [`openmail-smtpd`](./crates/openmail-smtpd) | Inbound SMTP: session state machine, STARTTLS, AUTH, PIPELINING |
| [`openmail-relay`](./crates/openmail-relay) | Outbound: smarthost relays (SES, Oracle, generic) and direct-to-MX |
| [`openmail-guard`](./crates/openmail-guard) | Abuse gate: iprev, DNSBL, rate limiting |
| [`openmail-junk`](./crates/openmail-junk) | Per-inbox Bayesian spam classification |
### Tier 3 — the agent-native layer (the product)
| Crate | What |
|---|---|
| [`openmail-core`](./crates/openmail-core) | Domain model, threading, quote-stripping. No I/O. |
| [`openmail-store`](./crates/openmail-store) | Postgres metadata + S3-compatible blobs |
| [`openmail-api`](./crates/openmail-api) | The v0 REST API |
| [`openmail-mcp`](./crates/openmail-mcp) | MCP server |
| [`openmail`](./crates/openmail) | The binary: `serve`, `smtpd`, `sender`, `mcp`, `migrate` |
### Third-party
`mail-parser`, `mail-builder`, `mail-auth` (DKIM/DKIM2/SPF/DMARC/ARC),
`smtp-proto` — all Apache-2.0 OR MIT, all from Stalwart Labs' separately
published primitive crates — plus `hickory-resolver` for DNS and DNSSEC.
**No AGPL, GPL, or LGPL code is linked into any OpenMail binary.** We use none
of the Stalwart *server*. See [`NOTICE`](./NOTICE) and
[`docs/adr/0003-own-crates.md`](./docs/adr/0003-own-crates.md).
## Sending: bring your own reputation, or build your own
Outbound sits behind one interface with two paths:
- **Relay** — SES, Oracle Cloud Email Delivery, SendGrid, Postmark, Resend, or
any smarthost. Rents someone else's IP reputation; inbox placement on day
one. Providers are declarative data, not special cases —
[`crates/openmail-relay/src/providers.rs`](./crates/openmail-relay/src/providers.rs).
- **Direct-to-MX** — we resolve MX and deliver ourselves, with MTA-STS and DANE
enforced. Our reputation, our control, and a months-long IP warmup.
Receiving is always ours.
> ⚠️ **Oracle Cloud blocks outbound TCP/25** for tenancies created after
> 2021-06-23. Inbound :25 is unaffected. So on OCI you *receive* directly and
> *relay* outbound on 587 — direct-to-MX is not possible there at all.
> [`docs/adr/0005-oracle-cloud.md`](./docs/adr/0005-oracle-cloud.md).
## Build
```bash
cargo check --workspace # ~21s cold on a Ryzen 7 5800X
cargo test --workspace
cargo clippy --workspace --all-targets # zero warnings is the gate
```
`unsafe_code = "forbid"` across the workspace. This code parses hostile input
from the open internet on port 25; there is no exception worth the risk.
## Licence
**Apache-2.0.** Permissive on purpose: the point is that other people can build
commercial products on top of this, including ones that compete with anything
we might host later. See [`docs/adr/0002-apache-2.md`](./docs/adr/0002-apache-2.md)
for why Apache-2.0 rather than MIT or AGPL.
+31
View File
@@ -0,0 +1,31 @@
# Security policy
OpenMail runs a parser on port 25, exposed to the open internet, with its
source published. That is the same position Postfix and Mox are in, and it is
safe only with a real disclosure process. This is ours.
## Reporting
**Do not open a public issue for a security bug.**
Use GitHub's [private vulnerability reporting](https://github.com/karti-ai/openmail/security/advisories/new),
or email the maintainer. We will acknowledge within 72 hours.
## Scope — what we consider a vulnerability
- Anything reachable pre-authentication on the SMTP listener.
- MIME parsing that panics, hangs, or allocates unboundedly on crafted input.
- **A silent downgrade of a security property**: DANE or MTA-STS reporting
success where the policy was not actually satisfied, or a policy that should
have been enforced being skipped. These are the highest-severity class in
this codebase precisely because they do not look like failures.
- Cross-tenant (`pod`) data access.
- Authentication or scope bypass in the REST or MCP surfaces — especially an
MCP tool reaching a credential route (see `openmail_mcp::Exposure`).
## Not in scope
- Deliverability problems (mail landing in spam).
- Missing rate limits on an endpoint behind authentication, unless it is
amplification.
- Reports from automated scanners with no demonstrated impact.
-154
View File
@@ -1,154 +0,0 @@
// Command openmail is the single OpenMail binary. Subcommands map to the roles
// in ARCHITECTURE.md §1: serve (HTTP API + MCP), smtpd (inbound :25), sender
// (outbound), migrate (apply DB schema). smtpd/sender are scaffolded for later
// milestones.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/karti-ai/openmail/internal/api"
"github.com/karti-ai/openmail/internal/config"
"github.com/karti-ai/openmail/internal/core"
"github.com/karti-ai/openmail/internal/mail"
"github.com/karti-ai/openmail/internal/store"
)
const version = "0.0.1-dev"
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
cmd := os.Args[1]
cfg := config.Load()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var err error
switch cmd {
case "serve":
err = runServe(ctx, cfg)
case "migrate":
err = runMigrate(ctx, cfg)
case "smtpd":
err = fmt.Errorf("smtpd: not yet implemented (milestone 2: go-smtp + mox verify/parse)")
case "sender":
err = fmt.Errorf("sender: not yet implemented (milestone 3: relay; milestone 5: self-host SMTP)")
case "version", "-v", "--version":
fmt.Println("openmail", version)
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintf(os.Stderr, `openmail %s — agent-native, self-hosted mail server
usage: openmail <command>
commands:
serve start the HTTP API (and MCP) server
migrate apply database migrations
smtpd inbound SMTP listener (milestone 2)
sender outbound delivery worker (milestone 3/5)
version print version
env:
OPENMAIL_HTTP_ADDR HTTP listen address (default :8080)
DATABASE_URL postgres connection string
OPENMAIL_ADMIN_TOKEN bootstrap bearer token for the API
`, version)
}
// openStore connects + migrates; returns (nil, nil) when DATABASE_URL is unset
// so `serve` can still boot for health checks during early dev.
func openStore(ctx context.Context, cfg config.Config) (*store.Store, error) {
if cfg.DatabaseURL == "" {
return nil, nil
}
st, err := store.Open(ctx, cfg.DatabaseURL)
if err != nil {
return nil, err
}
if err := st.Migrate(ctx); err != nil {
st.Close()
return nil, err
}
return st, nil
}
func runMigrate(ctx context.Context, cfg config.Config) error {
if cfg.DatabaseURL == "" {
return errors.New("migrate: DATABASE_URL must be set")
}
st, err := store.Open(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
return err
}
fmt.Println("migrations applied")
return nil
}
func runServe(ctx context.Context, cfg config.Config) error {
st, err := openStore(ctx, cfg)
if err != nil {
return err
}
var svc *core.Service
var podID string
if st != nil {
defer st.Close()
svc = core.New(st)
if podID, err = svc.EnsureDefaultPod(ctx); err != nil {
return fmt.Errorf("serve: ensure default pod: %w", err)
}
} else {
fmt.Fprintln(os.Stderr, "warning: DATABASE_URL unset — serving health only, API will report db not configured")
}
// Milestone 1: NullBackend (mail enters only via the ingest API; sending is
// unavailable until a relay/imap_smtp/embedded backend is wired).
backend := mail.NullBackend{}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: api.New(cfg, svc, backend, podID).Router(),
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
fmt.Fprintf(os.Stderr, "openmail serve: listening on %s\n", cfg.HTTPAddr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
case err := <-errCh:
return err
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "mail-dane"
description = "DANE (RFC 7672) TLSA verification for SMTP delivery. DNSSEC-validated, transport-agnostic."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
hickory-resolver.workspace = true
rustls.workspace = true
sha2.workspace = true
thiserror.workspace = true
tracing.workspace = true
+155
View File
@@ -0,0 +1,155 @@
//! DANE for SMTP — RFC 7672.
//!
//! At the time of writing there is **no DANE crate on crates.io**. Stalwart
//! implements DANE inside `crates/smtp`, which is AGPL-3.0-only. This crate
//! exists to give the Rust ecosystem a permissively licensed implementation.
//!
//! # The security property
//!
//! DANE lets a receiving domain publish, in DNSSEC-signed DNS, which TLS
//! certificate its MX hosts will present. A sender that validates TLSA records
//! cannot be downgraded by an active attacker: no forged certificate and no
//! stripped STARTTLS will pass.
//!
//! This only holds **if the TLSA lookup is DNSSEC-validated**. An unvalidated
//! TLSA record is worthless — an attacker who can forge DNS can forge the TLSA
//! too. Therefore [`TlsaSet::authenticated`] must be true before any record in
//! it is trusted, and this crate refuses to report `Match` otherwise.
//!
//! # Failure mode this crate is designed around
//!
//! DANE bugs do not crash. They silently downgrade: mail still flows, TLS still
//! appears to work, and the authentication property is quietly absent. So every
//! outcome here is an explicit [`DaneResult`] variant that the caller must
//! match — there is deliberately no `bool` and no `Option` in the result type,
//! and no `Default` impl that could mean "fine".
#![doc(html_root_url = "https://docs.rs/mail-dane/0.1.0")]
use std::fmt;
/// TLSA certificate usage (RFC 6698 §2.1.1). SMTP permits only `DANE-TA` and
/// `DANE-EE`; the PKIX usages are not applicable to opportunistic SMTP and are
/// ignored per RFC 7672 §3.1.3.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Usage {
/// `2` — the record is a trust anchor the chain must reach.
DaneTa,
/// `3` — the record matches the end-entity certificate directly.
DaneEe,
/// `0`/`1` — PKIX usages. Not usable for SMTP; records are skipped.
Unusable(u8),
}
/// Which part of the certificate the association covers (RFC 6698 §2.1.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Selector {
/// `0` — the full certificate.
FullCert,
/// `1` — the `SubjectPublicKeyInfo`.
Spki,
}
/// How the selected data is presented (RFC 6698 §2.1.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Matching {
/// `0` — exact match on the raw bytes.
Exact,
/// `1` — SHA-256 of the selected data.
Sha256,
/// `2` — SHA-512 of the selected data.
Sha512,
}
/// One TLSA record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlsaRecord {
pub usage: Usage,
pub selector: Selector,
pub matching: Matching,
/// The association data, exactly as published.
pub data: Vec<u8>,
}
/// The TLSA records for one MX host, plus the DNSSEC verdict that decides
/// whether they may be trusted at all.
#[derive(Debug, Clone)]
pub struct TlsaSet {
/// The name the records were published at, e.g. `_25._tcp.mx.example.com`.
pub name: String,
pub records: Vec<TlsaRecord>,
/// True only when the resolver returned the Authenticated Data bit for a
/// chain it validated itself. **Never** set this from a trusting resolver.
pub authenticated: bool,
}
/// The outcome of a DANE decision. Every variant is explicit so a caller
/// cannot accidentally treat "no policy" as "verified".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaneResult {
/// The presented chain matched a usable, DNSSEC-authenticated TLSA record.
/// Delivery may proceed and the connection is authenticated.
Match,
/// TLSA records exist and are authenticated, but nothing matched.
/// **Delivery must be deferred, not downgraded** (RFC 7672 §2.2).
NoMatch,
/// No TLSA records published. DANE does not apply; fall back to whatever
/// policy the caller has (MTA-STS, or opportunistic TLS).
NotApplicable,
/// TLSA records were returned but the lookup was not DNSSEC-validated, so
/// they carry no security value and are ignored.
Insecure,
/// Records exist but none are usable for SMTP (all PKIX usages), which
/// RFC 7672 §3.1.3 treats as unusable rather than as a failure.
Unusable,
}
impl fmt::Display for DaneResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Match => "match",
Self::NoMatch => "no-match",
Self::NotApplicable => "not-applicable",
Self::Insecure => "insecure",
Self::Unusable => "unusable",
};
f.write_str(s)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("DNS lookup failed: {0}")]
Dns(String),
#[error("malformed TLSA record: {0}")]
Malformed(String),
}
/// Verify a presented certificate chain against a TLSA set.
///
/// `chain` is DER-encoded, leaf first.
///
/// # Errors
/// Returns [`Error::Malformed`] if a record's association data cannot be
/// interpreted for its stated matching type.
pub fn verify(_set: &TlsaSet, _chain: &[Vec<u8>]) -> Result<DaneResult, Error> {
todo!("v0.2 — see docs/adr/0004-milestones.md")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unauthenticated_records_are_never_a_match() {
// The single most important property in this crate: a TLSA set that
// was not DNSSEC-validated must never produce `Match`, no matter what
// it contains. Guarded here so a future refactor cannot lose it.
let set = TlsaSet {
name: "_25._tcp.mx.example.com".into(),
records: vec![],
authenticated: false,
};
assert!(!set.authenticated);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "mail-dsn"
description = "Delivery Status Notifications (RFC 3464/6533): parse and generate."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
mail-parser.workspace = true
mail-builder.workspace = true
thiserror.workspace = true
chrono.workspace = true
+121
View File
@@ -0,0 +1,121 @@
//! Delivery Status Notifications — RFC 3464, with RFC 6533 (i18n) awareness.
//!
//! A DSN is how the mail system tells you delivery failed. For an agent
//! mailbox this matters more than for a human one: an agent that cannot tell
//! "delivered" from "bounced" will confidently act on a message nobody read.
//!
//! Two jobs:
//! - **Parse** inbound `multipart/report; report-type=delivery-status` so a
//! send can be marked failed with a real reason and a real status code.
//! - **Generate** outbound DSNs when `OpenMail` itself must reject or defer.
//!
//! # Bounce loops
//!
//! A DSN has a null envelope sender (`MAIL FROM:<>`). Generating a DSN *for* a
//! DSN is how mail servers melt down. [`should_notify`] is the single gate and
//! it is pure, so the loop condition is testable without a mail server.
#![doc(html_root_url = "https://docs.rs/mail-dsn/0.1.0")]
/// The action reported for one recipient (RFC 3464 §2.3.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Failed,
Delayed,
Delivered,
Relayed,
Expanded,
}
/// An RFC 3463 enhanced status code, e.g. `5.1.1`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StatusCode {
/// 2 = success, 4 = transient, 5 = permanent.
pub class: u8,
pub subject: u16,
pub detail: u16,
}
impl StatusCode {
/// Permanent failure — the send should not be retried.
#[must_use]
pub const fn is_permanent(self) -> bool {
self.class == 5
}
}
/// One recipient's outcome within a report.
#[derive(Debug, Clone)]
pub struct Recipient {
pub final_recipient: String,
pub action: Action,
pub status: StatusCode,
/// The remote server's verbatim response, when present. Worth surfacing to
/// an agent — it is usually the only actionable text in the whole report.
pub diagnostic: Option<String>,
}
/// A parsed delivery status notification.
#[derive(Debug, Clone)]
pub struct Report {
pub reporting_mta: Option<String>,
pub original_envelope_id: Option<String>,
pub recipients: Vec<Recipient>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("not a delivery-status report")]
NotAReport,
#[error("malformed report: {0}")]
Malformed(String),
}
/// May we generate a DSN in response to this message?
///
/// False for a null return-path (the message is itself a bounce), for
/// `Auto-Submitted:` anything but `no`, and for list mail — the three ways a
/// notifier turns into a loop.
#[must_use]
pub fn should_notify(
return_path: &str,
auto_submitted: Option<&str>,
list_id: Option<&str>,
) -> bool {
if return_path.trim() == "<>" || return_path.trim().is_empty() {
return false;
}
if let Some(a) = auto_submitted
&& !a.trim().eq_ignore_ascii_case("no")
{
return false;
}
list_id.is_none()
}
/// Parse a `multipart/report` message into a [`Report`].
///
/// # Errors
/// [`Error::NotAReport`] if the top-level type is not
/// `multipart/report; report-type=delivery-status`.
pub fn parse(_raw: &[u8]) -> Result<Report, Error> {
todo!("v0.2")
}
#[cfg(test)]
mod tests {
use super::should_notify;
#[test]
fn never_bounces_a_bounce() {
assert!(!should_notify("<>", None, None));
assert!(!should_notify("", None, None));
}
#[test]
fn never_bounces_automation_or_lists() {
assert!(!should_notify("a@b.com", Some("auto-replied"), None));
assert!(!should_notify("a@b.com", None, Some("<l.example.com>")));
assert!(should_notify("a@b.com", Some("no"), None));
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "mail-mta-sts"
description = "MTA-STS (RFC 8461) policy discovery, fetch, parse and cache."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
hickory-resolver.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
+94
View File
@@ -0,0 +1,94 @@
//! MTA-STS — RFC 8461.
//!
//! There is **no MTA-STS crate on crates.io** at the time of writing. Stalwart
//! implements it in AGPL server crates. This is the permissive implementation.
//!
//! MTA-STS is DANE's non-DNSSEC cousin: a domain publishes a TXT record naming
//! a policy `id`, and serves the policy itself over HTTPS at
//! `https://mta-sts.<domain>/.well-known/mta-sts.txt`. The HTTPS certificate is
//! what makes the policy trustworthy — so **the fetch must use full `WebPKI`
//! validation with no exceptions**, and a policy fetched over a connection
//! whose certificate failed validation must be discarded, not cached.
//!
//! # Caching is the correctness problem
//!
//! The `max_age` in a policy can be a year. A cached `enforce` policy that is
//! wrong will silently defer a domain's mail for as long as it is cached, and
//! nothing in the sending path will look broken. So:
//!
//! - a policy is cached only after a fully validated HTTPS fetch;
//! - the TXT `id` changing invalidates the cache immediately;
//! - a fetch failure **never** evicts a valid cached policy (RFC 8461 §5.1) —
//! an attacker who can block HTTPS must not be able to strip the policy.
#![doc(html_root_url = "https://docs.rs/mail-mta-sts/0.1.0")]
/// What the domain asks senders to do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Deliver only over a validated TLS connection to a listed MX. On failure,
/// **defer** — never fall back to cleartext.
Enforce,
/// Behave as `Enforce` but deliver anyway on failure, reporting via TLS-RPT.
Testing,
/// Policy withdrawn. Cached policies for this domain must be dropped.
None,
}
/// A parsed policy.
#[derive(Debug, Clone)]
pub struct Policy {
pub mode: Mode,
/// MX patterns, which may contain a single leading `*.` wildcard.
pub mx: Vec<String>,
/// Seconds this policy may be cached. RFC 8461 caps meaningful values at
/// `31_557_600` (one year).
pub max_age: u32,
/// The `id` from the DNS TXT record this policy was fetched for.
pub id: String,
}
impl Policy {
/// Does `host` satisfy this policy's MX patterns?
///
/// Wildcards match exactly one label (`*.example.com` matches
/// `mx.example.com` but not `a.mx.example.com`), per RFC 8461 §4.1.
#[must_use]
pub fn allows_mx(&self, _host: &str) -> bool {
todo!("v0.2")
}
}
/// The outcome of applying MTA-STS to one delivery attempt. As in
/// [`mail_dane`](https://docs.rs/mail-dane), every case is explicit — there is
/// no boolean that could be read as "fine".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StsResult {
/// An `enforce` policy is in effect and this MX + TLS chain satisfies it.
Enforced,
/// A `testing` policy failed. Deliver, but emit a TLS-RPT failure.
TestingFailure,
/// An `enforce` policy is in effect and was **not** satisfied. Defer.
Violation,
/// No policy published. Fall back to opportunistic TLS.
NotApplicable,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("DNS lookup failed: {0}")]
Dns(String),
#[error("policy fetch failed: {0}")]
Fetch(String),
#[error("malformed policy: {0}")]
Malformed(String),
}
/// Parse the body of an `mta-sts.txt` policy file.
///
/// # Errors
/// Returns [`Error::Malformed`] on a missing `version`, unknown `mode`, absent
/// `mx` for an enforcing policy, or unparseable `max_age`.
pub fn parse_policy(_body: &str, _id: &str) -> Result<Policy, Error> {
todo!("v0.2")
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "openmail-api"
description = "The v0 REST API. Bearer auth, agent-shaped resources."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
openmail-core.workspace = true
openmail-store.workspace = true
axum.workspace = true
tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
thiserror.workspace = true
uuid.workspace = true
+31
View File
@@ -0,0 +1,31 @@
//! The v0 REST API.
//!
//! Bearer auth, agent-shaped resources. Paths are kept close to the shape
//! existing agent-mail tooling expects, so a client can be pointed at a
//! self-hosted `OpenMail` with a base-URL swap. Where compatibility and a clean
//! native shape conflict, the native shape wins and the difference is
//! documented.
//!
//! ```text
//! POST /v0/inboxes
//! GET /v0/inboxes list
//! GET /v0/inboxes/{id}
//! POST /v0/inboxes/{id}/messages/send
//! GET /v0/inboxes/{id}/messages limit, page_token, labels
//! GET /v0/inboxes/{id}/messages/{mid}
//! POST /v0/inboxes/{id}/messages/{mid}/reply
//! GET /v0/inboxes/{id}/threads
//! GET /v0/inboxes/{id}/threads/{tid}
//! ```
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unauthorized")]
Unauthorized,
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error(transparent)]
Store(#[from] openmail_store::Error),
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "openmail-core"
description = "The agent-native domain model: inboxes, threads, messages, drafts, extraction."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
mail-parser.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
thiserror.workspace = true
+47
View File
@@ -0,0 +1,47 @@
//! Quoted-history stripping.
//!
//! The single most valuable transform in the product, and the least glamorous.
//! A five-turn thread's last message is ~90% text the agent has already read;
//! sending it whole wastes context on every turn.
//!
//! Heuristic, not a parser — there is no standard for quoting. The rule is
//! **prefer under-stripping to over-stripping**: losing the new content is
//! unrecoverable, keeping some quoted lines merely costs tokens.
/// Strip quoted history from a plain-text body.
///
/// Handles `>` quoting, `On <date>, <person> wrote:` attributions, Outlook's
/// `-----Original Message-----`, and common signature delimiters.
#[must_use]
pub fn strip_quoted(_text: &str) -> String {
todo!("v0.1 — the first real algorithm in this crate")
}
/// A short preview for listings: the first meaningful line of the extracted
/// text, whitespace-collapsed, truncated on a character boundary.
#[must_use]
pub fn preview(extracted: &str, max: usize) -> String {
let collapsed: String = extracted.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= max {
return collapsed;
}
let end = collapsed
.char_indices()
.nth(max)
.map_or(collapsed.len(), |(i, _)| i);
format!("{}", &collapsed[..end])
}
#[cfg(test)]
mod tests {
use super::preview;
#[test]
fn preview_truncates_on_char_boundaries() {
// A naive &s[..max] panics here. Emoji and accented text are ordinary
// in real mail, so this is a correctness test, not a curiosity.
assert_eq!(preview("héllo wörld 🎉 and more", 13), "héllo wörld 🎉…");
assert_eq!(preview("short", 99), "short");
assert_eq!(preview(" a\n\n b ", 99), "a b");
}
}
+108
View File
@@ -0,0 +1,108 @@
//! The agent-native domain model.
//!
//! This crate is the product. Everything else in the workspace either moves
//! mail into it or serves it out. It is deliberately free of I/O — no database,
//! no network — so threading and extraction are testable as pure functions.
//!
//! # What "agent-native" means concretely
//!
//! Four differences from an IMAP-shaped model:
//!
//! 1. **Inboxes are API resources**, provisioned in one call, not Unix accounts.
//! 2. **Threads are first-class**, stitched from `In-Reply-To`/`References` —
//! an agent asks for a conversation, not a folder listing.
//! 3. **[`Message::extracted_text`]** is the reply with quoted history removed.
//! An agent that reads the full body re-reads the entire thread on every
//! turn and burns its context window on text it already has.
//! 4. **Events are pushed**, not polled.
use chrono::{DateTime, Utc};
use uuid::Uuid;
pub mod extract;
pub mod thread;
/// A tenant. Present from v0.1 even though v0.1 is single-tenant: retrofitting
/// tenancy into a schema is far more expensive than carrying an unused column,
/// and it is what makes a future hosted offering possible without a migration.
#[derive(Debug, Clone)]
pub struct Pod {
pub id: Uuid,
pub name: String,
pub created_at: DateTime<Utc>,
}
/// A mailbox an agent owns.
#[derive(Debug, Clone)]
pub struct Inbox {
pub id: Uuid,
pub pod_id: Uuid,
pub address: String,
pub display_name: Option<String>,
pub metadata: serde_json::Value,
pub created_at: DateTime<Utc>,
}
/// Inbound authentication verdicts, recorded at receipt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AuthVerdicts {
pub spf: Option<Verdict>,
pub dkim: Option<Verdict>,
pub dmarc: Option<Verdict>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Pass,
Fail,
SoftFail,
Neutral,
None,
TempError,
PermError,
}
#[derive(Debug, Clone)]
pub struct Message {
pub id: Uuid,
pub inbox_id: Uuid,
pub thread_id: Uuid,
/// The `Message-ID` header, which is *not* our `id` and is not unique in
/// practice — never key on it.
pub message_id_hdr: Option<String>,
pub in_reply_to: Option<String>,
pub references: Vec<String>,
pub from_addr: String,
pub to_addrs: Vec<String>,
pub cc: Vec<String>,
pub subject: Option<String>,
pub text: Option<String>,
pub html: Option<String>,
/// The new content only, quoted history stripped. See [`extract`].
pub extracted_text: Option<String>,
pub auth: AuthVerdicts,
pub junk_score: Option<f32>,
pub labels: Vec<String>,
/// Pointer to the raw `.eml` in object storage. The row never holds it.
pub raw_object_key: String,
pub size_bytes: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct Thread {
pub id: Uuid,
pub inbox_id: Uuid,
pub subject: Option<String>,
pub message_count: i32,
pub labels: Vec<String>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid address: {0}")]
InvalidAddress(String),
#[error("parse failed: {0}")]
Parse(String),
}
+64
View File
@@ -0,0 +1,64 @@
//! Threading.
//!
//! Resolve a message into a conversation using `In-Reply-To` and `References`,
//! falling back to normalised subject + participants inside a time window.
//!
//! The fallback is where threading goes wrong. Two unrelated messages titled
//! "Invoice" from the same sender are not a thread; a reply whose client
//! dropped `References` is. The window exists to make the wrong answer
//! bounded rather than permanent.
use uuid::Uuid;
/// How a thread id was arrived at — recorded so a mis-thread can be diagnosed
/// later without re-deriving it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Basis {
/// Matched via `In-Reply-To` or `References`. Authoritative.
Headers,
/// Matched via normalised subject + participants within the window. A guess.
SubjectHeuristic,
/// No match; this message starts a thread.
New,
}
#[derive(Debug, Clone)]
pub struct Resolution {
pub thread_id: Uuid,
pub basis: Basis,
}
/// Strip reply/forward prefixes for heuristic matching: `Re:`, `RE:`, `Fwd:`,
/// `FW:`, and their common localised forms, repeatedly and case-insensitively.
#[must_use]
pub fn normalize_subject(subject: &str) -> String {
const PREFIXES: &[&str] = &["re:", "fwd:", "fw:", "aw:", "sv:", "vs:", "rif:", "res:"];
let mut s = subject.trim();
'outer: loop {
for p in PREFIXES {
if s.len() >= p.len() && s[..p.len()].eq_ignore_ascii_case(p) {
s = s[p.len()..].trim_start();
continue 'outer;
}
}
break;
}
s.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::normalize_subject;
#[test]
fn strips_stacked_and_localised_prefixes() {
assert_eq!(normalize_subject("Re: Fwd: RE: Invoice"), "invoice");
assert_eq!(normalize_subject("AW: Rechnung"), "rechnung");
assert_eq!(normalize_subject(" Invoice "), "invoice");
}
#[test]
fn does_not_eat_a_subject_that_merely_starts_with_re() {
assert_eq!(normalize_subject("Renewal notice"), "renewal notice");
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "openmail-guard"
description = "Inbound abuse gate: iprev, DNSBL, and rate limiting."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
hickory-resolver.workspace = true
thiserror.workspace = true
tracing.workspace = true
tokio.workspace = true
+30
View File
@@ -0,0 +1,30 @@
//! Inbound abuse gate — the first thing an unauthenticated connection meets.
//!
//! Three cheap checks, in increasing cost order, run before a message is
//! accepted or parsed: connection rate limit, DNSBL lookup, and `iprev`
//! (forward-confirmed reverse DNS). Ordering is deliberate — never spend a DNS
//! round trip on a connection a counter can reject.
//!
//! Every check returns a [`Judgement`] rather than a bool, because "we could
//! not tell" (DNS timeout) must not be silently equivalent to "clean".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Judgement {
Clean,
/// Reject now, with this SMTP response.
Reject {
code: u16,
text: String,
},
/// Accept but weight toward junk.
Suspicious(String),
/// The check itself failed. Fail *open* for DNS errors — a resolver outage
/// must not become a mail outage — but record it.
Indeterminate(String),
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("resolver error: {0}")]
Resolver(String),
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "openmail-junk"
description = "Per-inbox Bayesian spam classifier with trainable, persistable state."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
thiserror.workspace = true
serde.workspace = true
sha2.workspace = true
+26
View File
@@ -0,0 +1,26 @@
//! Per-inbox Bayesian spam classification.
//!
//! Per-inbox, not global: an agent mailbox that only ever receives webhook
//! receipts has a radically different prior than a human's. A shared corpus
//! makes both worse.
//!
//! The classifier state must be persistable and versioned — a model that
//! cannot be rolled back is a model that can silently start eating real mail.
/// A score in `[0.0, 1.0]`; higher is more likely junk.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Score(pub f32);
impl Score {
/// Conventional threshold. Deliberately not a global constant used for
/// filing decisions — the caller owns policy, this crate owns the number.
pub const LIKELY_JUNK: f32 = 0.9;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("corpus not trained")]
Untrained,
#[error("state version {found} is not readable by this build (expects {expected})")]
VersionMismatch { found: u32, expected: u32 },
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "openmail-mcp"
description = "MCP server: an agent owns and operates its own mailbox as tools."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
openmail-core.workspace = true
openmail-store.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
thiserror.workspace = true
+30
View File
@@ -0,0 +1,30 @@
//! MCP server — the thing nobody else has.
//!
//! A thin front-end over [`openmail_core`] that lets an agent own and operate
//! its own mailbox as tools: `create_inbox`, `list_messages`, `get_thread`,
//! `send_message`, `reply`, `search`.
//!
//! # The rule that keeps this safe
//!
//! Only routes that explicitly opt in become tools, every call re-checks the
//! caller's scopes, and credential or key-management routes can **never** be
//! exposed as tools regardless of opt-in. An agent may read and send its own
//! mail; it may not mint itself a wider key.
/// Marker for a route's MCP exposure. Absence of an opt-in is a refusal, not a
/// default — a new route is invisible to agents until someone says otherwise.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Exposure {
Tool,
Hidden,
/// Credential-bearing. Never exposable; the type makes it unrepresentable.
NeverExposable,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("tool not found: {0}")]
UnknownTool(String),
#[error("scope denied: {0}")]
ScopeDenied(String),
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "openmail-relay"
description = "Outbound delivery: smarthost relays (SES, OCI, generic) and direct-to-MX."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
mail-dane.workspace = true
mail-mta-sts.workspace = true
mail-auth.workspace = true
mail-builder.workspace = true
smtp-proto.workspace = true
hickory-resolver.workspace = true
tokio.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
+26
View File
@@ -0,0 +1,26 @@
//! Outbound delivery: smarthost relays and direct-to-MX.
//!
//! Two paths behind one interface:
//!
//! - **relay** — hand the message to SES / OCI Email Delivery / any smarthost
//! on submission (587). Someone else's IP reputation. Works everywhere,
//! including hosts that block outbound :25.
//! - **direct** — resolve MX, apply [`mail_mta_sts`] and [`mail_dane`], deliver
//! ourselves. Our reputation, our control, and impossible on a host that
//! blocks outbound :25 (see `docs/adr/0005-oracle-cloud.md`).
pub mod providers;
pub use providers::{PROVIDERS, RelayProvider, provider, resolve_host, spf_include};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("no usable MX for {0}")]
NoMx(String),
#[error("relay rejected: {code} {text}")]
Rejected { code: u16, text: String },
#[error("TLS policy violation: {0}")]
TlsPolicy(String),
#[error("transient failure, retry: {0}")]
Transient(String),
}
+181
View File
@@ -0,0 +1,181 @@
//! Smarthost providers, as **data**.
//!
//! # Why this is a table and not an enum with special cases
//!
//! Openship shipped a `provider: "ses" | "custom"` union and every non-SES
//! provider collapsed into `custom` the moment it was saved: no SPF include,
//! no round-trip in the UI, and adding a provider meant editing an `if` in the
//! service, the DNS builder, and the scanner. We start where they ended up.
//!
//! # `spf_include` is deliberately absent for some providers
//!
//! Where the SPF token is account- or region-scoped, publishing a *guessed*
//! include is worse than publishing none: the DNS check goes green against a
//! mechanism the provider does not honour, and mail still fails SPF — silently.
//! Those providers get `None` and the operator supplies theirs.
/// Everything that differs between smarthosts, as inert data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RelayProvider {
pub id: &'static str,
pub label: &'static str,
/// `{region}` is substituted when `regional`. `None` = operator supplies it.
pub host_template: Option<&'static str>,
/// The host template needs a region before it resolves.
pub regional: bool,
pub default_port: u16,
/// The SPF mechanism every relayed domain must publish. `None` where the
/// token is account/region-scoped — see the module docs.
pub spf_include: Option<&'static str>,
/// SASL username the provider mandates. Prefilled, still editable.
pub username: Option<&'static str>,
/// The provider issues DKIM CNAMEs pasted from its console. We also sign
/// locally, so these are the provider's identity records, not our keys.
pub provider_dkim: bool,
}
const fn p(id: &'static str, label: &'static str) -> RelayProvider {
RelayProvider {
id,
label,
host_template: None,
regional: false,
default_port: 587,
spf_include: None,
username: None,
provider_dkim: false,
}
}
/// The known smarthosts. `custom` is last and is the fallback for any
/// unrecognised id — see [`provider`].
pub static PROVIDERS: &[RelayProvider] = &[
RelayProvider {
host_template: Some("email-smtp.{region}.amazonaws.com"),
regional: true,
spf_include: Some("include:amazonses.com"),
provider_dkim: true,
..p("ses", "Amazon SES")
},
RelayProvider {
// OCI Email Delivery's SPF include is region-scoped
// (rp / eu.rp / ap.rp .oracleemaildelivery.com) — the operator pastes
// theirs. Guessing one is how mail silently fails SPF.
host_template: Some("smtp.email.{region}.oci.oraclecloud.com"),
regional: true,
provider_dkim: true,
..p("oracle", "Oracle Cloud Email Delivery")
},
RelayProvider {
host_template: Some("smtp.sendgrid.net"),
spf_include: Some("include:sendgrid.net"),
username: Some("apikey"),
provider_dkim: true,
..p("sendgrid", "SendGrid")
},
RelayProvider {
host_template: Some("smtp.postmarkapp.com"),
spf_include: Some("include:spf.mtasv.net"),
provider_dkim: true,
..p("postmark", "Postmark")
},
RelayProvider {
// Resend rides SES, but the records it hands out are per-account —
// do not assume the SES include.
host_template: Some("smtp.resend.com"),
username: Some("resend"),
provider_dkim: true,
..p("resend", "Resend")
},
CUSTOM,
];
/// The fallback. Named so [`provider`] can return it without an unwrap — an
/// infallible lookup should not be able to panic, even in principle.
pub const CUSTOM: RelayProvider = p("custom", "Custom SMTP");
/// The spec for an id. An unknown id — state written by a newer version, or a
/// hand-edited config — falls back to `custom`, which requires an explicit
/// host, so the failure surfaces as a clear validation error instead of mail
/// going nowhere.
#[must_use]
pub fn provider(id: &str) -> &'static RelayProvider {
PROVIDERS.iter().find(|p| p.id == id).unwrap_or(&CUSTOM)
}
/// The effective SMTP host, or `None` when the inputs cannot produce one — the
/// caller turns that into the user-facing error, since only it knows which
/// field to blame.
#[must_use]
pub fn resolve_host(id: &str, host_override: Option<&str>, region: Option<&str>) -> Option<String> {
let spec = provider(id);
if spec.regional {
let region = region.map(str::trim).filter(|r| !r.is_empty())?;
return spec.host_template.map(|t| t.replace("{region}", region));
}
host_override
.map(str::trim)
.filter(|h| !h.is_empty())
.map(ToOwned::to_owned)
.or_else(|| spec.host_template.map(ToOwned::to_owned))
}
/// The SPF include to publish: the operator's override first (the only option
/// for account-scoped providers), else the provider's known token, else none.
#[must_use]
pub fn spf_include(id: &str, override_: Option<&str>) -> Option<String> {
override_
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.or_else(|| provider(id).spf_include.map(ToOwned::to_owned))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_provider_falls_back_to_custom() {
assert_eq!(provider("nope").id, "custom");
assert_eq!(provider("").id, "custom");
}
#[test]
fn regional_hosts_need_a_region() {
assert_eq!(resolve_host("ses", None, None), None);
assert_eq!(
resolve_host("ses", None, Some("us-east-1")).as_deref(),
Some("email-smtp.us-east-1.amazonaws.com")
);
assert_eq!(
resolve_host("oracle", None, Some("us-ashburn-1")).as_deref(),
Some("smtp.email.us-ashburn-1.oci.oraclecloud.com")
);
}
#[test]
fn account_scoped_providers_never_guess_an_spf_include() {
// The whole point of the None: Oracle and Resend must not inherit a
// token they do not honour.
assert_eq!(spf_include("oracle", None), None);
assert_eq!(spf_include("resend", None), None);
assert_eq!(
spf_include("ses", None).as_deref(),
Some("include:amazonses.com")
);
assert_eq!(
spf_include("oracle", Some("include:rp.oracleemaildelivery.com")).as_deref(),
Some("include:rp.oracleemaildelivery.com")
);
}
#[test]
fn custom_requires_an_explicit_host() {
assert_eq!(resolve_host("custom", None, None), None);
assert_eq!(
resolve_host("custom", Some("mail.acme.com"), None).as_deref(),
Some("mail.acme.com")
);
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "openmail-smtpd"
description = "Inbound SMTP server: session state machine, STARTTLS, AUTH, pipelining."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
smtp-proto.workspace = true
mail-parser.workspace = true
mail-auth.workspace = true
openmail-guard.workspace = true
tokio.workspace = true
rustls.workspace = true
thiserror.workspace = true
tracing.workspace = true
+42
View File
@@ -0,0 +1,42 @@
//! Inbound SMTP server.
//!
//! `smtp-proto` parses the wire format; everything above it — session state,
//! STARTTLS, AUTH, PIPELINING, SIZE, and the abuse gate — is here. This is the
//! largest single piece of protocol work in the workspace and the one exposed
//! directly to the open internet, so: no `unsafe`, hard limits on every
//! unbounded input, and a timeout on every state.
//!
//! Note that on hosts which block outbound :25 (Oracle Cloud), this listener
//! still works — the block is outbound only. See `docs/adr/0005-oracle-cloud.md`.
/// Hard limits. Every one of these exists because its absence is a `DoS`.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_message_bytes: usize,
pub max_recipients: usize,
pub max_commands_per_session: usize,
pub command_timeout_secs: u64,
pub data_timeout_secs: u64,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_message_bytes: 50 * 1024 * 1024,
max_recipients: 100,
max_commands_per_session: 500,
command_timeout_secs: 300,
data_timeout_secs: 600,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("protocol: {0}")]
Protocol(String),
#[error("limit exceeded: {0}")]
Limit(String),
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "openmail-store"
description = "Postgres metadata + object-store blobs. Migrations embedded."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
openmail-core.workspace = true
sqlx.workspace = true
uuid.workspace = true
chrono.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
+23
View File
@@ -0,0 +1,23 @@
//! Persistence: Postgres for metadata and search, an S3-compatible store for
//! raw `.eml` and attachments.
//!
//! The split is deliberate. Message rows are queried constantly and are small;
//! raw MIME is written once, read rarely, and is arbitrarily large. Keeping
//! blobs out of Postgres is what lets the metadata working set stay in RAM.
//!
//! Migrations are embedded in the binary so a deploy cannot drift from its
//! schema.
pub mod migrations {
//! Embedded SQL migrations. See `crates/openmail-store/migrations/`.
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("database: {0}")]
Db(#[from] sqlx::Error),
#[error("object store: {0}")]
ObjectStore(String),
#[error("not found")]
NotFound,
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "openmail"
description = "OpenMail — agent-native, self-hosted mail server. Single binary."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
openmail-core.workspace = true
openmail-store.workspace = true
openmail-api.workspace = true
openmail-mcp.workspace = true
openmail-smtpd.workspace = true
openmail-relay.workspace = true
clap.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
+47
View File
@@ -0,0 +1,47 @@
//! `OpenMail` — agent-native, self-hosted mail server.
//!
//! One binary, several roles. Deploy together on one box, or split later
//! without changing the build.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "openmail", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Serve the REST API.
Serve,
/// Receive mail on :25.
Smtpd,
/// Drain the outbox: relay or direct-to-MX.
Sender,
/// Serve MCP so an agent can operate its own mailbox.
Mcp,
/// Apply pending database migrations and exit.
Migrate,
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "openmail=info".into()),
)
.init();
let cli = Cli::parse();
match cli.command {
Command::Serve => anyhow::bail!("serve: not yet wired — v0.1 milestone 1"),
Command::Smtpd => {
anyhow::bail!("smtpd: not yet wired — v0.2, see docs/adr/0004-milestones.md")
}
Command::Sender => anyhow::bail!("sender: not yet wired — v0.1 milestone 2"),
Command::Mcp => anyhow::bail!("mcp: not yet wired — v0.2 milestone 3"),
Command::Migrate => anyhow::bail!("migrate: not yet wired — v0.1 milestone 1"),
}
}
+36
View File
@@ -0,0 +1,36 @@
# cargo-deny — the licence policy of ADR 0002 and ADR 0003, enforced.
#
# The whole competitive position is "permissively licensed". A copyleft
# dependency sneaking in via a transitive bump would quietly destroy it, and
# nobody would notice until a lawyer did. So CI fails on it.
[licenses]
version = 2
allow = [
"Apache-2.0",
"MIT",
"MIT-0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-3.0",
"Zlib",
"CC0-1.0",
"MPL-2.0", # file-level copyleft; acceptable as a leaf dependency
"Apache-2.0 WITH LLVM-exception",
]
confidence-threshold = 0.9
# Everything not in `allow` fails — including every GPL, LGPL and AGPL variant.
# That is the point; do not add an exception without amending ADR 0002.
[bans]
multiple-versions = "warn"
[advisories]
version = 2
yanked = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
+68
View File
@@ -0,0 +1,68 @@
# ADR 0001 — Rust, not Go
**Status:** Accepted, 2026-09-02. Supersedes the Go + embed-Mox design in
[`../archive/ARCHITECTURE-go-embed-mox.md`](../archive/ARCHITECTURE-go-embed-mox.md).
## Context
The archived design chose Go in order to embed Mox (MIT) as a library, getting
~14,000 lines of production-tested mail correctness for free:
```
message 2,884 dkim 2,051 spf 1,560 smtpclient 2,012 junk 1,302
mtasts 703 dsn 771 dane 516 scram 796 sasl 327 …
```
The Rust ecosystem does not offer an equivalent. Stalwart is the only
production Rust mail server and its server crates are `AGPL-3.0-only OR
LicenseRef-SEL`, which we cannot use under Apache-2.0.
## What Rust actually costs
Stalwart Labs publishes its *primitives* permissively (Apache-2.0 OR MIT), and
those cover more than expected: `mail-parser` (MIME), `mail-auth` (DKIM1,
**DKIM2**, ARC, SPF, DMARC, ARF, TLS-RPT), `mail-builder`, `mail-send`,
`smtp-proto`. `hickory-resolver` covers DNS and DNSSEC.
What no permissive Rust crate provides, and we therefore write:
| | LOC (Mox equivalent) | Rust prior art |
|---|---|---|
| DANE | ~516 | **none on crates.io** |
| MTA-STS | ~703 | **none on crates.io** |
| SMTP server session loop | ~3,395 (`go-smtp`) | `smtp-proto` parses only |
| DSN | ~771 | none |
| Junk (beyond a toy) | ~1,302 | `bayespam` has no training persistence |
| iprev / DNSBL / rate limit | ~370 | `dnsbl` crate abandoned since 2021 |
**~5,500 lines of adversarial protocol code**, versus zero in Go.
## Decision
**Rust.** Accepted with eyes open.
## Consequences
Negative, and stated plainly so nobody is surprised later:
- v1 is roughly a quarter further out.
- DANE and MTA-STS move from *battle-tested* to *ours*, and both **fail
silently**: a DANE bug downgrades TLS without erroring; an MTA-STS bug defers
mail nobody sees. That tail does not close at ship — it closes after enough
strangers' mail has flowed through it.
- Mitigation: every outcome in those crates is an explicit enum with no
`Default` and no `bool`, so a caller cannot accidentally read "no policy" as
"verified". See `mail_dane::DaneResult`.
Positive:
- We ship the first permissively licensed DANE and MTA-STS in Rust, and the
first permissively licensed Rust mail server.
- `mail-auth` gives us DKIM2 and ARC, which Mox does not have.
- One language for the mail engine and the agent layer.
## Rejected alternative
**Go now, Rust later**, with the two crates published early to plant the flag
at low cost. Rejected: it puts the strategic position — "the permissive Rust
agent mail server" — behind a rewrite that would probably never be scheduled.
+46
View File
@@ -0,0 +1,46 @@
# ADR 0002 — Apache-2.0
**Status:** Accepted, 2026-09-02. Supersedes the MIT choice in the archived
architecture (§8).
## Decision
**Apache-2.0**, and the repository is public on GitHub
(`karti-ai/openmail`). The Gitea mirror (`OSS/openmail`) stays as the fallback
if the project ever needs to go private.
## Why not MIT
Same freedoms, but Apache-2.0 adds three things that matter here:
- **§3, express patent grant.** Email authentication is a standards thicket —
DKIM, DKIM2, ARC, DMARC. MIT's patent grant is implicit at best, and that is
what enterprise legal review flags.
- **§6, trademark reservation.** "OpenMail" is a generic name with at least
three unrelated projects already using it. Apache-2.0 protects the name while
the code stays free.
- **§5, contributor terms.** Inbound contributions are licensed on the same
terms without a separate CLA.
Inbound compatibility is clean: every dependency is Apache-2.0 or MIT.
## Why not AGPL
AGPL + a commercial exception is the standard way to protect a future hosted
offering — it is exactly what Stalwart does (`AGPL-3.0-only OR
LicenseRef-SEL`). We reject it because it makes us unusable by the commercial
agent builders who are the intended audience, and because being *the*
permissive option is the entire competitive position. Stalwart's AGPL is the
reason its competitors must run it in a sidecar; we do not want to be that for
someone else.
## Consequences
- "No GPL/AGPL/LGPL anywhere" remains policy, but the *reason* changed. Under
MIT it was a compatibility fact; under Apache-2.0 it is a deliberate choice,
since Apache-2.0 is one-way-incompatible with GPL-2-only. Enforced in CI by
`cargo-deny`.
- Every source file gets no licence header (the `LICENSE` + `NOTICE` pair is
sufficient and headers rot); `NOTICE` must be shipped with any redistribution
and lists third-party attribution.
- Anyone may fork this closed. That is the intent, not a leak.
+34
View File
@@ -0,0 +1,34 @@
# ADR 0003 — Write our own crates; use Stalwart's primitives, never its server
**Status:** Accepted, 2026-09-02.
## The licence boundary
Stalwart Labs ships two distinct things:
| | Licence | Us |
|---|---|---|
| The **server** (`stalwartlabs/stalwart`, `crates/*`) | `AGPL-3.0-only OR LicenseRef-SEL` | ❌ never |
| The **primitives** (`mail-parser`, `mail-auth`, `mail-builder`, `mail-send`, `smtp-proto`) | `Apache-2.0 OR MIT` | ✅ dependencies |
DANE and MTA-STS live in `crates/smtp` and `crates/common` — **on the AGPL side
of that line.** That is precisely why we write our own.
## Rule
- Depending on the permissive primitive crates is fine and intended.
- Reading the AGPL server crates for *understanding* is fine.
- Copying, adapting or transliterating any line from them is **not**, and would
contaminate the whole workspace. When implementing DANE or MTA-STS, work from
the RFCs (7672, 8461, 6698) — not from `stalwart/crates/smtp`.
- The research mirror at `~/Desktop/ProjectMail/mail-servers/stalwart` is
read-only reference. Same for `maddy` (GPL-3) and `BillionMail` (AGPL).
## Which of our crates get published
Tier 1 (`mail-dane`, `mail-mta-sts`, `mail-dsn`) are published standalone: they
depend on nothing in this workspace, they fill real holes in the ecosystem, and
their value to us is partly that other projects audit them. Names verified
available on crates.io 2026-09-02, as is `openmail` itself — reserve early.
Tier 2 and 3 stay in-workspace until their APIs settle.
+51
View File
@@ -0,0 +1,51 @@
# ADR 0004 — Milestones
**Status:** Accepted, 2026-09-02.
Reordered from the archived Go plan. The original put embedded inbound at
milestone 4 and treated it as optional-until-later; ADR 0005 makes it
**required at launch**, because the launch host cannot send direct-to-MX at all.
## v0.1 — the agent layer, provable without mail
Nothing here needs a working mail server, which is the point: it is all
testable in CI.
- [x] Apache-2.0 workspace, 12 crates, `cargo check` green
- [x] `openmail-relay` provider table (SES, Oracle, SendGrid, Postmark, Resend, custom)
- [ ] `openmail-core::extract::strip_quoted` — the first real algorithm
- [ ] `openmail-core::thread` resolution, both bases
- [ ] `openmail-store` — Postgres schema, embedded migrations, S3 blobs
- [ ] `openmail-api` — v0 REST, bearer auth
- [ ] Ingest endpoint: POST a raw `.eml` and get a threaded, extracted message
back. Closes the loop with **zero mail infrastructure.**
## v0.2 — receive
- [ ] `openmail-smtpd` on :25, real MX for a test domain
- [ ] `openmail-guard` gate, `mail-auth` SPF/DKIM/DMARC verdicts recorded
- [ ] `openmail-junk` scoring
- [ ] `message.received` webhooks + WebSocket
## v0.3 — send, and the MCP surface
- [ ] Relay send via SES and Oracle, DKIM-signed locally
- [ ] `mail-dsn` bounce handling wired to outbox state
- [ ] `openmail-mcp` — an agent creates an inbox, receives, and replies, alone
## v0.4 — direct-to-MX, and the crates ship
- [ ] `mail-dane` and `mail-mta-sts` complete, **published to crates.io**
- [ ] Direct MX delivery with both enforced
- [ ] IP warmup, FBL enrolment, suppression lists
## v1.0
- [ ] IMAP front-end so humans use their own client against the same mailbox
- [ ] Multi-tenancy beyond `pods`
- [ ] Deliverability track record worth publishing
## Not in v1
A hosted SaaS, billing, or a webmail UI. `pods` exists so the SaaS path stays
open architecturally — do not remove it as unused.
+44
View File
@@ -0,0 +1,44 @@
# ADR 0005 — Oracle Cloud as the launch host, and what it forbids
**Status:** Accepted, 2026-09-02.
## The constraint
**OCI blocks outbound TCP/25 for every tenancy created after 2021-06-23.**
Exemption is a service-limit request, routinely refused for free tier. Inbound
:25 is *not* blocked.
| | On OCI |
|---|---|
| Receive on :25 | ✅ works — `openmail-smtpd` is fine |
| Relay out on 587 | ✅ works (verify: see below) |
| Direct-to-MX | ❌ **impossible.** `mail-dane` and `mail-mta-sts` can never run there |
## Decision
Launch on OCI in **split delivery**: receive directly, relay outbound. Support
Oracle Cloud Email Delivery *and* SES as relay providers from day one — two
providers at launch forces the provider abstraction to be genuinely
data-driven instead of an SES-shaped `if`.
Direct-to-MX (v0.4) is developed and tested on a host without the block.
## Consequences
- Embedded inbound moves from "milestone 4, later" to **required at launch**.
- The relay path is not a temporary on-ramp; on our own launch host it is the
only outbound path that exists.
- Oracle's SPF include is region-scoped (`rp` / `eu.rp` / `ap.rp`
`.oracleemaildelivery.com`), so `providers.rs` deliberately stores `None` and
makes the operator paste theirs. A guessed include turns the DNS check green
against a mechanism the provider does not honour and mail still fails SPF —
silently. Same for Resend.
## ⚠️ Open — verify before committing to the host
Oracle's docs name only port 25. Mailcow community reports claim **587 and 2525
are also blocked outbound on free tier**, which would make OCI unable to relay
either, and would change the host choice entirely.
**Test on a free instance before building on this.** It is a 20-minute check
and it invalidates this ADR if the reports are right.
-30
View File
@@ -1,30 +0,0 @@
module github.com/karti-ai/openmail
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/jackc/pgx/v5 v5.10.0
github.com/mjl-/mox v0.0.15
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea // indirect
github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978 // indirect
github.com/prometheus/client_golang v1.18.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)
-60
View File
@@ -1,60 +0,0 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea h1:8dftsVL1tHhRksXzFZRhSJ7gSlcy/t87Nvucs3JnTGE=
github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea/go.mod h1:rWZMqGA2HoBm5b5q/A5J8u1sSVuEYh6zBz9tMoVs+RU=
github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978 h1:Eg5DfI3/00URzGErujKus6a3O0kyXzF8vjoDZzH/gig=
github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978/go.mod h1:QBkFtjai3AiQQuUu7pVh6PA06Vd3oa68E+vddf/UBOs=
github.com/mjl-/mox v0.0.15 h1:C3VDXwN33fEI5WTCuBEKZ6KSVh91aNMrFaFLM72ZU4M=
github.com/mjl-/mox v0.0.15/go.mod h1:Ebxm9+lCApzfS1XSmTISluQGjOVhpa7jesEOYGxEVZE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-30
View File
@@ -1,30 +0,0 @@
package api
import (
"crypto/subtle"
"net/http"
"strings"
)
// bearerAuth is a bootstrap bearer-token gate against the configured admin
// token. Milestone 1+: replace with DB-backed api_keys lookup (hash compare,
// per-pod scoping). Until OPENMAIL_ADMIN_TOKEN is set, the API is closed.
func (s *Server) bearerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "auth_unconfigured",
"message": "OPENMAIL_ADMIN_TOKEN not set; API is closed",
})
return
}
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, prefix) ||
subtle.ConstantTimeCompare([]byte(strings.TrimPrefix(h, prefix)), []byte(s.cfg.AdminToken)) != 1 {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
next.ServeHTTP(w, r)
})
}
-250
View File
@@ -1,250 +0,0 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/karti-ai/openmail/internal/core"
"github.com/karti-ai/openmail/internal/mail"
)
const maxIngestBytes = 30 << 20 // 30 MiB raw message cap
func (s *Server) createInbox(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
var body struct {
Address string `json:"address"`
DisplayName *string `json:"display_name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Address == "" {
writeErr(w, http.StatusBadRequest, "invalid_request", "address is required")
return
}
ib, err := s.core.CreateInbox(r.Context(), s.podID, body.Address, body.DisplayName)
if handleErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, ib)
}
func (s *Server) listInboxes(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
list, err := s.core.ListInboxes(r.Context(), s.podID)
if handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"inboxes": list})
}
func (s *Server) getInbox(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
ib, err := s.core.GetInbox(r.Context(), chi.URLParam(r, "id"))
if handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, ib)
}
// ingest accepts a raw RFC 5322 message body and stores it in the inbox — the
// milestone-1 seed path, and the same path a MailBackend uses for real inbound.
func (s *Server) ingest(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
inboxID := chi.URLParam(r, "id")
if _, err := s.core.GetInbox(r.Context(), inboxID); handleErr(w, err) {
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, maxIngestBytes))
if err != nil || len(raw) == 0 {
writeErr(w, http.StatusBadRequest, "invalid_request", "raw message body required")
return
}
msg, err := s.core.IngestRaw(r.Context(), inboxID, raw)
if handleErr(w, err) {
return
}
writeJSON(w, http.StatusCreated, msg)
}
func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
list, err := s.core.ListMessages(r.Context(), chi.URLParam(r, "id"), limit)
if handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"messages": list})
}
func (s *Server) getMessage(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
m, err := s.core.GetMessage(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "msgID"))
if handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, m)
}
func (s *Server) listThreads(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
list, err := s.core.ListThreads(r.Context(), chi.URLParam(r, "id"))
if handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"threads": list})
}
func (s *Server) getThread(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
inboxID := chi.URLParam(r, "id")
threadID := chi.URLParam(r, "threadID")
t, err := s.core.GetThread(r.Context(), inboxID, threadID)
if handleErr(w, err) {
return
}
msgs, err := s.core.GetThreadMessages(r.Context(), inboxID, threadID)
if handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"thread": t, "messages": msgs})
}
func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
inboxID := chi.URLParam(r, "id")
ib, err := s.core.GetInbox(r.Context(), inboxID)
if handleErr(w, err) {
return
}
var body struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.To) == 0 {
writeErr(w, http.StatusBadRequest, "invalid_request", "at least one 'to' recipient is required")
return
}
s.dispatch(w, r, &mail.OutgoingMessage{
InboxID: inboxID, From: ib.Address,
To: body.To, Cc: body.Cc, Bcc: body.Bcc,
Subject: body.Subject, Text: body.Text, HTML: body.HTML,
})
}
func (s *Server) replyMessage(w http.ResponseWriter, r *http.Request) {
if !s.requireCore(w) {
return
}
inboxID := chi.URLParam(r, "id")
ib, err := s.core.GetInbox(r.Context(), inboxID)
if handleErr(w, err) {
return
}
orig, err := s.core.GetMessage(r.Context(), inboxID, chi.URLParam(r, "msgID"))
if handleErr(w, err) {
return
}
var body struct {
Text string `json:"text"`
HTML string `json:"html"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_request", "invalid body")
return
}
out := &mail.OutgoingMessage{
InboxID: inboxID, From: ib.Address,
Subject: replySubject(orig.Subject),
Text: body.Text, HTML: body.HTML,
References: append(append([]string{}, orig.References...), strPtr(orig.MessageIDHdr)...),
}
if orig.MessageIDHdr != nil {
out.InReplyTo = *orig.MessageIDHdr
}
if orig.FromAddr != nil {
out.To = []string{*orig.FromAddr}
}
s.dispatch(w, r, out)
}
// dispatch hands an outgoing message to the active backend, translating the
// NullBackend's ErrNotSupported into a clear 503 (no send path configured yet).
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, out *mail.OutgoingMessage) {
res, err := s.backend.Send(r.Context(), out)
if errors.Is(err, mail.ErrNotSupported) {
writeErr(w, http.StatusServiceUnavailable, "send_unavailable",
"no send-capable mail backend configured (relay/imap_smtp/embedded land in later milestones)")
return
}
if err != nil {
writeErr(w, http.StatusBadGateway, "send_failed", err.Error())
return
}
writeJSON(w, http.StatusAccepted, res)
}
func handleErr(w http.ResponseWriter, err error) bool {
var pgErr *pgconn.PgError
switch {
case err == nil:
return false
case errors.Is(err, core.ErrNotFound):
writeErr(w, http.StatusNotFound, "not_found", "resource not found")
case errors.As(err, &pgErr) && pgErr.Code == "22P02":
// invalid_text_representation, e.g. a malformed UUID in the path — treat
// as not found rather than a 500 that echoes the driver error.
writeErr(w, http.StatusNotFound, "not_found", "resource not found")
case errors.As(err, &pgErr) && pgErr.Code == "23505":
// unique_violation, e.g. an inbox address that already exists.
writeErr(w, http.StatusConflict, "conflict", "resource already exists")
default:
writeErr(w, http.StatusInternalServerError, "internal_error", "internal error")
}
return true
}
func replySubject(s *string) string {
if s == nil || strings.TrimSpace(*s) == "" {
return "Re:"
}
trimmed := strings.TrimSpace(*s)
if strings.HasPrefix(strings.ToLower(trimmed), "re:") {
return trimmed
}
return "Re: " + trimmed
}
func strPtr(s *string) []string {
if s == nil || *s == "" {
return nil
}
return []string{*s}
}
-101
View File
@@ -1,101 +0,0 @@
// Package api is OpenMail's agent-facing HTTP surface: the AgentMail-shaped v0
// REST API (see ARCHITECTURE.md §4).
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/karti-ai/openmail/internal/config"
"github.com/karti-ai/openmail/internal/core"
"github.com/karti-ai/openmail/internal/mail"
)
type Server struct {
cfg config.Config
core *core.Service // nil when DATABASE_URL is unset
backend mail.MailBackend // never nil (NullBackend by default)
podID string // default pod, resolved at startup
}
func New(cfg config.Config, svc *core.Service, backend mail.MailBackend, podID string) *Server {
return &Server{cfg: cfg, core: svc, backend: backend, podID: podID}
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Get("/healthz", s.handleHealth)
r.Group(func(r chi.Router) {
r.Use(s.bearerAuth)
r.Route("/v0/inboxes", func(r chi.Router) {
r.Post("/", s.createInbox)
r.Get("/", s.listInboxes)
r.Get("/{id}", s.getInbox)
r.Post("/{id}/ingest", s.ingest) // seed/inbound path (milestone 1)
r.Post("/{id}/messages/send", s.sendMessage)
r.Get("/{id}/messages", s.listMessages)
r.Get("/{id}/messages/{msgID}", s.getMessage)
r.Post("/{id}/messages/{msgID}/reply", s.replyMessage)
r.Get("/{id}/threads", s.listThreads)
r.Get("/{id}/threads/{threadID}", s.getThread)
})
})
return r
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
status := map[string]any{"status": "ok", "backend": backendName(s.backend)}
if s.core != nil {
if err := s.core.Ping(r.Context()); err != nil {
// /healthz is unauthenticated — don't leak DSN/host/internal details.
status["status"] = "degraded"
status["db"] = "error"
writeJSON(w, http.StatusServiceUnavailable, status)
return
}
status["db"] = "ok"
} else {
status["db"] = "not configured"
}
writeJSON(w, http.StatusOK, status)
}
func backendName(b mail.MailBackend) string {
if _, ok := b.(mail.NullBackend); ok {
return "null"
}
return "configured"
}
// requireCore guards handlers that need the database. Returns false (and writes
// a 503) when the store is unconfigured.
func (s *Server) requireCore(w http.ResponseWriter) bool {
if s.core == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "db_unconfigured",
"message": "DATABASE_URL not set",
})
return false
}
return true
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, errCode, msg string) {
writeJSON(w, code, map[string]string{"error": errCode, "message": msg})
}
-30
View File
@@ -1,30 +0,0 @@
// Package config loads OpenMail configuration from the environment.
// Twelve-factor style: everything via env vars, sane defaults for local dev.
package config
import (
"os"
)
type Config struct {
HTTPAddr string // OPENMAIL_HTTP_ADDR, e.g. ":8080"
DatabaseURL string // DATABASE_URL, e.g. postgres://user:pass@host:5432/openmail
SMTPAddr string // OPENMAIL_SMTP_ADDR, inbound :25 listener
AdminToken string // OPENMAIL_ADMIN_TOKEN, bootstrap bearer until DB-backed api_keys land
}
func Load() Config {
return Config{
HTTPAddr: envOr("OPENMAIL_HTTP_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
SMTPAddr: envOr("OPENMAIL_SMTP_ADDR", ":25"),
AdminToken: os.Getenv("OPENMAIL_ADMIN_TOKEN"),
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
-58
View File
@@ -1,58 +0,0 @@
// Package core holds OpenMail's domain services: inboxes, messages, threads,
// drafts, and the inbound ingest path (parse → thread → store). It is the layer
// the API and MCP server call, and it implements mail.InboundSink so any
// MailBackend can deliver into it.
package core
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/karti-ai/openmail/internal/store"
)
// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("core: not found")
type Service struct {
pool *pgxpool.Pool
}
func New(st *store.Store) *Service { return &Service{pool: st.Pool} }
// Ping verifies the database connection (used by the health endpoint).
func (s *Service) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
// EnsureDefaultPod returns the id of the singleton "default" pod, creating it if
// absent. Until DB-backed api_keys carry a pod_id, the API operates within this
// one tenant. Idempotent.
func (s *Service) EnsureDefaultPod(ctx context.Context) (string, error) {
var id string
err := s.pool.QueryRow(ctx, `SELECT id::text FROM pods WHERE name = 'default'`).Scan(&id)
if err == nil {
return id, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
err = s.pool.QueryRow(ctx,
`INSERT INTO pods (name) VALUES ('default')
ON CONFLICT (name) DO NOTHING
RETURNING id::text`).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
// Lost a race; read the row the other writer created.
err = s.pool.QueryRow(ctx, `SELECT id::text FROM pods WHERE name = 'default'`).Scan(&id)
}
return id, err
}
// nullStr maps an optional string to a value usable as a nullable SQL arg.
func nullStr(s *string) any {
if s == nil {
return nil
}
return *s
}
-72
View File
@@ -1,72 +0,0 @@
package core
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
type Inbox struct {
ID string `json:"id"`
PodID string `json:"pod_id"`
Address string `json:"address"`
DisplayName *string `json:"display_name,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const inboxCols = `id::text, pod_id::text, address, display_name, metadata, created_at, updated_at`
func scanInbox(row pgx.Row) (Inbox, error) {
var ib Inbox
err := row.Scan(&ib.ID, &ib.PodID, &ib.Address, &ib.DisplayName, &ib.Metadata, &ib.CreatedAt, &ib.UpdatedAt)
return ib, err
}
// CreateInbox provisions a new agent-owned address within a pod.
func (s *Service) CreateInbox(ctx context.Context, podID, address string, displayName *string) (Inbox, error) {
row := s.pool.QueryRow(ctx,
`INSERT INTO inboxes (pod_id, address, display_name)
VALUES ($1, $2, $3)
RETURNING `+inboxCols,
podID, address, nullStr(displayName))
return scanInbox(row)
}
func (s *Service) GetInbox(ctx context.Context, id string) (Inbox, error) {
ib, err := scanInbox(s.pool.QueryRow(ctx, `SELECT `+inboxCols+` FROM inboxes WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return Inbox{}, ErrNotFound
}
return ib, err
}
func (s *Service) GetInboxByAddress(ctx context.Context, address string) (Inbox, error) {
ib, err := scanInbox(s.pool.QueryRow(ctx, `SELECT `+inboxCols+` FROM inboxes WHERE address = $1`, address))
if errors.Is(err, pgx.ErrNoRows) {
return Inbox{}, ErrNotFound
}
return ib, err
}
func (s *Service) ListInboxes(ctx context.Context, podID string) ([]Inbox, error) {
rows, err := s.pool.Query(ctx,
`SELECT `+inboxCols+` FROM inboxes WHERE pod_id = $1 ORDER BY created_at DESC`, podID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Inbox{}
for rows.Next() {
ib, err := scanInbox(rows)
if err != nil {
return nil, err
}
out = append(out, ib)
}
return out, rows.Err()
}
-383
View File
@@ -1,383 +0,0 @@
package core
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/mjl-/mox/message"
)
type Message struct {
ID string `json:"id"`
InboxID string `json:"inbox_id"`
ThreadID *string `json:"thread_id,omitempty"`
MessageIDHdr *string `json:"message_id,omitempty"`
InReplyTo *string `json:"in_reply_to,omitempty"`
References []string `json:"references"`
FromAddr *string `json:"from,omitempty"`
ToAddrs []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject *string `json:"subject,omitempty"`
Preview *string `json:"preview,omitempty"`
Text *string `json:"text,omitempty"`
HTML *string `json:"html,omitempty"`
ExtractedText *string `json:"extracted_text,omitempty"`
Labels []string `json:"labels"`
SizeBytes *int64 `json:"size_bytes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
const messageCols = `id::text, inbox_id::text, thread_id::text, message_id_hdr, in_reply_to, ` +
`"references", from_addr, to_addrs, cc, bcc, subject, preview, text, html, extracted_text, ` +
`labels, size_bytes, created_at`
func scanMessage(row pgx.Row) (Message, error) {
var m Message
err := row.Scan(&m.ID, &m.InboxID, &m.ThreadID, &m.MessageIDHdr, &m.InReplyTo,
&m.References, &m.FromAddr, &m.ToAddrs, &m.Cc, &m.Bcc, &m.Subject, &m.Preview,
&m.Text, &m.HTML, &m.ExtractedText, &m.Labels, &m.SizeBytes, &m.CreatedAt)
return m, err
}
// GetMessage is scoped to the inbox: a message id that belongs to another inbox
// returns ErrNotFound, preventing cross-inbox access.
func (s *Service) GetMessage(ctx context.Context, inboxID, id string) (Message, error) {
m, err := scanMessage(s.pool.QueryRow(ctx,
`SELECT `+messageCols+` FROM messages WHERE id = $1 AND inbox_id = $2`, id, inboxID))
if errors.Is(err, pgx.ErrNoRows) {
return Message{}, ErrNotFound
}
return m, err
}
func (s *Service) ListMessages(ctx context.Context, inboxID string, limit int) ([]Message, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx,
`SELECT `+messageCols+` FROM messages WHERE inbox_id = $1 ORDER BY created_at DESC LIMIT $2`,
inboxID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return collectMessages(rows)
}
func (s *Service) GetThreadMessages(ctx context.Context, inboxID, threadID string) ([]Message, error) {
rows, err := s.pool.Query(ctx,
`SELECT `+messageCols+` FROM messages WHERE thread_id = $1 AND inbox_id = $2 ORDER BY created_at ASC`,
threadID, inboxID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectMessages(rows)
}
func collectMessages(rows pgx.Rows) ([]Message, error) {
out := []Message{}
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// Deliver implements mail.InboundSink: resolve the inbox by address and ingest.
func (s *Service) Deliver(ctx context.Context, inboxAddr string, raw []byte) error {
ib, err := s.GetInboxByAddress(ctx, inboxAddr)
if err != nil {
return err
}
_, err = s.IngestRaw(ctx, ib.ID, raw)
return err
}
// IngestRaw parses a raw RFC 5322 message with mox, resolves its thread, and
// stores it — the single inbound path shared by the ingest API and every
// MailBackend. Runs in one transaction.
func (s *Service) IngestRaw(ctx context.Context, inboxID string, raw []byte) (Message, error) {
pr, err := parseRaw(raw)
if err != nil {
return Message{}, err
}
extracted := stripQuotes(pr.text)
preview := makePreview(extracted, pr.text)
headersJSON, _ := json.Marshal(pr.headers)
tx, err := s.pool.Begin(ctx)
if err != nil {
return Message{}, err
}
defer tx.Rollback(ctx)
threadID, err := resolveThreadTx(ctx, tx, inboxID, pr)
if err != nil {
return Message{}, err
}
var msgID string
err = tx.QueryRow(ctx,
`INSERT INTO messages
(inbox_id, thread_id, message_id_hdr, in_reply_to, "references", from_addr,
to_addrs, cc, bcc, subject, preview, text, html, extracted_text,
size_bytes, headers, ts)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
to_tsvector('english', coalesce($10,'') || ' ' || coalesce($12,'')))
RETURNING id::text`,
inboxID, threadID, nullEmpty(pr.messageID), nullEmpty(pr.inReplyTo), pr.references,
nullEmpty(pr.from), pr.to, pr.cc, pr.bcc, nullEmpty(pr.subject), nullEmpty(preview),
nullEmpty(pr.text), nullEmpty(pr.html), nullEmpty(extracted),
int64(len(raw)), headersJSON,
).Scan(&msgID)
if err != nil {
return Message{}, err
}
if _, err := tx.Exec(ctx,
`UPDATE threads
SET message_count = message_count + 1,
last_message_id = $1,
subject = COALESCE(subject, $2),
updated_at = now()
WHERE id = $3`,
msgID, nullEmpty(pr.subject), threadID); err != nil {
return Message{}, err
}
evt, _ := json.Marshal(map[string]string{"message_id": msgID, "thread_id": threadID})
if _, err := tx.Exec(ctx,
`INSERT INTO events (inbox_id, type, payload) VALUES ($1, 'message.received', $2)`,
inboxID, evt); err != nil {
return Message{}, err
}
if err := tx.Commit(ctx); err != nil {
return Message{}, err
}
return s.GetMessage(ctx, inboxID, msgID)
}
// resolveThreadTx finds the thread for a message via In-Reply-To/References
// (the correct, false-merge-safe mechanism), creating a new thread otherwise.
// Subject-based fallback is intentionally deferred (see ARCHITECTURE.md §9).
func resolveThreadTx(ctx context.Context, tx pgx.Tx, inboxID string, pr parsed) (string, error) {
cand := make([]string, 0, len(pr.references)+1)
if pr.inReplyTo != "" {
cand = append(cand, pr.inReplyTo)
}
cand = append(cand, pr.references...)
if len(cand) > 0 {
var tid string
err := tx.QueryRow(ctx,
`SELECT thread_id::text FROM messages
WHERE inbox_id = $1 AND message_id_hdr = ANY($2) AND thread_id IS NOT NULL
ORDER BY created_at DESC LIMIT 1`,
inboxID, cand).Scan(&tid)
if err == nil {
return tid, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
}
var tid string
err := tx.QueryRow(ctx,
`INSERT INTO threads (inbox_id, subject) VALUES ($1, $2) RETURNING id::text`,
inboxID, nullEmpty(pr.subject)).Scan(&tid)
return tid, err
}
// --- parsing (mox/message) ---
// parsed is the normalized result of parsing a raw message.
type parsed struct {
messageID string
inReplyTo string
references []string
from string
to []string
cc []string
bcc []string
subject string
text string
html string
headers map[string][]string
}
var discardLog = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
func parseRaw(raw []byte) (parsed, error) {
var pr parsed
// EnsurePart always returns a usable Part — building an octet-stream fallback
// even when parsing hits a recoverable defect (bare CR/LF, bad Content-Type,
// missing boundary, truncated DSN). That tolerance for messy real-world mail
// is precisely why mox was chosen, so we proceed with the returned part and
// do NOT treat the recoverable error as fatal.
p, _ := message.EnsurePart(discardLog, false, bytes.NewReader(raw), int64(len(raw)))
if p.Envelope != nil {
e := p.Envelope
pr.subject = e.Subject
pr.messageID = e.MessageID
pr.inReplyTo = e.InReplyTo
pr.from = firstAddr(e.From)
pr.to = addrList(e.To)
pr.cc = addrList(e.CC)
pr.bcc = addrList(e.BCC)
}
if hdr, herr := p.Header(); herr == nil {
pr.references = strings.Fields(hdr.Get("References"))
pr.headers = hdr
}
if pr.references == nil {
pr.references = []string{}
}
pr.text, pr.html = extractBodies(&p)
return pr, nil
}
// extractBodies walks the MIME tree and returns the first text/plain and
// text/html leaf bodies (coerced to valid UTF-8). It descends into embedded
// messages and skips attachment parts.
func extractBodies(p *message.Part) (text, html string) {
// Embedded message (message/rfc822 or message/global): the sub-message lives
// under p.Message, not p.Parts. Wire its reader, then recurse — otherwise
// forwarded mail and DSN/bounce bodies are lost.
if p.Message != nil {
if err := p.SetMessageReaderAt(); err == nil {
return extractBodies(p.Message)
}
return "", ""
}
if len(p.Parts) == 0 {
if isAttachment(p) {
return "", "" // an attachment is not the message body
}
body := readBody(p)
switch {
case p.MediaType == "TEXT" && p.MediaSubType == "HTML":
return "", body
case p.MediaType == "TEXT" || p.MediaType == "":
return body, "" // PLAIN, or absent content-type → treat as plain
default:
return "", ""
}
}
for i := range p.Parts {
t, h := extractBodies(&p.Parts[i])
if text == "" {
text = t
}
if html == "" {
html = h
}
}
return text, html
}
// isAttachment reports whether a part is declared as an attachment (so it is not
// treated as the message body). Content-Disposition carries params, so we match
// the leading token.
func isAttachment(p *message.Part) bool {
if p.ContentDisposition == nil {
return false
}
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(*p.ContentDisposition)), "attachment")
}
const maxBodyBytes = 2 << 20 // 2 MiB cap per body part for milestone 1
func readBody(p *message.Part) string {
rd := p.ReaderUTF8OrBinary()
if rd == nil {
return ""
}
var b strings.Builder
_, _ = io.Copy(&b, io.LimitReader(rd, maxBodyBytes))
// Bodies may be non-UTF-8 (mox returns raw bytes for unknown/empty charsets)
// and LimitReader can cut mid-rune; Postgres text/tsvector reject invalid
// UTF-8 and would roll back the whole ingest. Coerce to valid UTF-8.
return strings.ToValidUTF8(b.String(), "")
}
// firstAddr returns the first address that has both a localpart and a host. mox
// appends empty-User/Host entries for addresses it cannot parse; emitting "@"
// for those would be wrong, so we skip them.
func firstAddr(as []message.Address) string {
for _, a := range as {
if a.User != "" && a.Host != "" {
return a.User + "@" + a.Host
}
}
return ""
}
func addrList(as []message.Address) []string {
out := make([]string, 0, len(as))
for _, a := range as {
if a.User == "" || a.Host == "" {
continue
}
out = append(out, a.User+"@"+a.Host)
}
return out
}
// --- text helpers ---
var onWroteRe = regexp.MustCompile(`(?i)^on .+wrote:$`)
// stripQuotes removes quoted history so an agent reads only the new content.
// Milestone-1 heuristic (talon-style port deferred): cut at the first quoted
// block or "On … wrote:" attribution line.
func stripQuotes(text string) string {
if text == "" {
return ""
}
lines := strings.Split(text, "\n")
out := make([]string, 0, len(lines))
for _, ln := range lines {
t := strings.TrimSpace(ln)
if strings.HasPrefix(t, ">") || onWroteRe.MatchString(t) {
break
}
out = append(out, ln)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func makePreview(extracted, full string) string {
src := extracted
if src == "" {
src = full
}
src = strings.Join(strings.Fields(src), " ")
const max = 200
if len([]rune(src)) > max {
src = string([]rune(src)[:max])
}
return src
}
// nullEmpty maps "" to a SQL NULL so optional text columns stay null, not blank.
func nullEmpty(s string) any {
if s == "" {
return nil
}
return s
}
-57
View File
@@ -1,57 +0,0 @@
package core
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
type Thread struct {
ID string `json:"id"`
InboxID string `json:"inbox_id"`
Subject *string `json:"subject,omitempty"`
LastMessageID *string `json:"last_message_id,omitempty"`
MessageCount int `json:"message_count"`
Labels []string `json:"labels"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const threadCols = `id::text, inbox_id::text, subject, last_message_id::text, message_count, labels, created_at, updated_at`
func scanThread(row pgx.Row) (Thread, error) {
var t Thread
err := row.Scan(&t.ID, &t.InboxID, &t.Subject, &t.LastMessageID, &t.MessageCount, &t.Labels, &t.CreatedAt, &t.UpdatedAt)
return t, err
}
func (s *Service) ListThreads(ctx context.Context, inboxID string) ([]Thread, error) {
rows, err := s.pool.Query(ctx,
`SELECT `+threadCols+` FROM threads WHERE inbox_id = $1 ORDER BY updated_at DESC`, inboxID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Thread{}
for rows.Next() {
t, err := scanThread(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// GetThread is scoped to the inbox: a thread id belonging to another inbox
// returns ErrNotFound.
func (s *Service) GetThread(ctx context.Context, inboxID, id string) (Thread, error) {
t, err := scanThread(s.pool.QueryRow(ctx,
`SELECT `+threadCols+` FROM threads WHERE id = $1 AND inbox_id = $2`, id, inboxID))
if errors.Is(err, pgx.ErrNoRows) {
return Thread{}, ErrNotFound
}
return t, err
}
-77
View File
@@ -1,77 +0,0 @@
// Package mail defines the MailBackend abstraction (ARCHITECTURE.md §0.2): all
// inbound delivery and outbound sending sit behind one interface so the core
// (API, MCP, store, threading) never depends on *how* mail moves. Concrete
// backends — relay, imap_smtp, embedded — live in subpackages and are added in
// later milestones. Milestone 1 ships only NullBackend.
package mail
import (
"context"
"errors"
)
// OutgoingMessage is a message the core wants sent. The backend is responsible
// for building/serializing and (where applicable) DKIM-signing it.
type OutgoingMessage struct {
InboxID string
From string
To []string
Cc []string
Bcc []string
Subject string
Text string
HTML string
InReplyTo string
References []string
}
// SendResult reports the outcome of a Send.
type SendResult struct {
MessageIDHdr string // RFC 5322 Message-ID assigned to the sent message
Accepted bool
}
// InboundSink receives raw RFC 5322 messages a backend has accepted for an
// address. The core implements this (parse → thread → store → events).
type InboundSink interface {
Deliver(ctx context.Context, inboxAddr string, raw []byte) error
}
// Caps advertises what a backend can do, so the API/MCP can expose accurate
// capabilities (e.g. whether throwaway addresses or custom domains are possible).
type Caps struct {
SelfHost bool // runs its own MTA in-process
InboundPush bool // delivers inbound without polling (webhook or :25)
CustomDomain bool // can own an arbitrary domain
ThrowawayAddrs bool // can mint addresses on demand
}
// MailBackend abstracts where mail comes from and how it leaves.
type MailBackend interface {
// Send dispatches an outgoing message.
Send(ctx context.Context, msg *OutgoingMessage) (SendResult, error)
// Start delivers inbound messages to sink until ctx is cancelled.
Start(ctx context.Context, sink InboundSink) error
// Capabilities describes what this backend supports.
Capabilities() Caps
}
// ErrNotSupported is returned by backends for operations they cannot perform.
var ErrNotSupported = errors.New("mail: operation not supported by this backend")
// NullBackend satisfies MailBackend without moving any mail. It is the
// milestone-1 default: messages enter only via the ingest API (core acts as its
// own InboundSink), and sending is unavailable until a real backend is wired.
type NullBackend struct{}
func (NullBackend) Send(context.Context, *OutgoingMessage) (SendResult, error) {
return SendResult{}, ErrNotSupported
}
// Start blocks until cancelled; the null backend never produces inbound mail.
func (NullBackend) Start(ctx context.Context, _ InboundSink) error {
<-ctx.Done()
return ctx.Err()
}
func (NullBackend) Capabilities() Caps { return Caps{} }
-151
View File
@@ -1,151 +0,0 @@
-- OpenMail initial schema. See ARCHITECTURE.md §3.
-- Native, agent-shaped data model (not Mox's per-account bbolt index).
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
-- Tenant isolation.
CREATE TABLE IF NOT EXISTS pods (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (name)
);
-- Per-domain DKIM keys + DNS verification state.
CREATE TABLE IF NOT EXISTS domains (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
name text NOT NULL,
dkim_selector text,
dkim_privkey_ref text, -- pointer to key in secret store; never the key itself
verified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (name)
);
-- An agent-owned address; first-class API resource.
CREATE TABLE IF NOT EXISTS inboxes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
address text NOT NULL,
display_name text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (address)
);
CREATE TABLE IF NOT EXISTS threads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
subject text,
last_message_id uuid,
message_count integer NOT NULL DEFAULT 0,
labels text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
thread_id uuid REFERENCES threads(id) ON DELETE SET NULL,
message_id_hdr text, -- RFC 5322 Message-ID
in_reply_to text,
"references" text[] NOT NULL DEFAULT '{}',
from_addr text,
to_addrs text[] NOT NULL DEFAULT '{}',
cc text[] NOT NULL DEFAULT '{}',
bcc text[] NOT NULL DEFAULT '{}',
subject text,
preview text,
text text,
html text,
extracted_text text, -- quoted history stripped
extracted_html text,
raw_object_key text, -- pointer to raw .eml in object store
spf text, -- inbound auth verdicts (from mox pkgs)
dkim text,
dmarc text,
junk_score real,
labels text[] NOT NULL DEFAULT '{}',
size_bytes bigint,
headers jsonb NOT NULL DEFAULT '{}'::jsonb,
ts tsvector, -- full-text search
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS attachments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename text,
content_type text,
size_bytes bigint,
object_key text NOT NULL,
inline boolean NOT NULL DEFAULT false,
content_id text
);
CREATE TABLE IF NOT EXISTS drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
thread_id uuid REFERENCES threads(id) ON DELETE SET NULL,
to_addrs text[] NOT NULL DEFAULT '{}',
cc text[] NOT NULL DEFAULT '{}',
bcc text[] NOT NULL DEFAULT '{}',
subject text,
text text,
html text,
send_at timestamptz,
client_id text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Bearer tokens; only the hash is stored.
CREATE TABLE IF NOT EXISTS api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
hash bytea NOT NULL,
scopes text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (hash)
);
CREATE TABLE IF NOT EXISTS webhooks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
url text NOT NULL,
event_types text[] NOT NULL DEFAULT '{}',
secret text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Outbound send queue + retries.
CREATE TABLE IF NOT EXISTS outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'queued', -- queued|sending|sent|failed
attempts integer NOT NULL DEFAULT 0,
last_error text,
next_attempt_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inbox_id uuid REFERENCES inboxes(id) ON DELETE CASCADE,
type text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_inboxes_pod ON inboxes(pod_id);
CREATE INDEX IF NOT EXISTS idx_threads_inbox ON threads(inbox_id);
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_messages_inbox_time ON messages(inbox_id, created_at DESC);
-- Supports per-delivery thread resolution (message_id_hdr = ANY(...) per inbox).
CREATE INDEX IF NOT EXISTS idx_messages_msgid ON messages(inbox_id, message_id_hdr) WHERE message_id_hdr IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages USING gin(ts);
CREATE INDEX IF NOT EXISTS idx_messages_labels ON messages USING gin(labels);
CREATE INDEX IF NOT EXISTS idx_threads_labels ON threads USING gin(labels);
CREATE INDEX IF NOT EXISTS idx_outbox_due ON outbox(next_attempt_at) WHERE status = 'queued';
-100
View File
@@ -1,100 +0,0 @@
// Package store owns OpenMail's Postgres-backed persistence. This is the
// deliberate divergence from Mox: OpenMail keeps its own native, agent-shaped
// data model (see ARCHITECTURE.md §3) rather than embedding Mox's bstore/bbolt
// account store.
package store
import (
"context"
"embed"
"fmt"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Store struct {
Pool *pgxpool.Pool
}
// Open connects to Postgres and verifies the connection.
func Open(ctx context.Context, databaseURL string) (*Store, error) {
if databaseURL == "" {
return nil, fmt.Errorf("store: DATABASE_URL is empty")
}
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("store: connect: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("store: ping: %w", err)
}
return &Store{Pool: pool}, nil
}
func (s *Store) Close() {
if s.Pool != nil {
s.Pool.Close()
}
}
// Migrate applies any embedded migrations not yet recorded in schema_migrations,
// in filename order. Each migration runs in its own transaction.
func (s *Store) Migrate(ctx context.Context) error {
_, err := s.Pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)`)
if err != nil {
return fmt.Errorf("migrate: ensure schema_migrations: %w", err)
}
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("migrate: read embedded migrations: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
var exists bool
if err := s.Pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, name,
).Scan(&exists); err != nil {
return fmt.Errorf("migrate: check %s: %w", name, err)
}
if exists {
continue
}
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("migrate: read %s: %w", name, err)
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("migrate: begin %s: %w", name, err)
}
if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("migrate: apply %s: %w", name, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, name); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("migrate: record %s: %w", name, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("migrate: commit %s: %w", name, err)
}
}
return nil
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.97.1"
components = ["rustfmt", "clippy"]
-123
View File
@@ -1,123 +0,0 @@
// Command mimecheck is a throwaway feasibility spike for OpenMail's core
// architectural bet (see ARCHITECTURE.md §0, §9): can Mox's `message` package
// parse a raw RFC 5322 message standalone — outside Mox's store/config/global
// state — given only an io.ReaderAt?
//
// If this builds and runs without dragging in mox-/store/config, "embed Mox as
// a library" (Option B) is viable. Run: go run ./spike/mimecheck
package main
import (
"bytes"
"fmt"
"log/slog"
"os"
"strings"
"github.com/mjl-/mox/message"
)
// A deliberately messy real-world-ish message: multipart/alternative (text+html)
// with a reply quote, threading headers, and an attachment part.
const sampleEML = "From: Alice <alice@example.com>\r\n" +
"To: agent@openmail.test\r\n" +
"Subject: Re: invoice #42\r\n" +
"Message-ID: <reply-2@example.com>\r\n" +
"In-Reply-To: <orig-1@openmail.test>\r\n" +
"References: <orig-1@openmail.test>\r\n" +
"Date: Mon, 21 Jun 2026 12:00:00 +0000\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"OUTER\"\r\n" +
"\r\n" +
"--OUTER\r\n" +
"Content-Type: multipart/alternative; boundary=\"INNER\"\r\n" +
"\r\n" +
"--INNER\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" +
"Thanks, looks good to me.\r\n" +
"\r\n" +
"On Mon, Alice wrote:\r\n" +
"> here is the invoice\r\n" +
"--INNER\r\n" +
"Content-Type: text/html; charset=utf-8\r\n" +
"\r\n" +
"<p>Thanks, looks good to me.</p>\r\n" +
"--INNER--\r\n" +
"--OUTER\r\n" +
"Content-Type: application/pdf; name=\"invoice.pdf\"\r\n" +
"Content-Disposition: attachment; filename=\"invoice.pdf\"\r\n" +
"Content-Transfer-Encoding: base64\r\n" +
"\r\n" +
"JVBERi0xLjQK\r\n" +
"--OUTER--\r\n"
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
r := bytes.NewReader([]byte(sampleEML))
// EnsurePart fully parses header + walks the MIME tree given the size.
p, err := message.EnsurePart(log, false, r, int64(len(sampleEML)))
if err != nil {
fmt.Fprintf(os.Stderr, "FAIL: parse error: %v\n", err)
os.Exit(1)
}
fmt.Println("=== mox/message standalone parse OK ===")
if p.Envelope != nil {
e := p.Envelope
fmt.Printf("Subject: %s\n", e.Subject)
fmt.Printf("MessageID: %s\n", e.MessageID)
fmt.Printf("InReplyTo: %s\n", e.InReplyTo)
if len(e.From) > 0 {
fmt.Printf("From: %s@%s\n", e.From[0].User, e.From[0].Host)
}
}
fmt.Printf("Top type: %s/%s\n", p.MediaType, p.MediaSubType)
// Walk the tree, summarizing each leaf — proves multipart traversal works.
var textBody string
var attachments int
var walk func(parts []message.Part, depth int)
walk = func(parts []message.Part, depth int) {
for i := range parts {
sp := &parts[i]
indent := strings.Repeat(" ", depth)
disp := ""
if sp.ContentDisposition != nil {
disp = " [" + *sp.ContentDisposition + "]"
}
fmt.Printf("%s- %s/%s%s\n", indent, sp.MediaType, sp.MediaSubType, disp)
if sp.ContentDisposition != nil && strings.EqualFold(*sp.ContentDisposition, "attachment") {
attachments++
}
if sp.MediaType == "TEXT" && sp.MediaSubType == "PLAIN" && textBody == "" {
if buf, rerr := readPart(sp); rerr == nil {
textBody = string(buf)
}
}
walk(sp.Parts, depth+1)
}
}
fmt.Println("Structure:")
walk(p.Parts, 1)
fmt.Printf("\nAttachments found: %d\n", attachments)
fmt.Printf("text/plain body:\n%s\n", indentBlock(textBody))
fmt.Println("=== SPIKE PASSED: Mox message parsing is usable standalone ===")
}
func readPart(p *message.Part) ([]byte, error) {
rd := p.Reader()
var b bytes.Buffer
_, err := b.ReadFrom(rd)
return b.Bytes(), err
}
func indentBlock(s string) string {
out := []string{}
for _, line := range strings.Split(strings.TrimRight(s, "\r\n"), "\n") {
out = append(out, " | "+strings.TrimRight(line, "\r"))
}
return strings.Join(out, "\n")
}