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:
co-authored by
Claude Opus 5
parent
428040d964
commit
36b15ddcaf
@@ -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
@@ -1,4 +1,8 @@
|
||||
/bin/
|
||||
*.exe
|
||||
/target
|
||||
**/*.rs.bk
|
||||
.env
|
||||
.DS_Store
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
/data
|
||||
|
||||
Generated
+3451
File diff suppressed because it is too large
Load Diff
+78
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ./...
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
[](./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
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{} }
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user