+ Secure, scalable mail & collaboration server with comprehensive protocol support 🛡️ (IMAP, JMAP, SMTP, CalDAV, CardDAV, WebDAV)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Features
+
+**Stalwart** is an open-source mail & collaboration server with JMAP, IMAP4, POP3, SMTP, CalDAV, CardDAV and WebDAV support and a wide range of modern features. It is written in Rust and designed to be secure, fast, robust and scalable.
+
+Key features:
+
+- **Email** server with complete protocol support:
+ - JMAP:
+ * [JMAP for Mail](https://datatracker.ietf.org/doc/html/rfc8621) server.
+ * [JMAP for Sieve Scripts](https://www.ietf.org/archive/id/draft-ietf-jmap-sieve-22.html).
+ * [WebSocket](https://datatracker.ietf.org/doc/html/rfc8887), [Blob Management](https://www.rfc-editor.org/rfc/rfc9404.html) and [Quotas](https://www.rfc-editor.org/rfc/rfc9425.html) extensions.
+ - IMAP:
+ * [IMAP4rev2](https://datatracker.ietf.org/doc/html/rfc9051) and [IMAP4rev1](https://datatracker.ietf.org/doc/html/rfc3501) server.
+ * [ManageSieve](https://datatracker.ietf.org/doc/html/rfc5804) server.
+ * Numerous [extensions](https://stalw.art/docs/development/rfcs#imap4-and-extensions) supported.
+ - POP3:
+ - [POP3](https://datatracker.ietf.org/doc/html/rfc1939) server.
+ - [STLS](https://datatracker.ietf.org/doc/html/rfc2595) and [SASL](https://datatracker.ietf.org/doc/html/rfc5034) support as well as other [extensions](https://datatracker.ietf.org/doc/html/rfc2449).
+ - SMTP:
+ * SMTP server with built-in [DMARC](https://datatracker.ietf.org/doc/html/rfc7489), [DKIMv2](https://datatracker.ietf.org/doc/draft-ietf-dkim-dkim2-spec/), [DKIMv1](https://datatracker.ietf.org/doc/html/rfc6376), [SPF](https://datatracker.ietf.org/doc/html/rfc7208) and [ARC](https://datatracker.ietf.org/doc/html/rfc8617) support for message authentication.
+ * Strong transport security through [DANE](https://datatracker.ietf.org/doc/html/rfc6698), [MTA-STS](https://datatracker.ietf.org/doc/html/rfc8461) and [SMTP TLS](https://datatracker.ietf.org/doc/html/rfc8460) reporting.
+ * Automated DKIM key rotation and management.
+ * Inbound throttling and filtering with granular configuration rules, sieve scripting, MTA hooks and milter integration.
+ * Distributed virtual queues with delayed delivery, priority delivery, quotas, routing rules and throttling support.
+ * Envelope rewriting and message modification.
+- **Collaboration** server:
+ - Calendaring and scheduling:
+ - [CalDAV](https://datatracker.ietf.org/doc/html/rfc4791) and [CalDAV Scheduling](https://datatracker.ietf.org/doc/html/rfc6638) support.
+ - [JMAP for Calendars](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-calendars-24) support.
+ - Contact management:
+ - [CardDAV](https://datatracker.ietf.org/doc/html/rfc6352) support.
+ - [JMAP for Contacts](https://datatracker.ietf.org/doc/html/rfc9610) support.
+ - File storage:
+ - [WebDAV](https://datatracker.ietf.org/doc/html/rfc4918) support.
+ - [JMAP for File Storage](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-filenode-03) support.
+ - Sharing with fine-grained access controls:
+ - [WebDAV ACL](https://datatracker.ietf.org/doc/html/rfc3744) support.
+ - [JMAP Sharing](https://datatracker.ietf.org/doc/html/rfc9670) support.
+- **Spam** and **Phishing** built-in filter:
+ - Comprehensive set of filtering **rules** on par with popular solutions.
+ - LLM-driven spam filtering and message analysis.
+ - Statistical **spam classifier** with collaborative filtering, automatic training capabilities and address book integration.
+ - DNS Blocklists (**DNSBLs**) checking of IP addresses, domains, and hashes.
+ - Collaborative digest-based spam filtering with **Pyzor**.
+ - **Phishing** protection against homographic URL attacks, sender spoofing and other techniques.
+ - Trusted **reply** tracking to recognize and prioritize genuine e-mail replies.
+ - Sender **reputation** monitoring by IP address, ASN, domain and email address.
+ - **Greylisting** to temporarily defer unknown senders.
+ - **Spam traps** to set up decoy email addresses that catch and analyze spam.
+- **Flexible**:
+ - Pluggable storage backends with **RocksDB**, **FoundationDB**, **PostgreSQL**, **mySQL**, **SQLite**, **S3-Compatible**, **Azure** and **Redis** support.
+ - Full-text search available in 17 languages using the built-in search engine or via **Meilisearch**, **ElasticSearch**, **OpenSearch**, **PostgreSQL** or **mySQL** backends.
+ - Sieve scripting language with support for all [registered extensions](https://www.iana.org/assignments/sieve-extensions/sieve-extensions.xhtml).
+ - Email aliases, mailing lists, subaddressing and catch-all addresses support.
+ - Automated DNS management.
+ - Automatic account configuration and discovery with [PACC](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-pacc/), [autoconfig](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/) and [autodiscover](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019).
+ - Multi-tenancy support with domain and tenant isolation.
+ - Disk quotas per user and tenant.
+- **Secure and robust**:
+ - Encryption at rest with **S/MIME** or **OpenPGP**.
+ - Automatic TLS certificate provisioning with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) using `TLS-ALPN-01`, `DNS-01`, `DNS-PERSIST-01` or `HTTP-01` challenges.
+ - Automated blocking of IP addresses that attack, abuse or scan the server for exploits.
+ - Rate limiting.
+ - Security audited (read the [report](https://stalw.art/blog/security-audit)).
+ - Memory safe (thanks to Rust).
+- **Scalable and fault-tolerant**:
+ - Designed to handle growth seamlessly, from small setups to large-scale deployments of thousands of nodes.
+ - Built with **fault tolerance** and **high availability** in mind, recovers from hardware or software failures with minimal operational impact.
+ - Peer-to-peer cluster coordination or with **Kafka**, **Redpanda**, **NATS** or **Redis**.
+ - **Kubernetes**, **Apache Mesos** and **Docker Swarm** support for automated scaling and container orchestration.
+ - Read replicas, sharded blob storage and in-memory data stores for high performance and low latency.
+- **Authentication and Authorization**:
+ - **OpenID Connect** authentication.
+ - OAuth 2.0 authorization with [authorization code](https://www.rfc-editor.org/rfc/rfc8628) and [device authorization](https://www.rfc-editor.org/rfc/rfc8628) flows.
+ - **LDAP**, **OIDC**, **SQL** or built-in authentication backend support.
+ - System for Cross-domain Identity Management ([SCIM](https://www.rfc-editor.org/info/rfc7643/)) v2 for automated provisioning.
+ - Two-factor authentication with Time-based One-Time Passwords (`2FA-TOTP`)
+ - Application passwords (App Passwords).
+ - Roles and permissions.
+ - Access Control Lists (ACLs).
+- **Observability**:
+ - Logging and tracing with **OpenTelemetry**, journald, log files and console support.
+ - Metrics with **OpenTelemetry** and **Prometheus** integration.
+ - Webhooks for event-driven automation.
+ - Alerts with email and webhook notifications.
+ - Live tracing and metrics.
+- **Web-based administration**:
+ - Dashboard with real-time statistics and monitoring.
+ - Account, domain, group and mailing list management.
+ - SMTP queue management for messages and outbound DMARC and TLS reports.
+ - Report visualization interface for received DMARC, TLS-RPT and Failure (ARF) reports.
+ - Configuration of every aspect of the mail server.
+ - Log viewer with search and filtering capabilities.
+ - Self-service portal for password reset and encryption-at-rest key management.
+
+## Screenshots
+
+
+
+## Presentation
+
+**Want a deeper dive?** Need to explain to your boss why Stalwart is the perfect fit? Whether you're evaluating options, making a case to your team, or simply curious about how it all works under the hood, these slides walk you through the key features, architecture, and benefits of Stalwart. Browse the [slides](https://stalw.art/slides) to see what makes it stand out.
+
+## Get Started
+
+Install Stalwart on your server by following the instructions for your platform:
+
+- [Linux / MacOS / FreeBSD](https://stalw.art/docs/install/platform/linux)
+- [Windows](https://stalw.art/docs/install/platform/windows)
+- [Docker](https://stalw.art/docs/install/platform/docker)
+
+All documentation is available at [stalw.art/docs](https://stalw.art/docs/install/get-started).
+
+## Support
+
+If you are having problems running Stalwart, found a bug, or just have a question, please head to the [Stalwart Support Portal](https://support.stalw.art) at [support.stalw.art](https://support.stalw.art).
+Additionally, you may purchase an [Enterprise License](https://stalw.art/enterprise) to obtain priority support from Stalwart Labs LLC, including response-time commitments and a private Priority Support area on the portal.
+
+## Contributing
+
+We welcome contributions, but to keep the project maintainable there are a few things to know before opening a pull request. Because of the high volume of low-quality, AI-generated submissions, pull requests are limited to a list of vouched contributors; to be added, post at [support.stalw.art](https://support.stalw.art) describing the change you would like to submit, together with a link to the proposed change. At this stage only bug fixes and translations are accepted, and new features are not, unless they involve just a few lines of code.
+For the full guidelines, please read [CONTRIBUTING.md](CONTRIBUTING.md).
+
+## Roadmap
+
+Stalwart has reached an exciting point in its journey, it’s now **feature complete**. All the core functionality and open standard email and collaboration protocols that we set out to support are in place. In other words, Stalwart already does everything you’d expect from a modern, standards-compliant mail and collaboration platform.
+
+The next major milestone is all about refinement: finalizing the database schema and focusing on performance optimizations to ensure everything runs as efficiently and reliably as possible. Once that’s done, we’ll be ready to roll out version **1.0**.
+
+Of course, development doesn’t stop there. The community has contributed hundreds of great ideas for improvements and new features, everything from subtle usability tweaks to entirely new integrations. You can see the full list of proposals over on our [GitHub issues](https://github.com/stalwartlabs/stalwart/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement). If there’s something you’d like to see prioritized, just give it a thumbs up as we plan to implement enhancements based on the community’s votes.
+
+## Sponsorship
+
+Your support is crucial in helping us continue to improve the project, add new features, and maintain the highest level of quality. By [becoming a sponsor](https://opencollective.com/stalwart), you help fund the development and future of Stalwart. As a thank-you, sponsors who contribute $5 per month or more will automatically receive a [Enterprise edition](https://stalw.art/enterprise/) license. And, sponsors who contribute $30 per month or more, also have access to [Premium Support](https://stalw.art/support) from Stalwart Labs.
+
+## Funding
+
+Part of the development of this project was funded through:
+
+- [NGI0 Entrust Fund](https://nlnet.nl/entrust), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101069594.
+- [NGI Zero Core](https://nlnet.nl/NGI0/), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101092990.
+
+If you find the project useful you can help by [becoming a sponsor](https://opencollective.com/stalwart). Thank you!
+
+## License
+
+This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v2 (SELv2)**:
+
+- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license.
+- The [Stalwart Enterprise License v2 (SELv2)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
+
+Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
+
+## Copyright
+
+Copyright (C) 2020, Stalwart Labs LLC
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..9d741d6
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,154 @@
+# Security Policy for Stalwart
+
+## Supported Versions
+
+We provide security updates for the following versions of Stalwart:
+
+| Version | Supported | End of Support |
+| ------- | ------------------ | -------------- |
+| 0.16.x | :white_check_mark: | TBD |
+| 0.15.x | :white_check_mark: | 2026-12-01 |
+| < 0.14 | :x: | Ended |
+
+**Note**: We typically support the current major version and one previous major version. Users are strongly encouraged to upgrade to the latest version for the best security posture.
+
+## Reporting a Vulnerability
+
+We take the security of Stalwart very seriously. If you believe you've found a security vulnerability, we encourage you to inform us responsibly through coordinated disclosure.
+
+### How to Report
+
+**Do not report security vulnerabilities through public GitHub issues, discussions, or social media.**
+
+Instead, please use one of these secure channels:
+
+1. **Email** (preferred): Send details to `security@stalw.art`
+2. **GitHub Security Advisories**: Use the "Report a vulnerability" button in the Security tab
+3. **Backup contact**: If no response within 48 hours, email `hello@stalw.art`
+
+### What to Include
+
+To help us understand and address the issue quickly, please include:
+
+**Required Information:**
+- Brief description of the vulnerability type
+- Affected version(s) and components
+- Steps to reproduce the issue
+- Impact assessment (what could an attacker achieve?)
+
+**Helpful Additional Details:**
+- Full paths of affected source files
+- Specific commit/branch where the issue exists
+- Required configuration to reproduce
+- Proof-of-concept code (if available)
+- Suggested mitigation or fix (if you have ideas)
+
+### Our Response Process
+
+**Timeline Commitments:**
+- **Initial acknowledgment**: Within 24 hours
+- **Detailed response**: Within 72 hours
+- **Status updates**: Every 7 days until resolved
+- **Resolution target**: 90 days for most issues
+
+**What We'll Do:**
+1. Acknowledge your report and assign a tracking ID
+2. Assess the vulnerability and determine severity
+3. Develop and test a fix
+4. Coordinate disclosure timeline with you
+5. Release security update and publish advisory
+6. Credit you in our security advisory (if desired)
+
+## Disclosure Policy
+
+We follow responsible disclosure principles:
+
+- **Coordinated disclosure**: We'll work with you to determine appropriate disclosure timing
+- **Typical timeline**: 90 days from report to public disclosure
+- **Early disclosure**: May occur if issue is being actively exploited
+- **Delayed disclosure**: May be necessary for complex issues requiring significant changes
+
+## Scope
+
+This security policy applies to:
+
+**In Scope:**
+- Stalwart (all supported versions)
+- Official Docker images
+- Documentation that could lead to insecure configurations
+- Dependencies with security implications
+
+**Out of Scope:**
+- Third-party integrations or plugins
+- Issues requiring physical access to the server
+- Social engineering attacks
+- Attacks requiring compromised credentials (unless the vulnerability enables credential compromise)
+- Theoretical vulnerabilities without practical exploitation
+
+## Security Measures
+
+**Our Commitments:**
+- Regular security audits of dependencies using `cargo audit`
+- Automated security scanning in CI/CD pipeline
+- Following Rust security best practices
+- Prompt security updates for critical dependencies
+- Security-focused code review process
+
+**User Responsibilities:**
+- Keep Stalwart updated to supported versions
+- Follow security configuration guidelines
+- Implement proper network security (firewalls, TLS, etc.)
+- Regular security monitoring and logging
+- Secure credential management
+
+## Legal Safe Harbor
+
+We support security research conducted in good faith. If you follow these guidelines:
+
+**We will NOT:**
+- Initiate legal action against you
+- Contact law enforcement about your research
+- Suspend or terminate your access to Stalwart services
+
+**You must:**
+- Only test against your own Stalwart installations
+- Not access, modify, or delete user data
+- Not perform testing that could degrade service availability
+- Not publicly disclose the issue before coordinated disclosure
+- Act in good faith and not for malicious purposes
+
+## Recognition
+
+We believe in recognizing security researchers who help keep Stalwart secure:
+
+- **Security Advisory Credits**: We'll credit you in our GitHub Security Advisories (unless you prefer to remain anonymous)
+- **Hall of Fame**: Significant contributors may be listed in our security acknowledgments
+- **Swag**: We may send Stalwart merchandise for notable contributions
+
+## Security Updates
+
+**Stay Informed:**
+- Subscribe to our [GitHub releases](https://github.com/stalwartlabs/stalwart/releases) for security updates
+- Join our community channels for security announcements
+- Enable GitHub notifications for security advisories
+
+**Update Process:**
+- Security updates are published as patch releases (e.g., 0.12.1 → 0.12.2)
+- Critical vulnerabilities may receive out-of-band releases
+- Docker images are updated simultaneously with releases
+- Security advisories are published through GitHub Security Advisories
+
+## Contact Information
+
+- **Security reports**: security@stalw.art
+- **General inquiries**: hello@stalw.art
+- **PGP Key**: Available upon request for sensitive communications
+
+## Additional Resources
+
+- [Stalwart Security Incident Response Process](SECURITY_PROCESS.md)
+- [Security Configuration Guide](https://stalw.art/docs/install/security)
+- [Rust Security Advisory Database](https://rustsec.org/)
+
+*This security policy is effective as of June 20, 2025 and may be updated periodically. Check back regularly for updates.*
+
diff --git a/SECURITY_PROCESS.md b/SECURITY_PROCESS.md
new file mode 100644
index 0000000..b32e96b
--- /dev/null
+++ b/SECURITY_PROCESS.md
@@ -0,0 +1,173 @@
+# Stalwart Security Incident Response Checklist
+
+## Phase 1 : Initial Assessment & Validation
+
+### Updates
+
+<< Use this section to detail the report received, initial assessment, and validation results >>
+
+Example:
+
+I've reviewed the security report and confirmed this vulnerability exists in Stalwart version X.Y.Z.
+
+Assessment of exploitability:
+
+- Attack complexity: [High/Medium/Low]
+- Prerequisites: [Authentication required/Network access/Specific configuration/etc.]
+- User interaction required: [Yes/No]
+
+Potential impact:
+- Email data confidentiality: [At risk/Not affected]
+- Server integrity: [At risk/Not affected]
+- Service availability: [At risk/Not affected]
+- Estimated affected installations: [Number/Percentage]
+
+### Resources
+
+- [Stalwart Security Policy](https://github.com/stalwartlabs/stalwart/blob/main/SECURITY.md)
+- [CVE Scoring Calculator](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator)
+- [Rust Security Advisory Database](https://rustsec.org/)
+
+### Tasks
+
+- [ ] Reproduce the vulnerability in test environment
+- [ ] Assess CVSS score and severity level
+- [ ] Check if vulnerability affects current stable version
+- [ ] Check if vulnerability affects LTS versions (if applicable)
+- [ ] Determine if this requires immediate action or can wait for next release cycle
+- [ ] Document technical details and root cause
+
+### Assessment Summary
+
+- **Severity Level**: `Critical|High|Medium|Low`
+- **CVSS Score**: `X.X`
+- **Affects versions**: `X.Y.Z to X.Y.Z`
+- **Root cause**: Brief technical explanation
+- **Introduced in commit/version**: `commit-hash` or `vX.Y.Z`
+- **Attack vector**: `Network|Local|Physical`
+- **Estimated timeline for fix**: `X days/weeks`
+
+## Phase 2: Immediate Response & Mitigation
+
+### Updates
+
+<< Document immediate actions taken and mitigation strategies >>
+
+Example:
+
+Working on hotfix for version X.Y.Z. Temporary workaround available by disabling [feature] in configuration.
+
+### Tasks
+
+- [ ] Implement immediate workaround if possible
+- [ ] Update security advisory draft
+- [ ] Prepare patch/hotfix
+- [ ] Test fix thoroughly in development environment
+- [ ] Prepare updated Docker images and binaries
+- [ ] Draft security advisory for GitHub Security Advisories
+- [ ] Consider if coordinated disclosure timeline needs adjustment
+
+### Mitigation Details
+
+- **Workaround available**: `Yes|No` - If yes, describe briefly
+- **Fix implemented on**: `YYYY-MM-DD`
+- **Patch/hotfix version**: `vX.Y.Z`
+- **GitHub Security Advisory ID**: `GHSA-XXXX-XXXX-XXXX`
+
+## Phase 3: Impact Assessment & User Analysis
+
+### Updates
+
+<< Analysis of potential impact on the Stalwart deployments >>
+
+Based on telemetry data and version statistics, approximately X installations may be affected.
+
+### Tasks
+
+- [ ] Analyze version adoption from update checks (if available)
+- [ ] Estimate number of vulnerable installations
+- [ ] Assess if default configurations are vulnerable
+- [ ] Review if vulnerability has been exploited (check logs, reports)
+- [ ] Determine if any user data may have been compromised
+- [ ] Check for indicators of active exploitation in the wild
+
+### Analysis Notes
+
+_Document your impact assessment process and findings_
+
+### Impact Summary
+
+- **Estimated vulnerable installations**: `~X out of Y`
+- **Default configuration vulnerable**: `Yes|No`
+- **Evidence of exploitation**: `Found|Not found|Unknown`
+- **User data potentially at risk**: `Email content|Credentials|Configuration|None`
+- **Confidence in assessment**: `High|Medium|Low`
+
+## Phase 4: Communication & Release
+
+### Updates
+
+<< Communication strategy and release timeline >>
+
+Security release vX.Y.Z will be published on YYYY-MM-DD with coordinated disclosure.
+
+### Tasks
+
+**Pre-release preparation:**
+
+- [ ] Finalize security patch
+- [ ] Prepare release notes with security details
+- [ ] Update documentation if needed
+- [ ] Test automated update mechanisms
+- [ ] Prepare GitHub Security Advisory
+
+**Communication channels:**
+
+- [ ] Draft announcement for Stalwart community forum/Discord
+- [ ] Prepare release announcement for GitHub
+- [ ] Draft security advisory content
+- [ ] Consider notification to major distributors/packagers
+
+**Release execution:**
+
+- [ ] Publish patched version to GitHub releases
+- [ ] Update Docker images on Docker Hub
+- [ ] Publish GitHub Security Advisory
+- [ ] Post to community channels (Discord/forum)
+- [ ] Update project website/documentation
+- [ ] Submit CVE request if warranted (CVSS ≥ 4.0)
+
+**Post-release:**
+
+- [ ] Monitor community channels for questions
+- [ ] Track adoption of security update
+- [ ] Follow up on any additional reports
+- [ ] Document lessons learned
+
+### Communication Record
+
+- **Security release published**: `YYYY-MM-DD HH:MM UTC`
+- **GitHub Security Advisory**: `GHSA-XXXX-XXXX-XXXX`
+- **CVE ID** (if applicable): `CVE-YYYY-XXXXX`
+- **Community announcement**: [Link to forum/Discord post]
+- **Estimated time to 50% adoption**: `X days/weeks`
+
+## Post-Incident Review
+
+### What went well?
+-
+
+### What could be improved?
+-
+
+### Action items for future incidents:
+- [ ]
+- [ ]
+- [ ]
+
+### Process improvements:
+- [ ]
+- [ ]
+
+## Emergency Contacts
+- **Primary maintainer**: hello@stalw.art
diff --git a/SECURITY_TEMPLATE.md b/SECURITY_TEMPLATE.md
new file mode 100644
index 0000000..059154a
--- /dev/null
+++ b/SECURITY_TEMPLATE.md
@@ -0,0 +1,135 @@
+# Stalwart Security Advisory
+
+**CVE ID:** CVE-YYYY-NNNNN
+**Publication Date:** YYYY-MM-DD
+**Last Updated:** YYYY-MM-DD
+
+## Summary
+
+[Provide a brief, non-technical summary of the vulnerability in 1-2 sentences]
+
+## Affected Products and Versions
+
+**Product:** Stalwart Mail and Collaboration Server
+
+**Affected Versions:**
+- Version X.X.X through Y.Y.Y
+- [List specific affected version ranges]
+
+**Fixed Versions:**
+- Version Z.Z.Z and later
+- [List all versions that include the fix]
+
+## Vulnerability Details
+
+### Description
+
+[Detailed technical description of the vulnerability, including how it can be exploited]
+
+### Impact
+
+[Describe the potential impact if this vulnerability is exploited]
+
+### CVSS Score
+
+**CVSS v3.1 Base Score:** X.X ([SEVERITY])
+**Vector String:** CVSS:3.1/AV:X/AC:X/PR:X/UI:X/S:X/C:X/I:X/A:X
+
+**Severity Breakdown:**
+- **Attack Vector:** [Network/Adjacent/Local/Physical]
+- **Attack Complexity:** [Low/High]
+- **Privileges Required:** [None/Low/High]
+- **User Interaction:** [None/Required]
+- **Scope:** [Unchanged/Changed]
+- **Confidentiality Impact:** [None/Low/High]
+- **Integrity Impact:** [None/Low/High]
+- **Availability Impact:** [None/Low/High]
+
+### CWE Classification
+
+**CWE-XXX:** [Weakness Name]
+
+## Technical Details
+
+### Root Cause
+
+[Explain the underlying cause of the vulnerability]
+
+### Attack Scenario
+
+[Describe a realistic attack scenario or proof of concept, without providing exploit code]
+
+### Prerequisites
+
+[List any conditions that must be met for successful exploitation]
+
+## Remediation
+
+### Recommended Actions
+
+1. **Immediate:** Upgrade to version Z.Z.Z or later
+2. **Short-term:** [Any temporary mitigation measures]
+3. **Long-term:** [Any additional security hardening recommendations]
+
+### Upgrade Instructions
+
+```bash
+# Example upgrade commands
+[Provide specific upgrade instructions for Stalwart]
+```
+
+### Workarounds
+
+[If applicable, describe any temporary workarounds for systems that cannot be immediately upgraded]
+
+**Note:** Workarounds are temporary measures and do not fully resolve the vulnerability. Upgrading is strongly recommended.
+
+## Detection
+
+### Indicators of Compromise
+
+[List any logs, patterns, or indicators that may suggest exploitation attempts]
+
+### Log Entries
+
+```
+[Example log entries that administrators should look for]
+```
+
+## Timeline
+
+- **YYYY-MM-DD:** Vulnerability discovered [by researcher/team name]
+- **YYYY-MM-DD:** Vendor notified
+- **YYYY-MM-DD:** Vendor acknowledged issue
+- **YYYY-MM-DD:** Fix developed and tested
+- **YYYY-MM-DD:** Fixed version released
+- **YYYY-MM-DD:** Public disclosure
+
+## Credits
+
+This vulnerability was discovered by [Researcher Name / Organization].
+
+## References
+
+- Stalwart Mail Server: https://stalw.art/
+- CVE Entry: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-YYYY-NNNNN
+- GitHub Advisory: [Link to GitHub Security Advisory if applicable]
+- Release Notes: [Link to release notes with fix]
+
+## Contact Information
+
+For questions or concerns regarding this advisory, please contact:
+
+**Security Team:** hello@stalw.art
+**Website:** https://stalw.art
+
+To report security vulnerabilities in Stalwart, please follow our [responsible disclosure policy](https://github.com/stalwartlabs/stalwart/security/policy).
+
+## Disclaimer
+
+This advisory is provided "as is" without warranty of any kind. The information contained in this advisory is subject to change without notice.
+
+---
+
+**Document Version:** 1.0
+**Classification:** Public
diff --git a/UPGRADING/v0_04.md b/UPGRADING/v0_04.md
new file mode 100644
index 0000000..e316dcb
--- /dev/null
+++ b/UPGRADING/v0_04.md
@@ -0,0 +1,49 @@
+# Upgrading from `v0.4.0` to `v0.4.x`
+
+- Replace the binary with the new version.
+- Restart the service.
+
+# Upgrading from `v0.3.x` to `v0.4.0`
+
+## What's changed
+
+- **Configuration File Split:** While the `config.toml` configuration file format hasn't changed much, the new version has divided it into multiple sub-files. These sub-files are now included from the new `config.toml`. This division was implemented because the config file had grown significantly, and splitting it improves organization.
+
+- **Changes in the Sieve Interpreter Attribute Names:**
+ - The configuration key prefix `jmap.sieve` (JMAP Sieve Interpreter) has been renamed to `sieve.untrusted`.
+ - The configuration key prefix `sieve` (SMTP Sieve Interpreter) has been renamed to `sieve.trusted`.
+
+## What's been added
+
+- **SPAM Filter Module:** The most notable addition in this version is the SPAM filter module. It comprises:
+ - A TOML configuration file located at `etc/smtp/spamfilter.toml`.
+ - A set of Sieve scripts in `etc/spamfilter/scripts`.
+ - Lookup maps in `etc/spamfilter/maps`.
+
+- **New Configuration Key:** A new key `resolver.public-suffix` has been added. This specifies the URL of the list of public suffixes.
+
+## Migration Steps
+
+1. **Backup:** Ensure you have a backup of your current `config.toml` file.
+2. **Download Configuration Bundle:** Fetch the new configuration bundle from [this link](https://get.stalw.art/resources/config.zip). Unpack it under `BASE_DIR/etc` (for example `/opt/stalwart-mail/etc`).
+3. **Update Configuration Files:** Modify the following files with your domain name, host name, certificate paths, DKIM signatures, and so on:
+ - `etc/config.toml`
+ - `etc/jmap/store.toml`
+ - `etc/jmap/oauth.toml`
+ - `etc/smtp/signature.toml`
+ - `etc/common/tls.toml`
+4. **Adjust included files:** If you are using an LDAP directory for authentication, edit `etc/config.toml` and replace the `etc/directory/sql.toml` include with `etc/directory/ldap.toml`.
+5. **Configure the SPAM Filter Database:** Set up and configure the SPAM filter database. More details can be found [here](https://stalw.art/docs/spamfilter/settings/database).
+6. **Review All TOML Files:** Navigate to every TOML file under the `etc/` directory and make necessary changes.
+7. **Update Binary:** Download and substitute the v0.4.0 binary suitable for your platform from [here](https://github.com/stalwartlabs/mail-server/releases/tag/v0.4.0).
+8. **Restart Service:** Conclude by restarting the Stalwart service.
+
+### Alternative Method:
+
+1. **Separate Installation:** Install v0.4.0 in a distinct directory. This will auto-update all configuration files and establish the spam filter database in SQLite format.
+2. **Move Configuration Files:** Transfer the configuration files from `etc/` and the SQLite spam filter database from `data/` to your current installation's directory.
+3. **Replace Binary:** Move the binary from the `bin/` directory to your current installation's `data/` directory.
+4. **Restart Service:** Finally, restart the Stalwart service.
+
+
+We apologize for the lack of an automated migration tool for this upgrade. However, we are planning on introducing an automated migration tool in the near future. Thank you for your understanding and patience.
diff --git a/UPGRADING/v0_05.md b/UPGRADING/v0_05.md
new file mode 100644
index 0000000..ef4ca39
--- /dev/null
+++ b/UPGRADING/v0_05.md
@@ -0,0 +1,63 @@
+# Upgrading from `v0.5.2` to `v0.5.3`
+
+- The following configuration attributes have been renamed, see [store.toml](https://github.com/stalwartlabs/mail-server/blob/main/resources/config/common/store.toml) for an example:
+ - `jmap.store.data` -> `storage.data`
+ - `jmap.store.fts` -> `storage.fts`
+ - `jmap.store.blob` -> `storage.blob`
+ - `jmap.encryption.*` -> `storage.encryption.*`
+ - `jmap.spam.header` -> `storage.spam.header`
+ - `jmap.fts.default-language` -> `storage.fts.default-language`
+ - `jmap.cluster.node-id` -> `storage.cluster.node-id`
+ - `management.directory` and `sieve.trusted.default.directory` -> `storage.directory`
+ - `sieve.trusted.default.store` -> `storage.lookup`
+- Proxy networks are now configured under `server.proxy.trusted-networks` rather than `server.proxy-trusted-networks`. IP addresses/masks have to be defined within a set (`{}`) rather than a list (`[]`), see [server.toml](https://github.com/stalwartlabs/mail-server/blob/main/resources/config/common/server.toml) for an example.
+
+
+# Upgrading from `v0.5.1` to `v0.5.2`
+
+- Make sure that implicit TLS is enabled for the JMAP [listener](https://stalw.art/docs/server/listener) configured under `ets/jmap/listener.toml`:
+ ```toml
+ [server.listener."jmap".tls]
+ implicit = true
+ ```
+- Optional: Enable automatic TLS with [ACME](https://stalw.art/docs/server/tls/acme).
+- Replace the binary with the new version.
+- Restart the service.
+
+# Upgrading from `v0.5.0` to `v0.5.1`
+
+- Replace the binary with the new version.
+- Restart the service.
+
+# Upgrading from `v0.4.x` to `v0.5.0`
+
+## What's changed
+
+- **Database Layout**: Version 0.5.0 utilizes a different database layout which is more efficient and allows multiple backends to be supported. For this reason, the database must be migrated to the new layout.
+- **Configuration file changes**: The configuration file has been updated to support multiple stores, most configuration attributes starting with `store.*` and `directory.*` need to be reviewed.
+- **SPAM filter**: Sieve scripts that interact with databases need to be updated. The functions `lookup` and `lookup_map` has been renamed to `key_exists` and `key_get`. It is recommended to replace all scripts with the new versions rather than updating them manually. Additionally, the SPAM database no longer requires an SQL server, it can now be stored in Redis or any of the supported databases.
+- **Directory superusers**: Due to problems and confusion with the `superuser-group` attribute, the concept of a superuser group has been removed. Instead, a new attribute `type` has been added to external directories. The value of this attribute can be `individual`, `group` or `admin`. The `admin` type is equivalent to the old superuser group. The `type` attribute is required for all principals in the directory, it defaults to `individual` if not specified.
+- **Purge schedules**: The attributes `jmap.purge.schedule.db` and `jmap.purge.schedule.blobs` have been removed. Instead, the purge frequency is now specified per store in `store..purge.frequency`. The attribute `jmap.purge.schedule.sessions` has been renamed to `jmap.purge.sessions.frequency`.
+
+## What's been added
+
+- **Multiple stores**: The server now supports multiple stores to be defined in the configuration file under `store.`. Which store to use is defined in the `jmap.store.data`, `jmap.store.fts` and `jmap.store.blob` settings.
+- **More backend options**: It is now possible to use `RocksDB`, `PostgreSQL` and `MySQL` as data stores. It is also now possible to store blobs in any of the supported databases instead of being limited to the filesystem or an S3-compatible storage. Full-text indexing can now be done using `Elasticsearch` and the Spam database stored in `Redis`.
+- **Internal Directory**: The server now has an internal directory that can be used to store user accounts, passwords and group membership. This directory can be used instead of an external directory such as LDAP or SQL.
+- **New settings**: When running Stalwart in a cluster, `jmap.cluster.node-id` allows to specify a unique identifier for each node. Messages containing the SPAM headers defined in `jmap.spam.header` are moved automatically to the user's Junk Mail folder.
+- **Default Sieve stores**: For Sieve scripts such as the Spam filter that require access to a directory and a lookup store, it is now possible to configure the default lookup store and directory using the `sieve.trusted.default.directory` and `sieve.trusted.default.store` settings.
+
+## Migration Steps
+
+Rather than manually updating the configuration file, it is recommended to start with a fresh configuration file and update it with the necessary settings:
+
+- Install `v0.5.0` in a distinct directory. You now have the option to use an [internal directory](https://stalw.art/docs/directory/types/internal), which will allow you to manage users and groups directly from Stalwart server. Alternatively, you can continue to use an external directory such as LDAP or SQL.
+- Update the configuration files with your previous settings. All configuration attributes are backward compatible, except those starting with `store.*`, `directory.*` and `jmap.purge.*`.
+- Export each account following the procedure described in the [migration guide](https://stalw.art/docs/management/database/migrate).
+- Stop the old `v0.4.x` server.
+- If there are messages pending to be delivered in the SMTP queue, move the `queue` directory to the new installation.
+- Start the new `v0.5.0` server.
+- Import each account following the procedure described in the [migration guide](https://stalw.art/docs/management/database/migrate).
+
+
+Once again, we apologize for the lack of an automated migration tool for this upgrade. However, we are planning on introducing an automated migration tool once the web-admin is released in Q1 2024. Thank you for your understanding and patience.
diff --git a/UPGRADING/v0_06.md b/UPGRADING/v0_06.md
new file mode 100644
index 0000000..d7c137f
--- /dev/null
+++ b/UPGRADING/v0_06.md
@@ -0,0 +1,7 @@
+# Upgrading from `v0.5.3` to `v0.6.0`
+
+- In order to support [expressions](https://stalw.art/docs/configuration/expressions/overview), version `0.6.0` introduces multiple breaking changes in the SMTP server configuration file. It is recommended to download the new SMTP configuration files from the [repository](https://github.com/stalwartlabs/mail-server/tree/main/resources/config/smtp), make any necessary changes and replace the old files under `INSTALL_DIR/etc/smtp` with the new ones.
+- If you are using custom subaddressing of catch-all rules, you'll need to replace these rules with expressions. Check out the updated [syntax](https://stalw.art/docs/directory/addresses).
+- Message queues are now distributed and stored in the backend specified by the `storage.data` and `storage.blob` settings. Make sure to flush your SMTP message queue before upgrading to `0.6.0` to avoid losing any outgoing messages pending delivery.
+- Replace the binary with the new version.
+- Restart the service.
diff --git a/UPGRADING/v0_07.md b/UPGRADING/v0_07.md
new file mode 100644
index 0000000..3ebef59
--- /dev/null
+++ b/UPGRADING/v0_07.md
@@ -0,0 +1,33 @@
+# Upgrading from `v0.6.0` to `v0.7.0`
+
+Version `0.7.0` of Stalwart introduces significant improvements and features that enhance performance and functionality. However, it also comes with multiple breaking changes in the configuration files and a revamped database layout optimized for accessing large mailboxes. Additionally, Stalwart now supports compression for binaries stored in the blob store, further increasing efficiency.
+Due to these extensive changes, the recommended approach for upgrading is to perform a clean reinstallation of Stalwart and manually migrate your accounts to the new version.
+
+## Pre-Upgrade Steps
+- Download the `v0.7.0` mail-server and CLI binaries for your platform from the [releases page](https://github.com/stalwartlabs/mail-server/releases/latest/).
+- Initialize the setup on a distinct directory using the command `sudo ./stalwart-mail --init /path/to/new-install`. This command will print the administrator password required to access the web-admin.
+- Create the `bin` directory using `mkdir /path/to/new-install/bin`.
+- Move the downloaded binaries to the `bin` directory using the command `mv stalwart-mail stalwart-cli /path/to/new-install/bin`.
+- Open `/path/to/new-install/etc/config.toml` in a text editor and comment out all listeners except the HTTP listener for port `8080`.
+- Start the new installation from the terminal using the command `sudo /path/to/new-install/bin/stalwart-mail --config /path/to/new-install/etc/config.toml`.
+- Point your browser to the web-admin at `http://yourserver.org:8080` and login using the auto-generated administrator password.
+- Configure the new installation with your domain, hostname, certificates, and other settings following the instructions at [stalw.art/docs/get-started](https://stalw.art/docs/get-started). Ignore the part about using the installation script, we are performing a manual installation.
+- Add your user accounts.
+- Configure Stalwart to run as the `stalwart-mail` user and `stalwart-mail` group from `Settings` > `Server` > `System`. This is not necessary if you are using Docker.
+- Stop the new installation by pressing `Ctrl+C` in the terminal.
+
+## Upgrade Steps
+- On your `v0.6.0` installation, open in a text editor the `smtp/listener.toml`, `imap/listener.toml` files and comment out all listeners except the JMAP/HTTP listener (we are going to need it to export the user accounts) and then restart the service.
+- If you are using an external store, backup the database using the appropriate method for your database system.
+- Create the `~/exports` directory, here we will store the exported accounts.
+- Using the existing CLI tool (not the one you just downloaded as it is not compatible), export each user account using the command `./stalwart-cli -u https://your-old-server.org -c export account ~/exports`.
+- Stop the `v0.6.0` installation using the command `sudo systemctl stop stalwart-mail`.
+- Move the old `v0.6.0` installation to a backup directory, for example `mv /opt/stalwart-mail /opt/stalwart-mail-backup`.
+- Move the new `v0.7.0` installation to the old installation directory, for example `mv /path/to/new-install /opt/stalwart-mail`.
+- Set the right permissions for the new installation using the command `sudo chown -R stalwart-mail:stalwart-mail /opt/stalwart-mail`.
+- Start the new installation using the command `sudo systemctl start stalwart-mail`.
+- Import the accounts using the new CLI tool with the command `./stalwart-cli -u http://yourserver.org:8080 -c import account ~/exports/`.
+- Using the admin tool, reactivate all the necessary listener (SMTP, IMAP, etc.)
+- Restart the service using the command `sudo systemctl restart stalwart-mail`.
+
+We apologize for the complexity of the upgrade process associated with this version of Stalwart. We understand the challenges and inconveniences that the requirement for a clean reinstallation and manual account migration poses. Moving forward, an automated migration tool will be included in any future releases that necessitate changes to the database layout, aiming to streamline the upgrade process for you. Furthermore, as we approach the milestone of version 1.0.0, we anticipate that such foundational changes will become increasingly infrequent, leading to more straightforward updates. We appreciate your patience and commitment to Stalwart during this upgrade.
diff --git a/UPGRADING/v0_08.md b/UPGRADING/v0_08.md
new file mode 100644
index 0000000..e240bbf
--- /dev/null
+++ b/UPGRADING/v0_08.md
@@ -0,0 +1,86 @@
+# Upgrading from `v0.7.3` to `v0.8.0`
+
+Version `0.8.0` includes both performance and security enhancements that require your data to be migrated to a new database layout. Luckily version `0.7.3` includes a migration tool which should make this process much easier than previous upgrades. In addition to the new layout, you will have to change the systemd service file to use the `CAP_NET_BIND_SERVICE` capability.
+
+## Preparation
+- Upgrade to version `0.7.3` if you haven't already. If you are on a version previous to `0.7.0`, you will have to do a manual migration of your data using the Command-line Interface.
+- Create a directory where your data will be exported to, for example `/opt/stalwart-mail/export`.
+
+## Systemd service upgrade (Linux only)
+- Stop the `v0.7.3` installation:
+ ```bash
+ $ sudo systemctl stop stalwart-mail
+ ```
+- Update your systemd file to include the `CAP_NET_BIND_SERVICE` capability. Open the file `/etc/systemd/system/stalwart-mail.service` in a text editor and add the following lines under the `[Service]` section:
+ ```
+ User=stalwart-mail
+ Group=stalwart-mail
+ AmbientCapabilities=CAP_NET_BIND_SERVICE
+ ```
+- Reload the daemon:
+ ```bash
+ $ systemctl daemon-reload
+ ```
+- Do not start the service yet.
+
+## Data migration
+- Stop Stalwart and export your data:
+
+ ```bash
+ $ sudo systemctl stop stalwart-mail
+ $ sudo /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export
+ $ sudo chown -R stalwart-mail:stalwart-mail /opt/stalwart-mail/export
+ ```
+
+ or, if you are using the Docker image:
+
+ ```bash
+ $ docker stop stalwart-mail
+ $ docker run --rm -v :/opt/stalwart-mail -it stalwart-mail /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export
+ ```
+- Backup your `v0.7.3` installation:
+ - If you are using RocksDB or SQLite, simply rename the `data` directory to `data-backup`, for example:
+ ```bash
+ $ mv /opt/stalwart-mail/data /opt/stalwart-mail/data-backup
+ $ mkdir /opt/stalwart-mail/data
+ $ chown stalwart-mail:stalwart-mail /opt/stalwart-mail/data
+ ```
+ - If you are using PostgreSQL, rename the database and create a blank database with the same name, for example:
+ ```sql
+ ALTER DATABASE stalwart RENAME TO stalwart_old;
+ CREATE database stalwart;
+ ```
+ - If you are using MySQL, rename the database and create a blank database with the same name, for example:
+ ```sql
+ CREATE DATABASE stalwart_old;
+ RENAME TABLE stalwart.b TO stalwart_old.b;
+ RENAME TABLE stalwart.v TO stalwart_old.v;
+ RENAME TABLE stalwart.l TO stalwart_old.l;
+ RENAME TABLE stalwart.i TO stalwart_old.i;
+ RENAME TABLE stalwart.t TO stalwart_old.t;
+ RENAME TABLE stalwart.c TO stalwart_old.c;
+ DROP DATABASE stalwart;
+ CREATE database stalwart;
+ ```
+ - If you are using FoundationDB, backup your database and clean the entire key range.
+- Download the `v0.8.0` mail-server for your platform from the [releases page](https://github.com/stalwartlabs/mail-server/releases/latest/) and replace the binary in `/opt/stalwart-mail/bin`. If you are using the Docker image, pull the latest image.
+- Import your data:
+
+ ```bash
+ $ sudo -u stalwart-mail /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --import /opt/stalwart-mail/export
+ ```
+
+ or, if you are using the Docker image:
+
+ ```bash
+ $ docker run --rm -v :/opt/stalwart-mail -it stalwart-mail /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --import /opt/stalwart-mail/export
+ ```
+- Start the service:
+ ```bash
+ $ sudo systemctl start stalwart-mail
+ ```
+
+ Or, if you are using the Docker image:
+ ```bash
+ $ docker start stalwart-mail
+ ```
\ No newline at end of file
diff --git a/UPGRADING/v0_09.md b/UPGRADING/v0_09.md
new file mode 100644
index 0000000..8c92fd4
--- /dev/null
+++ b/UPGRADING/v0_09.md
@@ -0,0 +1,9 @@
+# Upgrading from `v0.8.x` to `v0.9.0`
+
+Version `0.9.0` introduces significant internal improvements while maintaining compatibility with existing database layouts and configuration file formats from version `0.8.0`. As a result, no data or configuration migration is necessary. This release focuses on enhancing performance and functionality, particularly in logging and tracing capabilities.
+
+To upgrade to Stalwart version `0.9.0` from `0.8.x`, begin by downloading the latest version of the `stalwart-mail` binary. Once downloaded, replace the existing binary with the new version. Additionally, it's important to update the WebAdmin interface to the latest version to ensure compatibility and to access new features introduced in this release.
+
+In terms of breaking changes, this release brings significant updates to webhooks. All webhook event names have been modified, requiring a thorough review and adjustment of existing webhook configurations. Furthermore, the update introduces hundreds of new event types, enhancing the granularity and specificity of event handling capabilities. Users should familiarize themselves with these changes to effectively integrate them into their systems.
+
+The reason for this release being classified as a major version, despite the absence of changes to the database or configuration formats, is the complete rewrite of the logging and tracing layer. This overhaul substantially improves the efficiency and speed of generating detailed tracing and logging events, making the system more robust and facilitating easier debugging and monitoring.
diff --git a/UPGRADING/v0_10.md b/UPGRADING/v0_10.md
new file mode 100644
index 0000000..d0f32a6
--- /dev/null
+++ b/UPGRADING/v0_10.md
@@ -0,0 +1,37 @@
+
+# Upgrading from `v0.9.x` to `v0.10.0`
+
+## Important Notes
+
+- In version `0.10.0` accounts are associated with roles and permissions, which define what resources they can access. The concept of administrator or super user accounts no longer exists, now there is a single account type (the `individual` principal) which can be assigned the `admin` role or custom permissions to have administrator access.
+- Due to the changes in the database layout in order to support roles and permissions, the database must be migrated to the new layout. The migration is automatic and should not require any manual intervention.
+- While the database migration is automatic, it's recommended to **back up your data** before upgrading.
+- The webadmin must be upgraded **before** the mail server to maintain access post-upgrade. This is true even if you run Stalwart in Docker.
+
+## Step-by-Step Upgrade Process
+
+- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`.
+- Stop Stalwart and backup your data:
+
+ ```bash
+ $ sudo systemctl stop stalwart-mail
+ $ sudo /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export
+ $ sudo chown -R stalwart-mail:stalwart-mail /opt/stalwart-mail/export
+ ```
+
+ or, if you are using the Docker image:
+
+ ```bash
+ $ docker stop stalwart-mail
+ $ docker run --rm -v :/opt/stalwart-mail -it stalwart-mail /usr/local/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export
+ ```
+- Download the `v0.10.0` mail-server for your platform from the [releases page](https://github.com/stalwartlabs/mail-server/releases/latest/) and replace the binary in `/opt/stalwart-mail/bin`. If you are using the Docker image, pull the latest image.
+- Start the service:
+ ```bash
+ $ sudo systemctl start stalwart-mail
+ ```
+
+ Or, if you are using the Docker image:
+ ```bash
+ $ docker start stalwart-mail
+ ```
diff --git a/UPGRADING/v0_11.md b/UPGRADING/v0_11.md
new file mode 100644
index 0000000..9a30390
--- /dev/null
+++ b/UPGRADING/v0_11.md
@@ -0,0 +1,13 @@
+
+# Upgrading from `v0.10.x` to `v0.11.0`
+
+Version `0.11.0` introduces breaking changes to the spam filter configuration. Although no data migration is required, if changes were made to the previous spam filter, the configuration of the new spam filter should be reviewed. In particular:
+
+- `lookup.spam-*` settings are no longer used, these have been replaced by `spam-filter.*` settings. Review the [updated documentation](http://stalw.art/docs/spamfilter/overview).
+- Previous `spam-filter` and `track-replies` Sieve scripts cannot be used with the new version. They have been replaced by a built-in spam filter written in Rust.
+- Cache settings have changed, see the [documentation](https://stalw.art/docs/server/cache) for details.
+- Support for Pipes was removed in favor of MTA hooks and Milter.
+- `config.resource.spam-filter` is now `spam-filter.resource`.
+- `config.resource.webadmin` is now `webadmin.resource`.
+- `authentication.rate-limit` was removed as security is handled by fail2ban.
+
diff --git a/UPGRADING/v0_12.md b/UPGRADING/v0_12.md
new file mode 100644
index 0000000..46d3a54
--- /dev/null
+++ b/UPGRADING/v0_12.md
@@ -0,0 +1,68 @@
+
+# Upgrading from `v0.11.x` to `v0.12.x`
+
+## Important Notes
+
+Version `0.12.x` introduces significant improvements such as zero-copy deserialization which make the new database layout incompatible with the previous version. As a result, the database must be migrated to the new layout. The migration is done automatically on startup and should not require any manual intervention. However, it is highly recommended to **back up your data** before upgrading since it is not possible to downgrade the database once it has been migrated. You may also want to run a mock migration before upgrading to ensure that everything works as expected.
+
+In addition to the database layout changes, multiple settings were renamed:
+
+- `server.http.*` to `http.*`.
+- `jmap.folders.*` to `email.folders.*`.
+- `jmap.account.purge.frequency` to `account.purge.frequency`.
+- `jmap.email.auto-expunge` to `email.auto-expunge`.
+- `jmap.protocol.changes.max-history` to `changes.max-history`.
+- `storage.encryption.*` to `email.encryption.*`.
+
+## Step-by-Step Upgrade Process
+
+- Stop Stalwart in **every single node of your cluster**. If you are using the systemd service, you can do this with the following command:
+
+ ```bash
+ $ sudo systemctl stop stalwart-mail
+ ```
+
+- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the [built-in migration utility](https://stalw.art/docs/management/migration) to export your data to a file. For example:
+
+ ```bash
+ $ sudo /opt/stalwart-mail/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export
+ $ sudo chown -R stalwart-mail:stalwart-mail /opt/stalwart-mail/export
+ ```
+
+- Download the `v0.12.x` binary for your platform (which is now called `stalwart` rather than `mail-server`) from the [releases page](https://github.com/stalwartlabs/stalwart/releases/latest/) and replace the binary in `/opt/stalwart-mail/bin`. If you rename the binary from `stalwart` to `stalwart-mail`, you can keep the same systemd service file, otherwise you will need to update the service file to point to the new binary name.
+
+- Start the service. In a cluster, you can speed up the migration process by starting all nodes at once.
+ ```bash
+ $ sudo systemctl start stalwart-mail
+ ```
+
+- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`.
+
+## Step-by-Step Upgrade Process (Docker)
+
+- Stop the Stalwart container in **every single node of your cluster**. If you are using Docker, you can do this with the following command:
+
+ ```bash
+ $ docker stop stalwart-mail
+ ```
+
+- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the `--export` command to export your data to a file. For example:
+
+ ```bash
+ $ docker run --rm -v :/opt/stalwart-mail -it stalwart-mail /usr/local/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml --export /opt/stalwart-mail/export
+ ```
+
+- The Docker image location has now changed to `stalwartlabs/stalwart` instead of `stalwartlabs/mail-server`. Pull the latest image and configure it to use your existing data directory:
+
+ ```bash
+ $ docker run -d -ti -p 443:443 -p 8080:8080 \
+ -p 25:25 -p 587:587 -p 465:465 \
+ -p 143:143 -p 993:993 -p 4190:4190 \
+ -p 110:110 -p 995:995 \
+ -v :/opt/stalwart \
+ --name stalwart stalwartlabs/stalwart:latest
+ ```
+
+- Since the mount point has changed from `/opt/stalwart-mail` to `/opt/stalwart`, you will need to update your Stalwart's configuration file to reflect this change. Open the file `/opt/stalwart/etc/config.toml` and update the paths accordingly.
+
+- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`.
diff --git a/UPGRADING/v0_13.md b/UPGRADING/v0_13.md
new file mode 100644
index 0000000..ef5a039
--- /dev/null
+++ b/UPGRADING/v0_13.md
@@ -0,0 +1,59 @@
+# Upgrading from `v0.12.x` (and `v0.11.x`) to `v0.13.x`
+
+## Important Notes
+
+Version `0.13.x` introduces a significant redesign of the MTA’s delivery and queueing subsystem. This includes a transition to a new message queue serialization format and a move to a strategy-based configuration model for routing, scheduling, and delivery control. Upon first launch of version `0.13.0`, any messages currently in the outbound queue will be automatically migrated to the new format. This migration is handled internally and does not require manual intervention.
+
+However, if your deployment includes custom routing rules or queueing logic, it is important to manually reconfigure those settings using the new strategy framework. The previous configuration format for routing is no longer compatible and will need to be updated. For systems that rely solely on the default configuration, no changes are required and the upgrade should proceed without issue.
+
+Even if your system uses the default settings, it is strongly recommended to read the accompanying [blog announcement](https://stalw.art/blog/virtual-queues) and consult the [updated documentation](https://stalw.art/docs/mta/outbound/overview). These resources provide a full overview of the new delivery architecture and can help you determine whether any adjustments are needed for your environment.
+
+Before applying the upgrade to a production system, take time to familiarize yourself with the new configuration structure and validate that your delivery behavior aligns with the new model.
+
+## Step-by-Step Upgrade Process
+
+- Stop Stalwart in **every single node of your cluster**. If you are using the systemd service, you can do this with the following command:
+
+ ```bash
+ $ sudo systemctl stop stalwart
+ ```
+
+- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the [built-in migration utility](https://stalw.art/docs/management/migration) to export your data to a file. For example:
+
+ ```bash
+ $ sudo /opt/stalwart/bin/stalwart --config /opt/stalwart/etc/config.toml --export /opt/stalwart/export
+ $ sudo chown -R stalwart:stalwart /opt/stalwart/export
+ ```
+
+- Download the `v0.13.x` binary for your platform from the [releases page](https://github.com/stalwartlabs/stalwart/releases/latest/) and replace the binary in `/opt/stalwart/bin`.
+
+- Start the service. In a cluster, you can speed up the migration process by starting all nodes at once.
+ ```bash
+ $ sudo systemctl start stalwart
+ ```
+
+- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`.
+
+## Step-by-Step Upgrade Process (Docker)
+
+- Stop the Stalwart container in **every single node of your cluster**. If you are using Docker, you can do this with the following command:
+
+ ```bash
+ $ docker stop stalwart
+ ```
+
+- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the `--export` command to export your data to a file. For example:
+
+ ```bash
+ $ docker run --rm -v :/opt/stalwart -it stalwart /usr/local/bin/stalwart --config /opt/stalwart/etc/config.toml --export /opt/stalwart/export
+ ```
+
+- Pull the latest image and restart the container:
+
+ ```bash
+ $ docker pull stalwartlabs/stalwart:latest
+ $ docker start stalwart
+ ```
+
+- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`.
+
diff --git a/UPGRADING/v0_14.md b/UPGRADING/v0_14.md
new file mode 100644
index 0000000..0b3c9b7
--- /dev/null
+++ b/UPGRADING/v0_14.md
@@ -0,0 +1,48 @@
+# Upgrading from `v0.13.x` to `v0.14.x`
+
+## Binary installation
+
+- Stop Stalwart in **every single node of your cluster**. If you are using the systemd service, you can do this with the following command:
+
+ ```bash
+ $ sudo systemctl stop stalwart
+ ```
+
+- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the [built-in migration utility](https://stalw.art/docs/management/migration) to export your data to a file. For example:
+
+ ```bash
+ $ sudo /opt/stalwart/bin/stalwart --config /opt/stalwart/etc/config.toml --export /opt/stalwart/export
+ $ sudo chown -R stalwart:stalwart /opt/stalwart/export
+ ```
+
+- Download the latest binary for your platform from the [releases page](https://github.com/stalwartlabs/stalwart/releases/latest/) and replace the binary in `/opt/stalwart/bin`.
+
+- Start the service. In a cluster, you can speed up the migration process by starting all nodes at once.
+ ```bash
+ $ sudo systemctl start stalwart
+ ```
+
+- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`.
+
+## Containerized
+
+- Stop the Stalwart container in **every single node of your cluster**. If you are using Docker, you can do this with the following command:
+
+ ```bash
+ $ docker stop stalwart
+ ```
+
+- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database. If your database does not support backups, you can use the `--export` command to export your data to a file. For example:
+
+ ```bash
+ $ docker run --rm -v :/opt/stalwart -it stalwart /usr/local/bin/stalwart --config /opt/stalwart/etc/config.toml --export /opt/stalwart/export
+ ```
+
+- Pull the latest image and restart the container:
+
+ ```bash
+ $ docker pull stalwartlabs/stalwart:latest
+ $ docker start stalwart
+ ```
+
+- Upgrade the webadmin by clicking on `Manage` > `Maintenance` > `Update Webadmin`.
diff --git a/UPGRADING/v0_15.md b/UPGRADING/v0_15.md
new file mode 100644
index 0000000..f24a48f
--- /dev/null
+++ b/UPGRADING/v0_15.md
@@ -0,0 +1,156 @@
+# Upgrading from `v0.14.x` to `v0.15.x`
+
+Stalwart `v0.15.x` introduces **breaking changes** to both the **database schema** and some **configuration options**.
+Upgrading to this version **requires a schema migration**, which is performed **automatically when Stalwart starts** for the first time on `v0.15.x`.
+
+Because this migration modifies how data is stored and indexed, it is important to understand what will change, what will be migrated, and how the upgrade may impact your deployment—especially for larger installations.
+
+## What's changed
+
+Version `0.15.x` introduces significant internal improvements focused on performance, storage efficiency, and accuracy:
+
+- **Optimized database schema**: The database schema has been redesigned to use less storage space and significantly reduce the number of read and write operations required for common tasks.
+- **Rewritten search layer**: The search subsystem has been completely rewritten to use a more efficient and scalable indexing strategy.
+- **Native full-text search for SQL backends**: When using **PostgreSQL** or **MySQL** as the backend, Stalwart now leverages the database’s **native full-text search capabilities**, replacing the previous custom full-text search implementation.
+- **New spam classifier engine** : The spam classifier has been rewritten to use the **FTRL-Proximal** algorithm instead of the previous **Naive Bayes** implementation. This change improves classification accuracy, reduces memory usage, and reduces storage requirements for training data.
+
+
+## What will be migrated
+
+The migration process runs automatically at startup and will migrate the following data:
+
+- **E-mail metadata**, including flags, folders, and parsed message representations. *(The raw e-mail content stored in the blob store is not migrated.)*
+- **Encryption-at-rest settings**, which now also include a **spam training privacy option**
+- **MTA message queue metadata** *(The actual message contents are not migrated.)*
+- **Maintenance tasks**
+- **Blob links** *(The underlying blobs themselves are not migrated.)*
+- **Search indexes**, which will be **rebuilt** using the new indexing strategy
+
+## Important considerations
+
+- For deployments with **1,000 or more mailboxes**, the migration may take a **considerable amount of time**, depending on the volume of stored data.
+- During migration, **Stalwart runs in read-only mode**:
+ - No new e-mail can be received
+ - No outbound e-mail can be sent
+- It is **strongly recommended** to perform this upgrade during a **maintenance window**.
+- By default, the migration process is **multithreaded** and uses two threads for each available CPUs. You can control the number of threads by setting the following environment variable ``NUM_THREADS=``
+
+> **Note:** If you do **not** require any of the features introduced in `v0.15.x`, consider **waiting for the next major release**, which will introduce a proxy-based architecture allowing **zero-downtime upgrades**.
+
+## Upgrading steps
+
+### Binary installation
+
+- Stop Stalwart in **every single node of your cluster**. If you are using the systemd service, you can do this with the following command:
+
+ ```bash
+ $ sudo systemctl stop stalwart
+ ```
+
+- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database.
+
+- Download the latest binary for your platform from the [releases page](https://github.com/stalwartlabs/stalwart/releases/latest/) and replace the binary in `/opt/stalwart/bin`.
+
+- Start the service. In a cluster, you can speed up the migration process by starting all nodes at once.
+ ```bash
+ $ sudo systemctl start stalwart
+ ```
+
+### Containerized
+
+- Stop the Stalwart container in **every single node of your cluster**. If you are using Docker, you can do this with the following command:
+
+ ```bash
+ $ docker stop stalwart
+ ```
+
+- Backup your data following your database system's instructions. For example, if you are using RocksDB or SQLite, you can simply copy the `data` directory to a backup location. If you are using PostgreSQL or MySQL, you can use the `pg_dump` or `mysqldump` commands to create a backup of your database.
+
+- Pull the latest image and restart the container:
+
+ ```bash
+ $ docker pull stalwartlabs/stalwart:latest
+ $ docker start stalwart
+ ```
+
+## Post-upgrade steps
+
+After the upgrade and migration complete, several follow-up steps are required or recommended:
+
+- **Upgrade the webadmin**: Upgrade the webadmin interface by navigating to ``Manage → Maintenance → Update Webadmin``
+
+- **Update the spam rules**: Download and apply the latest spam rules from the webadmin ``Manage → Maintenance → Update Spam rules``
+
+- **Update search settings**: Review the updated documentation for search settings, as some configuration options have changed. In particular, the Elasticsearch backend now uses **different authentication settings** than previous versions.
+
+- **Rebuild search indexes**: All search indexes must be rebuilt to take advantage of the new indexing strategy. This can be done from the webadmin interface ``Manage → Maintenance``.
+
+- **Recalculate disk quotas for all accounts**: This step is **not required immediately**, but it is recommended to perform it at some point after the upgrade. The new version includes additional metadata in quota calculations, so recalculating ensures accurate disk usage reporting.
+ ```bash
+ $ curl -X DELETE https://myserver.org/api/store/quota/ -u : -k
+ ```
+
+- **Delete deprecated spam classifier keys**: Remove deprecated spam classifier keys from the memory store. These are the keys starting with the integer prefixes `12` to `16` and `17` to `18`:
+ - If you are using Redis:
+
+ ```bash
+ $ for code in {12..18}; do
+ char=$(printf "\\x$(printf '%02x' $code)")
+ redis-cli --scan --pattern "${char}*" | xargs -r redis-cli DEL
+ done
+ ```
+ - If you are using your database as the in-memory store:
+
+ ```bash
+ $ /opt/stalwart/bin/stalwart --config /opt/stalwart/etc/config.toml --console
+ Stalwart Server v0.15.2 Data Store CLI
+
+ > delete y\x0c\x00 y\x12\xff
+ > delete m\x0c\x00 m\x12\xff
+ > exit
+ ```
+ - If you are using your database as the in-memory store with Docker:
+
+ ```bash
+ $ docker stop stalwart
+ $ docker run -it --rm \
+ -v :/opt/stalwart \
+ --entrypoint /usr/local/bin/stalwart \
+ stalwartlabs/stalwart:latest \
+ --config /opt/stalwart/etc/config.toml --console
+ Stalwart Server v0.15.2 Data Store CLI
+
+ > delete y\x0c\x00 y\x12\xff
+ > delete m\x0c\x00 m\x12\xff
+ > exit
+
+ $ docker start stalwart
+ ```
+
+
+## Troubleshooting
+
+### Interrupted or stopped migration
+
+If the migration process is interrupted or stopped, it can be **resumed automatically** by simply restarting Stalwart.
+
+### `Data corruption detected` error
+
+If you see an error message similar to: ``Data corruption detected``. This indicates that **another node wrote data using the old format while the migration was in progress**. This usually happens when the cluster was **not fully stopped** before starting the upgrade.
+
+In order to resolve this issue, follow these steps:
+
+1. Stop **all** Stalwart nodes.
+2. Ensure **all nodes are upgraded** to `v0.15.x`.
+3. Start the nodes again.
+
+### Forcing a migration
+
+If the migration does not resume because the node responsible for it already marked it as completed, you can force migration using environment variables:
+
+- **Force re-migration of MTA queue metadata**: ``FORCE_MIGRATE_QUEUE=4``
+- **Force re-migration of blob links**: ``FORCE_MIGRATE_BLOBS=4``
+- **Force re-migration of a specific account**: ``FORCE_MIGRATE_ACCOUNT=``
+- **Force re-migration of all data**: ``FORCE_MIGRATE=4``
+
+Use these options with care and only when necessary.
diff --git a/UPGRADING/v0_16.md b/UPGRADING/v0_16.md
new file mode 100644
index 0000000..9f17156
--- /dev/null
+++ b/UPGRADING/v0_16.md
@@ -0,0 +1,605 @@
+# Upgrading from `v0.15.x` to `v0.16.x`
+
+Stalwart `v0.16.x` introduces **significant breaking changes** that make its configuration and management layer **completely incompatible** with every previous release. The database layout used to store user data (emails, calendars, contacts, files, blobs, search indexes) is **not** affected by this change, so message bodies, mailboxes, calendar events, and shared files remain on disk unchanged. What does change is **how the server is configured and managed**, and because those records live inside the same database, a multi-step migration is required.
+
+Before continuing, please read this document in full. Skipping steps will leave the server in an unrecoverable state and will require restoring from a backup.
+
+If any step below raises questions, a dedicated discussion thread for the `v0.16` upgrade is open at https://support.stalw.art. The earlier design discussion that led to these changes is also public at https://github.com/stalwartlabs/stalwart/discussions/2892 and describes the user-reported problems that motivated each breaking change.
+
+## A note on downtime
+
+Email is a critical service, and we understand that a forced maintenance window is disruptive: in some environments it is simply not an option. The breaking changes in `v0.16` are not cosmetic. Stalwart has been under continuous development for close to five years; in that time the feature set and the user base have both grown well beyond what the original configuration and management layer was designed for. The gap between what users need and what the old architecture can cleanly support has widened to the point where a redesign was unavoidable: and the redesign itself unlocks a long list of frequently-requested features that were simply not implementable under the previous model. The storage layer is untouched by all of this: emails, calendars, contacts, files, and every other piece of user data stay exactly where they are. The migration is about configuration, not about data.
+
+Operators who cannot accept downtime should **wait**. In the next two to three weeks we plan to release two tools that work together:
+
+- A **zero-downtime migration utility** that moves data (accounts, mailboxes, calendars, contacts, files) from an existing (`v0.15.x` or below) deployment to a freshly-installed `v0.16.x` deployment one account at a time, while both servers are running.
+- A **proxy** that sits in front of both deployments and routes each incoming connection to the server that currently owns that account. As accounts are migrated one by one, the proxy transparently shifts their traffic from the old deployment to the new one, so end users never notice a cutover.
+
+Together, these let operators migrate a live production deployment on an account-by-account basis with no scheduled maintenance window. When those tools are available, the instructions in this document will be superseded for most deployments. Everyone else can follow the manual steps below during a scheduled maintenance window.
+
+## What has changed
+
+### No more TOML configuration files
+
+The previous server used one or more TOML files, with some settings living on local disk and others living in the database. In `v0.16` there is a single small `config.json` on disk that describes **only** the datastore (the database Stalwart uses to keep everything else). Every other configuration and management setting: domains, accounts, mail routing, DKIM signatures, storage backends, rate limits, spam rules, and so on: is now stored inside that datastore as a **JMAP object**. JMAP ("JSON Meta Application Protocol") is the JSON-based API Stalwart uses to expose its data; treating configuration as JMAP objects means the same API that serves email metadata also serves server configuration.
+
+This change is driven by two real problems with the old model. First, in a **clustered deployment** every node had to carry its own copy of the configuration file and stay in lockstep with every other node. Divergence was easy to introduce and hard to debug, and it made distributed deployments unnecessarily fragile. Centralising everything in the database means configuration is consistent across the cluster by definition. Second, the **split between "settings in the file" and "settings in the database"** was a persistent source of user confusion: the same conceptual setting had to be documented in two places depending on where it happened to live, and administrators routinely edited the wrong one. A single unified model removes that entire category of mistake, and it gives management tooling (the WebUI and the CLI) a complete view of the system.
+
+**For Ansible, NixOS, Terraform, and other declarative tooling:** the small `config.json` is still a plain file and can be managed with existing tooling exactly as before. Everything that used to live in TOML is now managed through [`stalwart-cli apply`](https://stalw.art/docs/management/cli/apply), which accepts a declarative plan file and idempotently reconciles the live server state to match it, creating what is missing, updating what has changed, and removing what the plan no longer declares. This is the same pattern used by CockroachDB (cluster settings via SQL/CLI), Consul (KV store), Elasticsearch (`PUT /_cluster/settings`), and HashiCorp Vault (CLI/API for policies and secrets); infrastructure-as-code tooling targets the API rather than a file. The workflow becomes: commit the declarative plan to version control, deploy `config.json` through existing tooling, and invoke `stalwart-cli apply` as an idempotent step in a playbook or activation script.
+
+### REST API replaced by JMAP
+
+The `/api/...` endpoints from previous releases no longer exist. All management operations happen through **JMAP objects** reachable at `/jmap`. JMAP (RFC 8620) is a well-specified, transport-efficient protocol with first-class support for batch operations, push notifications, and fine-grained change tracking. Stalwart already speaks JMAP for email: extending it to administration gives operators and integrators a single consistent protocol for interacting with the entire server. In practice this means dozens of configuration changes can be applied in a single round-trip (the `apply` command uses this), any JMAP client library works against the management surface, and the same authentication flow covers both mail access and administration. Existing scripts and integrations that called the old REST endpoints must be updated; the new CLI is the straightforward replacement for most of them.
+
+### Account names must be email addresses
+
+Every user and group principal now has a **local part** (the name) and an associated **domain**. In previous releases an account could be a bare string such as `alice`; in `v0.16` it must be `alice@example.com`. The migration script handles this automatically: accounts without a domain are assigned the default domain of the deployment (chosen by scanning existing principals for the most common domain), so no users are lost during conversion.
+
+To avoid locking existing users out of their mail clients on the first login after the upgrade, `v0.16` **automatically appends the default domain** when a client authenticates with a bare username. Administrators running an **external directory** (LDAP, SQL, etc.), however, do need to update their directory filters to query by full email address rather than by bare account name; the old filters will no longer match.
+
+**CalDAV, CardDAV, and WebDAV clients need one manual adjustment.** These protocols use the account name as part of the URL path (for example `/dav/cal/alice`), and because the account name is now a full email address, that path changes. The `@` character is reserved in URLs and must be encoded as `%40`, so the equivalent path in `v0.16` becomes `/dav/cal/alice%40example.com`. Authentication itself still works (the server accepts the bare username and appends the default domain, as described above), but calendar, contact, and file sync will stop working until each client is reconfigured to point at the new path. It is a good idea to notify users before the upgrade so that they can update their calendar and contacts accounts in Apple Calendar, Thunderbird, DAVx⁵, and similar clients.
+
+Two reasons drove this requirement. The first is **support for multiple external directories simultaneously**: when account names are bare strings there is no reliable way to tell which directory owns a given username, whereas email addresses are naturally namespaced by domain and make that mapping unambiguous. The second, and more consequential, reason is the **PACC specification** ([draft-ietf-mailmaint-pacc](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-pacc/)): the IETF's replacement for the fragmented collection of autoconfig / autodiscover / SRV-record mechanisms that mail clients use today to discover server settings. PACC expects login names shaped like email addresses; when they are not, the server has to reveal whether a given account exists just to disambiguate the login, which is exactly the privacy leak the spec is designed to prevent. Aligning account names with email addresses is what lets Stalwart implement PACC correctly.
+
+PACC also brings OAuth into the autodiscovery flow, and because the draft originates from Apple, a correct PACC implementation is the path to supporting Apple Mail clients with OIDC and MFA: a long-standing user request that only becomes possible once this groundwork is in place.
+
+## What has been added
+
+- **A brand-new WebUI**, rewritten from scratch on top of the new JMAP-based management API.
+- **A brand-new CLI** (`stalwart-cli`) that also uses the JMAP API and can be used for day-to-day administration, scripted deployments, and infrastructure-as-code workflows. Full documentation is available at https://stalw.art/docs/management/cli.
+- **[Over one hundred feature requests and bug fixes](https://github.com/stalwartlabs/stalwart/blob/main/CHANGELOG.md#0160---2026-xx-xx)** across every subsystem.
+
+## Evaluate `v0.16` before migrating
+
+Because so much has changed, `v0.16` will feel like a different product at first contact. Concepts have been renamed, some have been removed, and several new ones have been introduced.
+
+It is **strongly recommended** that operators first install a fresh `v0.16` instance in a Docker container or a throwaway virtual machine, log into the new WebUI, and spend time becoming familiar with how configuration works in the new release. This avoids the situation where a critical production upgrade is the first time an operator sees the new interface.
+
+A second, equally important benefit: any settings created in the test deployment (directory integrations, SMTP listeners, spam rules, rate limits, TLS providers, etc.) can be exported using the [`snapshot`](https://stalw.art/docs/management/cli/overview/snapshot) command. The resulting JSON file is an `apply` plan that can be fed directly into the production instance after the migration completes. Time spent on a test deployment is not thrown away.
+
+## How the migration works
+
+The migration is a **multi-step, offline** process. At a high level:
+
+1. If the server is still on a version older than `v0.15.x`, it must first be upgraded to `v0.15.x`. The `v0.16` migration tooling does not support anything older. Operators who cannot upgrade to `v0.15.x` now should wait for the zero-downtime proxy described above, which will perform a direct migration from older releases.
+2. A Python helper script is run against the live `v0.15.x` server. It downloads the current settings and principals, converts them to the new format, and produces two files: `config.json` (the new on-disk datastore configuration) and `export.json` (a snapshot of everything else, in a format that the new CLI can replay).
+3. The `v0.15.x` server is stopped and its database is backed up.
+4. The `v0.16` binary (or Docker image) is started in **recovery mode**. On first start it detects the old data, wipes the pieces that are no longer compatible, migrates the spam classifier model, and comes up listening on a single HTTP port (`8080`) exposing the management API.
+5. `stalwart-cli apply` replays `export.json` (and, optionally, any snapshots from the test deployment) against the recovery-mode server.
+6. Recovery mode is disabled, the service manager (systemd / init.d / Docker) is reconfigured to use the new `config.json`, and the server is restarted normally.
+7. Post-migration tasks are triggered from the WebUI to recalculate disk quotas.
+
+The following sections describe each step in detail.
+
+> **Note for clustered deployments.** Before starting the migration, **every node in the cluster must be stopped**. If even one node is left running on `v0.15.x` while another is being upgraded, it will write records in the old format and cause data corruption that can only be repaired by manually deleting the offending keys. This requirement is repeated in the binary and Docker sections below, but it applies globally.
+
+## Step 1: Convert existing settings into a configuration snapshot
+
+This step is **independent of how Stalwart is deployed** and **does not require stopping the server**. The migration script talks to the running `v0.15.x` server over its management API and produces two JSON files on the machine where it is run. Running this step early is encouraged: it gives the operator a chance to review the generated files before touching the server, and to rerun the conversion with different options if needed.
+
+### Download the migration script
+
+Download the script from the Stalwart repository:
+
+```bash
+$ curl -fLO https://raw.githubusercontent.com/stalwartlabs/stalwart/refs/heads/main/resources/scripts/migrate_v016.py
+```
+
+Review the script before running it. It is a single self-contained Python file and makes no changes to the running server: it only reads configuration and principal data.
+
+### Create a Python virtual environment
+
+A virtual environment (`venv`) is a self-contained Python setup that keeps installed libraries out of the system-wide Python install. This avoids polluting the host Python and lets the script run on systems where `pip` installs are restricted.
+
+```bash
+$ python3 -m venv .venv
+$ source .venv/bin/activate
+(.venv) $ pip install requests urllib3
+```
+
+The first command creates the environment in a `.venv/` directory. The second activates it (the shell prompt usually gains a `(.venv)` prefix). The third installs the only two libraries the script needs.
+
+### Dump the live `v0.15.x` settings
+
+The script has two subcommands. The first, `dump`, connects to the running server and downloads its settings and principals into two files on disk:
+
+```bash
+(.venv) $ python migrate_v016.py dump \
+ --url https://mail.example.com \
+ --username admin \
+ --password adminPassword \
+ --settings settings.json \
+ --principals principals.json
+```
+
+Replace the URL and credentials with those of the `v0.15.x` server. The admin account must have permission to read all settings and principals. Output files default to `settings.json` and `principals.json` in the current directory. These files are plain JSON: opening them in a text editor to inspect their contents is encouraged.
+
+### Convert the dump to the new format
+
+The second subcommand, `convert`, reads the two dump files and produces the two files that the new server will consume:
+
+```bash
+(.venv) $ python migrate_v016.py convert \
+ --settings settings.json \
+ --principals principals.json \
+ --config config.json \
+ --output export.json
+```
+
+This produces:
+
+- `config.json`: the new on-disk datastore configuration. This is the file the `v0.16` server will be pointed at on startup. It is small, because it describes only the datastore (data store, blob store, search store, in-memory store).
+- `export.json`: a snapshot of every other piece of state the script could convert, in the format consumed by `stalwart-cli apply`. This file will be replayed against the `v0.16` server once it is running in recovery mode.
+
+### What the script converts: and what it does not
+
+The conversion is intentionally conservative. Only the following settings are migrated, because the rest have changed enough that automatic mapping would do more harm than good:
+
+- **User accounts, groups, and mailing lists** (with their aliases and memberships)
+- **Tenants** (in multi-tenant deployments)
+- **Domain names** (including those discovered by scanning the addresses of existing accounts)
+- **Data store, blob store, full-text search store, and in-memory store** settings
+- **DKIM signatures** (except `rsa-sha1`, which is obsolete and not supported in `v0.16`)
+- **TLS certificates** (including those issued by the built-in ACME client)
+
+**Everything else**: SMTP listeners, mail routing rules, rate limits, connection limits, spam filter settings, logging and telemetry configuration, authentication backends other than the ones listed above, session scripts, Sieve preludes, milter/MTA hook configuration, etc.: must be recreated on the new server.
+
+This is the reason the test deployment recommended above is so useful: recreating the remaining settings on a test `v0.16` instance, then using [`stalwart-cli snapshot`](https://stalw.art/docs/management/cli/snapshot) to export them, turns what would otherwise be manual post-migration work into a second `apply` run. If the production deployment is close to the defaults, this is straightforward. If it has extensive customisation, plan for the time this takes.
+
+## Step 2: Back up the database
+
+When the `v0.16` server starts for the first time, it will **wipe** the parts of the database that are no longer compatible with the new schema. No user mail is touched, but everything below is **deleted unconditionally**:
+
+- **Directory records.** Every user, group, tenant, OAuth client, domain, and mailing list record is removed. The mail itself stays in place on disk: it is the *directory entries* describing who owns it that are deleted. After the first `apply`, the new directory entries produced from `export.json` recreate these records with the same identities.
+- **All settings.** Every stored setting is deleted. `export.json` replays what the script was able to convert; anything the script could not convert needs to be recreated manually (or via a snapshot from the test deployment).
+- **DMARC, TLS, and ARF reports** (both incoming and outgoing). Report records are incompatible with the new schema.
+- **Pending tasks.** Maintenance tasks queued for background execution are discarded. `v0.16` exposes a task panel in the WebUI, and the equivalent tasks can be triggered manually from there.
+- **Telemetry data.** Metrics and trace spans are deleted.
+- **Spam training samples** (but **not** the spam classifier model itself: the model is preserved and migrated).
+- **Disk quotas.** All per-account disk-usage counters are reset to zero and must be recalculated after the migration (see Step 4).
+
+Because the wipe is irreversible, a full backup of the existing data must exist **before** the new server is started.
+
+### Embedded databases (RocksDB, SQLite)
+
+These store everything in a single directory on disk (typically `/var/lib/stalwart/data` or `/opt/stalwart/data`). A file-level copy while the server is stopped is sufficient:
+
+```bash
+$ sudo systemctl stop stalwart # or the equivalent for the service manager in use
+$ sudo cp -a /var/lib/stalwart /var/lib/stalwart.v015-backup
+```
+
+Record the path of the backup somewhere safe. If the migration fails, restoring this directory and starting the old binary returns the system to its previous state.
+
+### PostgreSQL / MySQL backends
+
+The database holds many tables, but only a subset needs to be captured to be able to undo the migration. Each table is a single ASCII character that corresponds to an internal Stalwart subspace. The destructive part of the migration touches the following tables:
+
+| Table | Purpose | Priority |
+|:---:|---|---|
+| `s` | Settings | **Critical**: contains all server configuration |
+| `d` | Directory | **Critical**: users, groups, domains, tenants, mailing lists, OAuth clients |
+| `r` | Incoming reports (DMARC, TLS, ARF) | Recommended |
+| `h` | Outgoing reports | Recommended |
+| `b` | Legacy bitmap index | Recommended |
+| `g` | Legacy full-text-search index | Recommended |
+| `j` | Legacy blob-extra metadata | Recommended |
+| `f` | Pending task queue | Recommended |
+| `u` | Quotas (partially reset) | Recommended |
+| `o` | Telemetry spans (traces) | Optional: can be very large |
+| `x` | Telemetry metrics | Optional: can be very large |
+| `w` | Legacy telemetry/spam-sample index | Optional: can be very large |
+
+The telemetry tables (`o`, `x`, `w`) can grow into tens of gigabytes on busy servers. Skipping them from the backup is reasonable unless there is a specific need to preserve historical metrics or traces.
+
+For PostgreSQL, a per-table dump looks like this:
+
+```bash
+$ pg_dump -U stalwart -d stalwart \
+ -t s -t d -t r -t h -t b -t g -t j -t f -t u \
+ -f /var/backups/stalwart-v015-critical.sql
+```
+
+The equivalent with `mysqldump`:
+
+```bash
+$ mysqldump -u stalwart -p stalwart \
+ s d r h b g j f u \
+ > /var/backups/stalwart-v015-critical.sql
+```
+
+A full database dump (`pg_dump` / `mysqldump` without the `-t` flags, or `pg_dumpall`) is the safest option if disk space allows.
+
+## Step 3: Perform the migration
+
+This step is the only one that requires downtime. The sequence has moving parts, and each moving part must complete before the next begins. Reading this entire section before starting is strongly encouraged.
+
+> **Clustered deployments:** stop **every** node before beginning. Leaving a single `v0.15.x` node running while the migration is in progress will corrupt the database.
+
+### Option A: Binary deployments (systemd / init.d)
+
+The following instructions assume the standard FHS layout (`/usr/local/bin/stalwart`, `/etc/stalwart/config.toml`, `/var/lib/stalwart`). Operators using a custom prefix (for example `/opt/stalwart`) should substitute their paths accordingly.
+
+**1. Download the `v0.16` binary.** Grab the release matching the target platform from https://github.com/stalwartlabs/stalwart/releases/latest. Do **not** replace the running binary yet.
+
+**2. Stop the old service.**
+
+- On systems with systemd:
+ ```bash
+ $ sudo systemctl stop stalwart
+ ```
+- On SysV-style systems with init.d:
+ ```bash
+ $ sudo service stalwart stop
+ ```
+
+Verify the process is gone with `ps` before continuing. In a cluster, repeat this on every node.
+
+**3. Back up the old binary and install the new one.**
+
+```bash
+$ sudo mv /usr/local/bin/stalwart /usr/local/bin/stalwart.v015
+$ sudo mv /path/to/downloaded/stalwart /usr/local/bin/stalwart
+$ sudo chmod 0755 /usr/local/bin/stalwart
+$ sudo chown root:root /usr/local/bin/stalwart
+```
+
+**4. Install the new `config.json`.** The file produced by the migration script in Step 1 goes where the old TOML configuration used to live:
+
+```bash
+$ sudo mv /path/to/config.json /etc/stalwart/config.json
+$ sudo chown stalwart:stalwart /etc/stalwart/config.json
+$ sudo chmod 0640 /etc/stalwart/config.json
+```
+
+The old `config.toml` can be kept as a reference but is no longer read by the server.
+
+**5. Start the new binary in recovery mode from the foreground.** Running the initial migration under the service manager is discouraged: if something goes wrong, the output scrolls past in `journalctl` and the restart loop masks the cause. Instead, run it directly as the `stalwart` user so that stdout and stderr are visible in the current terminal:
+
+```bash
+$ sudo -u stalwart env \
+ STALWART_RECOVERY_MODE=1 \
+ STALWART_RECOVERY_ADMIN=admin:someTemporaryPassword \
+ /usr/local/bin/stalwart --config=/etc/stalwart/config.json
+```
+
+`STALWART_RECOVERY_MODE=1` tells the server to enter the one-shot migration path: wipe the incompatible subspaces listed above, migrate the spam classifier model, and then bring up **only** the management HTTP endpoint on port `8080`. Mail ports stay closed. `STALWART_RECOVERY_ADMIN=admin:someTemporaryPassword` provisions a temporary admin credential that the CLI can authenticate against: this is needed because the converted `export.json` does not grant admin rights to any user (that is deliberate; admin assignment is a deployment decision). Replace `someTemporaryPassword` with a strong value; this account exists only until a real admin is created.
+
+The migration output will scroll past. When it finishes, the process stays in the foreground, listening on port `8080`. Leave this terminal open.
+
+**6. Apply the exported snapshot.** From a **second terminal** (on the same host or any machine that can reach the server on port `8080`), install the new CLI (*make sure to install v1.0.2 or later*): instructions at https://stalw.art/docs/management/cli/overview: and run:
+
+```bash
+$ export STALWART_URL=http://127.0.0.1:8080
+$ export STALWART_USER=admin
+$ export STALWART_PASSWORD=someTemporaryPassword
+$ stalwart-cli apply --file /path/to/export.json
+```
+
+A summary similar to the following should appear:
+
+```
+Plan: 0 destroy, 5 update, 6 create (…)
+✓ created Tenant (…)
+✓ created Domain (…)
+✓ created Account (…)
+…
+Done: 0 destroyed, 5 updated, … created (0 failed)
+```
+
+If any operation fails, the CLI stops immediately and prints the error. Fix the root cause (usually a conflict with an object created in an earlier attempt) and rerun. `apply` is re-entrant with `--continue-on-error` when needed.
+
+At this point, snapshots exported from the test deployment with `stalwart-cli snapshot` can also be applied, in order:
+
+```bash
+$ stalwart-cli apply --file /path/to/test-deployment-snapshot.json
+```
+
+**7. Shut down recovery mode.** Return to the terminal running the foreground server and press `Ctrl+C`. The process will exit cleanly.
+
+**8. Reconfigure the service manager.** The systemd unit or init.d script still references the old TOML path. Update it to point at the new JSON file:
+
+- For systemd (typically `/etc/systemd/system/stalwart.service`), locate the `ExecStart=` line and change the `--config=` argument:
+ ```ini
+ ExecStart=/usr/local/bin/stalwart --config=/etc/stalwart/config.json
+ ```
+ Then reload the unit:
+ ```bash
+ $ sudo systemctl daemon-reload
+ ```
+- For init.d (typically `/etc/init.d/stalwart`), update the `DAEMON_ARGS` line similarly.
+
+**9. Decide how to handle the recovery admin.** The recovery admin credential must be available the first time a real administrator logs in to create a proper admin account. Two options:
+
+- **Preferred, if a test deployment was used:** the test-deployment snapshot applied in step 6 can already include an administrator account, in which case no further action is needed. Start the service normally.
+- **Otherwise:** leave `STALWART_RECOVERY_ADMIN` in place until a real admin is created through the WebUI, then remove it. For systemd, set it via the environment file referenced by `EnvironmentFile=` in the service unit (the default Stalwart install creates `/etc/stalwart/stalwart.env` for exactly this purpose: uncomment the `STALWART_RECOVERY_ADMIN` line and set the value). For init.d, export the variable in `/etc/default/stalwart` or the distribution's equivalent. Do **not** set `STALWART_RECOVERY_MODE=1`: that is for the migration only and would put the server back into recovery mode at every restart.
+
+**10. Start the service.**
+
+```bash
+$ sudo systemctl start stalwart # or: sudo service stalwart start
+```
+
+Verify it comes up cleanly and is listening on its normal ports. The deployment is now on `v0.16`. For reference on how a fresh `v0.16` Linux install is expected to look, see https://stalw.art/docs/install/platform/linux.
+
+### Option B: Docker deployments
+
+The new Docker image uses **different mount points** than the old one. Where the previous image mounted a single `/opt/stalwart` volume, the new image mounts two:
+
+| Volume | Purpose |
+|:---|---|
+| `/etc/stalwart` | Configuration directory (contains `config.json`) |
+| `/var/lib/stalwart` | Persistent application data (RocksDB, local blobs, bootstrap registry) |
+
+The Docker migration uses the same recovery-mode pattern as the binary case: stop the old container, run a throwaway container in recovery mode, apply the snapshot, stop the throwaway, then start the real container.
+
+> **Clustered deployments:** stop every container running `v0.15.x` before starting the migration on any node.
+
+**1. Stop the old container.**
+
+```bash
+$ docker stop stalwart
+```
+
+**2. Prepare the new volumes.** Two named volumes (or two host directories, if bind-mounting) are required:
+
+```bash
+$ docker volume create stalwart-etc
+$ docker volume create stalwart-data
+```
+
+For deployments where the embedded database holds user mail (RocksDB / SQLite), the contents of the old `/opt/stalwart/data` directory must be placed in the new `stalwart-data` volume before starting the recovery-mode container. The simplest way is a helper container:
+
+```bash
+$ docker run --rm \
+ -v :/old \
+ -v stalwart-data:/new \
+ alpine sh -c 'cp -a /old/data/. /new/ && chown -R 2000:2000 /new'
+```
+
+Replace `` with the host path that the previous container had mounted at `/opt/stalwart`. The `chown` step is required because the new image runs as UID `2000`. For deployments that use external databases (PostgreSQL, MySQL, FoundationDB, S3, Azure, Redis, NATS), skip the copy: the data already lives outside the container.
+
+**3. Install `config.json` in the new config volume.**
+
+```bash
+$ docker run --rm \
+ -v /path/to/local/config.json:/src/config.json:ro \
+ -v stalwart-etc:/dst \
+ alpine sh -c 'cp /src/config.json /dst/config.json && chown 2000:2000 /dst/config.json'
+```
+
+> **Update embedded paths inside `config.json` and `export.json` for the new mount points.** The migration script writes the on-disk paths it found in the v0.15 deployment, which on the previous Docker image typically pointed at `/opt/stalwart/data` (and `/opt/stalwart/data/blobs` for the filesystem [BlobStore](https://stalw.art/docs/ref/object/blob-store)). The new image mounts persistent data at `/var/lib/stalwart` instead, so any path referencing the old location must be rewritten before the recovery container is started; otherwise the container exits with `Permission denied: /opt/stalwart/data` because UID `2000` cannot create that directory inside the container's filesystem.
+>
+> The migration script ships with a `--patch-paths` flag that handles the rewrite during `convert`:
+>
+> ```bash
+> $ python migrate_v016.py convert \
+> --settings settings.json --principals principals.json \
+> --config config.json --output export.json \
+> --patch-paths /opt/stalwart=/var/lib/stalwart
+> ```
+>
+> `--patch-paths SOURCE=DEST` walks both emitted files and rewrites any string value beginning with the source prefix. The flag may be supplied multiple times for deployments that mount data under several legacy paths. When the script detects `/opt/stalwart` in the source settings and the flag was not passed, it prints a notice with the exact command to rerun.
+>
+> For deployments that already produced `config.json` and `export.json` without the flag, the equivalent in-place edit is:
+>
+> ```bash
+> $ sed -i.bak \
+> -e 's|/opt/stalwart/data/blobs|/var/lib/stalwart/blobs|g' \
+> -e 's|/opt/stalwart/data|/var/lib/stalwart|g' \
+> config.json export.json
+> $ grep -n /opt/stalwart config.json export.json # verify clean
+> ```
+>
+> The blob-path substitution runs first so the more general data-path rewrite does not double-rewrite it. The `.bak` files left behind by `-i.bak` are the rollback if the substitution went wrong.
+>
+> Skip this paragraph entirely on deployments that use external databases (PostgreSQL, MySQL, FoundationDB) and external blob backends; those deployments have no on-disk paths to rewrite.
+
+**4. Start a temporary container in recovery mode.** This container exists only for the duration of the migration:
+
+```bash
+$ docker run -d --name stalwart-recovery \
+ -e STALWART_RECOVERY_MODE=1 \
+ -e STALWART_RECOVERY_ADMIN=admin:someTemporaryPassword \
+ -p 8080:8080 \
+ -v stalwart-etc:/etc/stalwart \
+ -v stalwart-data:/var/lib/stalwart \
+ stalwartlabs/stalwart:v0.16
+```
+
+Only port `8080` (management API) is published: mail ports stay closed in recovery mode. Watch the logs to confirm the migration completes successfully:
+
+```bash
+$ docker logs -f stalwart-recovery
+```
+
+Wait until the logs stop scrolling and settle on the message indicating the HTTP endpoint is listening.
+
+**5. Apply the exported snapshot.** From the host (or any machine that can reach `http://:8080`):
+
+```bash
+$ export STALWART_URL=http://127.0.0.1:8080
+$ export STALWART_USER=admin
+$ export STALWART_PASSWORD=someTemporaryPassword
+$ stalwart-cli apply --file /path/to/export.json
+```
+
+Follow with any snapshots captured from the test deployment:
+
+```bash
+$ stalwart-cli apply --file /path/to/test-deployment-snapshot.json
+```
+
+**6. Stop and remove the temporary container.**
+
+```bash
+$ docker stop stalwart-recovery
+$ docker rm stalwart-recovery
+```
+
+**7. Start the production container.** Same image, without `STALWART_RECOVERY_MODE`, with all mail ports published:
+
+```bash
+$ docker run -d --name stalwart \
+ --restart unless-stopped \
+ -e STALWART_RECOVERY_ADMIN=admin:someTemporaryPassword \
+ -p 443:443 -p 8080:8080 \
+ -p 25:25 -p 587:587 -p 465:465 \
+ -p 143:143 -p 993:993 \
+ -p 110:110 -p 995:995 \
+ -p 4190:4190 \
+ -v stalwart-etc:/etc/stalwart \
+ -v stalwart-data:/var/lib/stalwart \
+ stalwartlabs/stalwart:v0.16
+```
+
+The `STALWART_RECOVERY_ADMIN` variable is retained deliberately so that a real administrator account can still be created through the WebUI after the first login. Once a permanent admin exists, restart the container without that environment variable to remove the back-door credential. If the test deployment snapshot applied in step 5 already includes an administrator account, the variable can be omitted from this step entirely.
+
+For reference on the standard Docker deployment, see https://stalw.art/docs/install/platform/docker.
+
+## Step 4: Post-migration tasks
+
+With the server running on `v0.16`, a few follow-up actions are required to complete the upgrade.
+
+### Log in to the admin panel
+
+Open a browser and navigate to:
+
+```
+https://mail.example.org/admin
+```
+
+Replace `mail.example.org` with the server's hostname. Log in either with the recovery admin credential (if it is still active) or with the administrator account created via the test-deployment snapshot.
+
+A few behavioural changes from `v0.15.x` are worth flagging before the first sign-in:
+
+- **The WebUI is reached over HTTPS on the configured hostname only.** The OAuth, OIDC, and JMAP discovery documents `v0.16` publishes use `https:///...` exclusively in normal mode. Loading the WebUI by IP address, by container name, or over plain HTTP (for example `http://192.168.1.10:8080/admin`) will appear to load the sign-in page but will fail at the OAuth callback. Use the same hostname that was entered in Step 1 of the wizard, or that already lives on `defaultHostname` from the migrated settings.
+- **`http://...:8080` is no longer the right URL for day-to-day administration.** Port `8080` carries the recovery / bootstrap HTTP listener and is intended for the migration window; once the server is running normally it stops being a valid sign-in entry point.
+- **When the public HTTPS port is not `443`** (for example a reverse proxy on `:8443`, or a Docker host port mapping that diverges from the container's `443`), set the [`STALWART_HTTPS_PORT`](https://stalw.art/docs/configuration/environment-variables#public-urls) environment variable to that port and restart the server. Without it, the discovery documents will publish `https:///...` (port `443` implied) and clients will be sent to a port the proxy is not listening on.
+- **Plain-text mail listeners (port `587` submission, port `143` IMAP) are no longer added by default.** This is required for compliance with the PACC autoconfig draft, which only advertises implicit-TLS ports. Mail clients that were configured to connect over `587` STARTTLS will silently stop working until either the listener is recreated through the WebUI / CLI or the clients are pointed at the implicit-TLS ports (`465` for submission, `993` for IMAPS).
+
+#### Reverse-proxy deployments
+
+If the deployment sits behind a reverse proxy (NGINX, Traefik, Caddy, HAProxy, or similar), this is the part of the migration where proxy-related issues most often surface. The migrated `defaultHostname`, the proxy's public hostname, the proxy's listening port, and the proxy's TLS configuration all have to line up before the first sign-in completes; if any of them is off, the OAuth flow fails partway through with errors that are hard to relate back to the proxy.
+
+The most reliable way through this step is to **bypass the proxy temporarily** for the duration of the recovery-mode `apply`, the first sign-in, and the creation of a permanent administrator. Concretely:
+
+1. While running `stalwart-cli apply` and creating the permanent admin, point the CLI and the browser at Stalwart directly: `http://:8080` for the recovery-mode CLI session, then `https:///admin` (accepting any self-signed certificate warning) for the first WebUI sign-in.
+2. Once a permanent administrator account exists and the WebUI is confirmed working, restore the reverse-proxy configuration. From this point forward, end users reach Stalwart through the proxy and the discovery documents already point at the public hostname over HTTPS.
+
+A full description of how `v0.16` composes the published URLs, how the proxy can talk to Stalwart on either HTTP or HTTPS, and where to set `STALWART_HTTPS_PORT` for non-standard public ports lives at https://stalw.art/docs/server/reverse-proxy/overview.
+
+### Recalculate disk quotas
+
+Disk quotas were reset to zero during the wipe and need to be rebuilt from the actual mailbox contents. Navigate to the **Tasks** section of the admin panel and trigger the **"Recalculate disk quotas"** task. This spawns one subtask per user account, each of which scans that user's storage and updates the counter. On large deployments this may take a while to complete: progress is visible in the Tasks panel.
+
+### Recalculate tenant quotas (multi-tenant deployments)
+
+Only applicable when per-tenant disk quotas are in use. After the per-account recalculation has finished for every user, trigger a second task from the Tasks panel: **"Recalculate tenant quotas"**. This rolls the per-account totals up into the tenant-level counters.
+
+### Create a permanent administrator
+
+If the migration was performed without a snapshot from a test deployment, the only administrative credential at this point is the recovery admin defined by `STALWART_RECOVERY_ADMIN`. This credential is a back door: as long as the environment variable is set, the username and password it specifies can log in regardless of directory state. Create a real administrator account through **Management → Accounts**, verify the new account can log in, and then remove `STALWART_RECOVERY_ADMIN` from:
+
+- the systemd environment file (for example `/etc/stalwart/stalwart.env`) and restart the service, or
+- the init.d defaults file, and restart the service, or
+- the Docker container's `-e` flag (redeploy the container without it).
+
+### Review the rest of the configuration
+
+The migration script converts directory, domain, storage, DKIM, and certificate state. Everything else: SMTP listeners, mail routing, spam rules, rate limits, retention policies, ACME, authentication backends other than those listed above: must be reviewed in the WebUI and either recreated by hand, applied from a test-deployment snapshot, or validated against the defaults that `v0.16` ships with.
+
+## Troubleshooting
+
+### The server fails to start after installing `config.json`
+
+Confirm the file is valid JSON (`python -m json.tool config.json` or `jq . config.json`) and that the datastore described in it is reachable with the provided credentials. The daemon's logs will name the offending field when a field is missing or malformed.
+
+### `stalwart-cli apply` fails partway through
+
+Most failures come from trying to create an object whose parent does not exist yet (for example, a `DkimSignature` referencing a `Domain` that is missing from the plan). The error message names the object and the missing reference. Either edit the plan to include the missing parent, or split the `apply` into two runs using the individual snapshot files produced by the script and the test deployment.
+
+### Recovering from a partial `apply`
+
+`apply` runs operations in plan order and stops on the first error. When a `create` fails halfway through, every prior `create` in the same run has already been committed to the database. Re-running the same plan now fails with `primaryKeyViolation` (the objects exist) or `invalidForeignKey` (a parent that did not get created the first time is still missing).
+
+> **Do not bulk-delete `Account` objects to recover.** The migration plan creates each account with its original v0.15 account id (the `restore-` mechanism), so a migrated `Account` points at the existing v0.15 mailbox data in the data store. Deleting that `Account` schedules account destruction, which unlinks and erases all mail, calendars, and contacts stored under that id. On the community edition this runs immediately, with no retention window. Never run `delete Account` against a data store that already contains v0.15 mail.
+
+Recovery does not require deleting accounts. An account that a partial `apply` already created is correct and is reused as-is on the next run; the only objects that need clearing are the registry-only ones that carry no mailbox data and whose re-creation would otherwise raise `primaryKeyViolation`. While the server is still in recovery mode:
+
+```bash
+$ stalwart-cli query DkimSignature --json | jq -r '.[].id' \
+ | stalwart-cli delete DkimSignature --stdin
+$ stalwart-cli query Certificate --json | jq -r '.[].id' \
+ | stalwart-cli delete Certificate --stdin
+$ stalwart-cli query Domain --json | jq -r '.[].id' \
+ | stalwart-cli delete Domain --stdin
+$ stalwart-cli query Tenant --json | jq -r '.[].id' \
+ | stalwart-cli delete Tenant --stdin
+```
+
+`Domain` and `Tenant` hold only directory metadata and are safe to delete and recreate; `Account` is deliberately omitted. Then fix the underlying cause in `export.json` (most often a domain that fails the v0.16 hostname check, an account whose local-part contains `@`, or a stale `/opt/stalwart` path embedded by the migration script), remove from `export.json` the `create` operation for `Account` (and any other object that already committed before the failure, so re-applying it does not raise `primaryKeyViolation`), and rerun:
+
+```bash
+$ stalwart-cli apply --file export.json
+```
+
+If you must start over with the accounts as well, do not delete them: point the new deployment at an empty data store (or restore the v0.15 data-store backup) before re-running `apply`, so that destroying and recreating accounts cannot reach live mail.
+
+If the failure was caused by data that the migration script itself produced incorrectly, also rerun the script with the latest version from `main` before applying. Fixes during the v0.16.0 / v0.16.1 window addressed several edge cases (group names containing `@`, ACME base64 padding, single-URL Redis stores, paths embedded in custom storage backends, and `%{file:...}%` / `%{env:...}%` macros in DKIM private keys and certificates, which are now expanded by the script instead of being passed through verbatim and aborting the `apply`).
+
+For deployments where individual objects are easier to identify than to wipe wholesale, use `stalwart-cli query ` to list ids and `stalwart-cli delete --ids ` to remove a specific one. The same warning applies: deleting an `Account` destroys the mail stored under it. Only `Domain`, `Tenant`, `DkimSignature`, and `Certificate` are safe to delete and recreate during recovery.
+
+### Bootstrapping a real administrator from the CLI
+
+When the WebUI is unreachable for any reason (TLS not yet in place, reverse proxy misconfigured, OAuth callback failing), the CLI is the supported escape hatch for promoting the first real administrator. Authenticate as the recovery admin and run:
+
+```bash
+$ export STALWART_URL='http://127.0.0.1:8080'
+$ export STALWART_USER='admin'
+$ export STALWART_PASSWORD='someTemporaryPassword'
+
+$ stalwart-cli query Domain --fields id,name
+$ stalwart-cli create account/user \
+ --field name=admin \
+ --field domainId=
+$ stalwart-cli query Account --where name=admin --fields id
+$ stalwart-cli update Account \
+ --field 'credentials={"0":{"@type":"Password","secret":""}}'
+$ stalwart-cli update Account \
+ --field 'roles={"@type":"Admin"}'
+```
+
+Once the new account can sign in to the WebUI, remove `STALWART_RECOVERY_ADMIN` from the service environment and restart the service.
+
+### Common questions
+
+- **`primaryKeyViolation` on a rerun of `apply`**: see *Recovering from a partial `apply`* above.
+- **`Domain: create failed for create-N: invalidPatch | Invalid domain name`**: the domain in `export.json` does not pass the v0.16 hostname check (typically a missing or non-public TLD). Either correct the domain in v0.15 before redumping, or hand-edit the offending block in `export.json`.
+- **`/admin` redirects to `http://:8080/`**: fixed in v0.16.x; upgrade to the latest patch release.
+- **"Recalculate disk quotas" not visible in the WebUI**: open *Tasks → Scheduled → Create task*, choose the *Quota recalculation* maintenance type at the per-account scope, and pick a near-future timestamp.
+
+### `Data corruption detected` after migration
+
+This error means one node in a cluster was left running on `v0.15.x` while another was being migrated, and the old node wrote records in the obsolete format into the shared database. Stop every node in the cluster, ensure every binary is on `v0.16`, and restart. If corruption persists, the logs name the offending keys and they can be removed with the `stalwart-cli delete` command.
+
+### `/admin` (or `/account`) returns `404 Not Found`
+
+The WebUI is delivered as a downloadable [Application](https://stalw.art/docs/applications/overview) bundle that the server fetches from `https://github.com/stalwartlabs/webui/releases/latest/` on first start, and then refreshes on a schedule. When the very first download fails, no bundle has been unpacked locally and every request to a WebUI mount path returns `404 Not Found`. This is the most common cause of "the server is running, port `8080` answers, but `/admin` returns 404" reports during the migration.
+
+The fix is to make sure outbound HTTPS from the Stalwart host can reach GitHub's release storage (`github.com` and `objects.githubusercontent.com`). On a host that genuinely cannot reach the public internet, stage the WebUI bundle on an internal HTTPS server and update the [`resourceUrl`](https://stalw.art/docs/ref/object/application#resourceurl) field on the WebUI's [Application](https://stalw.art/docs/ref/object/application) record to point at the internal location. After the first successful download, subsequent failures are non-fatal: the previously installed bundle stays in service and `/admin` keeps working until the next successful refresh. The full description, including the precise hosts involved, is at https://stalw.art/docs/management/webui/overview#outbound-network-requirement.
+
+### Rolling back
+
+If the migration cannot be completed within the available maintenance window, the database backup captured in Step 2 can be restored and the old binary (preserved as `/usr/local/bin/stalwart.v015` in the example) started again. The `v0.16` binary will refuse to start a second time against a database that has already been migrated, so restoring the pre-migration backup is the only path back to `v0.15.x`.
+
+## Questions
+
+If any part of this migration is unclear, or if something does not behave as documented, please post in the dedicated upgrade discussion at https://support.stalw.art. Include:
+
+- The deployment type (binary / Docker / clustered)
+- The datastore backend (RocksDB / SQLite / PostgreSQL / MySQL / FoundationDB)
+- The exact version being upgraded from (`stalwart --version`)
+- Any error messages from the server log or the CLI, verbatim
+
+We would rather answer a question than watch a deployment break. There is no such thing as an obvious question for a migration of this size.
diff --git a/api/v1/openapi.yml b/api/v1/openapi.yml
new file mode 100644
index 0000000..7de8b44
--- /dev/null
+++ b/api/v1/openapi.yml
@@ -0,0 +1,586 @@
+openapi: 3.0.3
+info:
+ title: Stalwart Management API
+ description: |
+ REST Management API for Stalwart server. These endpoints are helpers
+ that complement the JMAP API — most of the server's configuration and data
+ is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover
+ interactive login, account introspection, configuration schema retrieval and
+ live (Server-Sent Events) telemetry streams.
+ version: "1.0"
+ license:
+ name: AGPL-3.0-only OR LicenseRef-SEL
+servers:
+ - url: https://{host}
+ description: Stalwart server
+ variables:
+ host:
+ default: mail.example.com
+ description: The hostname of Stalwart server
+security:
+ - bearerAuth: []
+ - basicAuth: []
+paths:
+ /api/auth:
+ post:
+ operationId: login
+ summary: Authenticate a user and obtain an authorization code
+ description: |
+ Anonymous endpoint used by the web UI and device-flow clients to exchange
+ user credentials (plus optional MFA token and PKCE challenge) for an
+ OAuth authorization `client_code` that can then be exchanged for an
+ access token via `POST /auth/token`. Rate-limited as an anonymous request.
+ security: []
+ tags: [Authentication]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginRequest'
+ examples:
+ authCode:
+ summary: Authorization-code flow (web UI)
+ value:
+ type: authCode
+ accountName: jane@example.com
+ accountSecret: s3cret
+ clientId: webadmin
+ redirectUri: https://mail.example.com/login
+ codeChallenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
+ codeChallengeMethod: S256
+ authDevice:
+ summary: Device-flow completion
+ value:
+ type: authDevice
+ accountName: jane@example.com
+ accountSecret: s3cret
+ code: BDWP-HQPK
+ responses:
+ '200':
+ description: Result of the authentication attempt
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginResponse'
+ examples:
+ authenticated:
+ value:
+ type: authenticated
+ clientCode: 3F7A9C1E4B2D8E6F
+ mfaRequired:
+ value:
+ type: mfaRequired
+ failure:
+ value:
+ type: failure
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+
+ /api/discover/{email}:
+ get:
+ operationId: discoverOidc
+ summary: Discover the OpenID Connect provider for an email address
+ description: |
+ Returns the OpenID Connect discovery document for the directory that
+ owns the domain part of `email`. If the domain is not bound to an
+ external OIDC directory, the server's own OIDC discovery document
+ (equivalent to `/.well-known/openid-configuration`) is returned.
+ Anonymous endpoint, rate-limited.
+ security: []
+ tags: [Authentication]
+ parameters:
+ - name: email
+ in: path
+ required: true
+ description: Email address or account name
+ schema:
+ type: string
+ format: email
+ responses:
+ '200':
+ description: OpenID Connect discovery document
+ content:
+ application/json:
+ schema:
+ type: object
+ description: OIDC discovery metadata (RFC 8414)
+ additionalProperties: true
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+
+ /api/account:
+ get:
+ operationId: getAccount
+ summary: Return the authenticated account's permissions, edition and locale
+ tags: [Account]
+ responses:
+ '200':
+ description: Account descriptor
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Account'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /api/schema:
+ get:
+ operationId: getSchemaRedirect
+ summary: Redirect to the versioned configuration schema URL
+ description: |
+ Redirects (302) to `/api/schema/{hash}` where `{hash}` is the SHA-256
+ of the current configuration schema. Use this when you do not yet know
+ the hash; once you have cached a schema at a given hash the immutable
+ cache will never require re-download.
+ tags: [Schema]
+ responses:
+ '302':
+ description: Redirect to the hashed schema URL
+ headers:
+ Location:
+ schema:
+ type: string
+ example: /api/schema/a1b2c3d4e5f6...
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /api/schema/{hash}:
+ get:
+ operationId: getSchema
+ summary: Return the configuration schema at a specific hash
+ description: |
+ Returns the JSON Schema describing the full Stalwart configuration tree.
+ The response is always gzip-encoded (`Content-Encoding: gzip`) and served
+ with an immutable cache policy — the schema for a given hash never
+ changes. If the hash does not match the server's current schema, the
+ server redirects to the correct URL.
+ tags: [Schema]
+ parameters:
+ - name: hash
+ in: path
+ required: true
+ description: SHA-256 hex digest of the configuration schema
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Gzipped JSON Schema document
+ headers:
+ Content-Encoding:
+ schema:
+ type: string
+ example: gzip
+ Cache-Control:
+ schema:
+ type: string
+ example: public, max-age=31536000, immutable
+ content:
+ application/json:
+ schema:
+ type: object
+ description: JSON Schema document describing Stalwart config
+ additionalProperties: true
+ '302':
+ description: Redirect to the current schema URL when the hash is stale
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /api/token/delivery:
+ get:
+ operationId: issueDeliveryToken
+ summary: Issue a short-lived token for live delivery diagnostics
+ description: |
+ Returns a plain-text bearer token, valid for 60 seconds, that authorises
+ connecting to `/api/live/delivery/{target}` as a query parameter
+ (`?token=...`). Useful for EventSource clients that cannot send
+ `Authorization` headers. Requires `LiveDeliveryTest` permission.
+ tags: [Live Telemetry]
+ responses:
+ '200':
+ description: Short-lived delivery token
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: eyJhbGciOi...
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+
+ /api/token/tracing:
+ get:
+ operationId: issueTracingToken
+ summary: Issue a short-lived token for live tracing (Enterprise)
+ description: |
+ Returns a plain-text bearer token, valid for 60 seconds, that authorises
+ connecting to `/api/live/tracing` as a query parameter. Requires the
+ `LiveTracing` permission. Available only in the Enterprise edition.
+ tags: [Live Telemetry]
+ responses:
+ '200':
+ description: Short-lived tracing token
+ content:
+ text/plain:
+ schema:
+ type: string
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ description: Enterprise feature not available in this edition
+
+ /api/token/metrics:
+ get:
+ operationId: issueMetricsToken
+ summary: Issue a short-lived token for live metrics (Enterprise)
+ description: |
+ Returns a plain-text bearer token, valid for 60 seconds, that authorises
+ connecting to `/api/live/metrics` as a query parameter. Requires the
+ `LiveMetrics` permission. Available only in the Enterprise edition.
+ tags: [Live Telemetry]
+ responses:
+ '200':
+ description: Short-lived metrics token
+ content:
+ text/plain:
+ schema:
+ type: string
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ description: Enterprise feature not available in this edition
+
+ /api/live/delivery/{target}:
+ get:
+ operationId: liveDelivery
+ summary: Stream outbound-delivery diagnostics as Server-Sent Events
+ description: |
+ Opens a `text/event-stream` connection that streams each stage of an
+ outbound delivery attempt to `target` (a domain or email address): MX
+ lookup, MTA-STS fetch, TLSA/DANE validation, SMTP conversation, and so
+ on. Each SSE frame carries a JSON-encoded `DeliveryStage` wrapped in a
+ single-element array (`data: [{...}]`). The stream ends with a final
+ `completed` event. Requires `LiveDeliveryTest` permission; may also be
+ authenticated via the `?token=` query parameter obtained from
+ `/api/token/delivery`.
+ tags: [Live Telemetry]
+ security:
+ - bearerAuth: []
+ - basicAuth: []
+ - liveToken: []
+ parameters:
+ - name: target
+ in: path
+ required: true
+ description: Target domain or email address to diagnose
+ schema:
+ type: string
+ - name: timeout
+ in: query
+ required: false
+ description: Maximum stream lifetime in seconds (minimum 1, default 30)
+ schema:
+ type: integer
+ minimum: 1
+ default: 30
+ responses:
+ '200':
+ description: Server-Sent Events stream of delivery-diagnose stages
+ content:
+ text/event-stream:
+ schema:
+ type: string
+ description: |
+ Series of SSE frames. Each `event: event` frame carries
+ `data: []`; the final frame's stage is
+ `{"type":"completed"}`.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+
+ /api/live/tracing:
+ get:
+ operationId: liveTracing
+ summary: Stream live tracing events (Enterprise)
+ description: |
+ Opens a `text/event-stream` connection streaming server trace events in
+ real time. Requires `LiveTracing` permission; may be authenticated via
+ the `?token=` query parameter from `/api/token/tracing`. Enterprise only.
+ tags: [Live Telemetry]
+ security:
+ - bearerAuth: []
+ - basicAuth: []
+ - liveToken: []
+ responses:
+ '200':
+ description: Server-Sent Events stream of tracing events
+ content:
+ text/event-stream:
+ schema:
+ type: string
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ description: Enterprise feature not available in this edition
+
+ /api/live/metrics:
+ get:
+ operationId: liveMetrics
+ summary: Stream live metrics events (Enterprise)
+ description: |
+ Opens a `text/event-stream` connection streaming server metrics in real
+ time. Requires `LiveMetrics` permission; may be authenticated via the
+ `?token=` query parameter from `/api/token/metrics`. Enterprise only.
+ tags: [Live Telemetry]
+ security:
+ - bearerAuth: []
+ - basicAuth: []
+ - liveToken: []
+ responses:
+ '200':
+ description: Server-Sent Events stream of metric events
+ content:
+ text/event-stream:
+ schema:
+ type: string
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ description: Enterprise feature not available in this edition
+
+components:
+ securitySchemes:
+ bearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+ description: OAuth2 access token issued by `POST /auth/token`.
+ basicAuth:
+ type: http
+ scheme: basic
+ description: HTTP Basic authentication using account name and secret.
+ liveToken:
+ type: apiKey
+ in: query
+ name: token
+ description: |
+ Short-lived token (60s lifetime) issued by `/api/token/{kind}` and used
+ to authorise Server-Sent Events streams where an `Authorization` header
+ cannot be set (e.g. browser `EventSource`).
+
+ responses:
+ BadRequest:
+ description: Request payload is malformed or fails validation
+ content:
+ application/problem+json:
+ schema:
+ $ref: '#/components/schemas/ProblemDetails'
+ Unauthorized:
+ description: Missing or invalid credentials
+ headers:
+ WWW-Authenticate:
+ schema:
+ type: string
+ example: Bearer realm="Stalwart Server"
+ content:
+ application/problem+json:
+ schema:
+ $ref: '#/components/schemas/ProblemDetails'
+ Forbidden:
+ description: Authenticated principal lacks the required permission
+ content:
+ application/problem+json:
+ schema:
+ $ref: '#/components/schemas/ProblemDetails'
+ NotFound:
+ description: Resource not found
+ content:
+ application/problem+json:
+ schema:
+ $ref: '#/components/schemas/ProblemDetails'
+ TooManyRequests:
+ description: Anonymous-request rate limit exceeded
+ content:
+ application/problem+json:
+ schema:
+ $ref: '#/components/schemas/ProblemDetails'
+
+ schemas:
+ LoginRequest:
+ type: object
+ description: |
+ Tagged union discriminated by `type`. Use `authCode` for the standard
+ OAuth authorization-code flow (optionally with PKCE) and `authDevice`
+ to complete an OAuth device-authorization flow.
+ oneOf:
+ - $ref: '#/components/schemas/LoginRequestAuthCode'
+ - $ref: '#/components/schemas/LoginRequestAuthDevice'
+ discriminator:
+ propertyName: type
+ mapping:
+ authCode: '#/components/schemas/LoginRequestAuthCode'
+ authDevice: '#/components/schemas/LoginRequestAuthDevice'
+
+ LoginRequestAuthCode:
+ type: object
+ required: [type, accountName, accountSecret, clientId]
+ properties:
+ type:
+ type: string
+ enum: [authCode]
+ accountName:
+ type: string
+ accountSecret:
+ type: string
+ format: password
+ mfaToken:
+ type: string
+ nullable: true
+ description: MFA token returned by a previous `mfaRequired` response
+ clientId:
+ type: string
+ description: OAuth client identifier
+ redirectUri:
+ type: string
+ format: uri
+ nullable: true
+ description: Must use `https://` unless the server is in recovery or dev mode
+ nonce:
+ type: string
+ nullable: true
+ scope:
+ type: string
+ nullable: true
+ codeChallenge:
+ type: string
+ nullable: true
+ description: PKCE code challenge (RFC 7636)
+ codeChallengeMethod:
+ type: string
+ enum: [plain, S256]
+ nullable: true
+ description: Defaults to `plain` when a `codeChallenge` is present
+ state:
+ type: string
+ nullable: true
+
+ LoginRequestAuthDevice:
+ type: object
+ required: [type, accountName, accountSecret, code]
+ properties:
+ type:
+ type: string
+ enum: [authDevice]
+ accountName:
+ type: string
+ accountSecret:
+ type: string
+ format: password
+ mfaToken:
+ type: string
+ nullable: true
+ code:
+ type: string
+ description: User-facing device code issued by `POST /auth/device`
+
+ LoginResponse:
+ type: object
+ description: Tagged union discriminated by `type`.
+ oneOf:
+ - $ref: '#/components/schemas/LoginResponseAuthenticated'
+ - $ref: '#/components/schemas/LoginResponseVerified'
+ - $ref: '#/components/schemas/LoginResponseMfaRequired'
+ - $ref: '#/components/schemas/LoginResponseFailure'
+ discriminator:
+ propertyName: type
+ mapping:
+ authenticated: '#/components/schemas/LoginResponseAuthenticated'
+ verified: '#/components/schemas/LoginResponseVerified'
+ mfaRequired: '#/components/schemas/LoginResponseMfaRequired'
+ failure: '#/components/schemas/LoginResponseFailure'
+
+ LoginResponseAuthenticated:
+ type: object
+ required: [type, clientCode]
+ properties:
+ type:
+ type: string
+ enum: [authenticated]
+ clientCode:
+ type: string
+ description: Authorization code to exchange at `POST /auth/token`
+
+ LoginResponseVerified:
+ type: object
+ required: [type]
+ properties:
+ type:
+ type: string
+ enum: [verified]
+
+ LoginResponseMfaRequired:
+ type: object
+ required: [type]
+ properties:
+ type:
+ type: string
+ enum: [mfaRequired]
+
+ LoginResponseFailure:
+ type: object
+ required: [type]
+ properties:
+ type:
+ type: string
+ enum: [failure]
+
+ Account:
+ type: object
+ required: [permissions, edition, locale]
+ properties:
+ permissions:
+ type: array
+ description: |
+ Effective permissions for the authenticated principal, filtered to
+ exclude internal/system-only permissions. Values are from the
+ `Permission` enum (e.g. `authenticate`, `jmap-email-get`,
+ `sys-account-settings-get`).
+ items:
+ type: string
+ edition:
+ type: string
+ enum: [oss, community, enterprise]
+ description: Server edition
+ locale:
+ type: string
+ description: Preferred locale for the account (IETF BCP 47-style tag)
+
+ ProblemDetails:
+ type: object
+ description: RFC 7807 problem details document
+ properties:
+ type:
+ type: string
+ format: uri
+ title:
+ type: string
+ status:
+ type: integer
+ detail:
+ type: string
+ instance:
+ type: string
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
new file mode 100644
index 0000000..c584f85
--- /dev/null
+++ b/crates/common/Cargo.toml
@@ -0,0 +1,101 @@
+[package]
+name = "common"
+version = "0.16.22"
+edition = "2024"
+build = "build.rs"
+
+[dependencies]
+utils = { path = "../utils" }
+nlp = { path = "../nlp" }
+store = { path = "../store" }
+trc = { path = "../trc" }
+directory = { path = "../directory" }
+coordinator = { path = "../coordinator" }
+types = { path = "../types" }
+registry = { path = "../registry" }
+jmap_proto = { path = "../jmap-proto" }
+sieve-rs = { version = "0.7", features = ["rkyv", "serde"] }
+mail-parser = { version = "0.11", features = ["full_encoding"] }
+mail-builder = { version = "1.0" }
+mail-auth = { version = "0.13", features = ["generate", "arc"] }
+smtp-proto = { version = "0.2.3", features = ["rkyv"] }
+dns-update = { version = "0.5" }
+calcard = { version = "0.3", features = ["rkyv"] }
+ahash = { version = "0.8.12", features = ["serde"] }
+parking_lot = "0.12.5"
+regex = "1.13.1"
+proxy-header = { version = "0.1.2", features = ["tokio"] }
+arc-swap = "1.9.2"
+rustls = { version = "0.23.43", default-features = false, features = ["std", "aws_lc_rs", "tls12"] }
+rustls-pemfile = "2.2"
+rustls-pki-types = { version = "1" }
+aws-lc-rs = { version = "1" }
+tokio = { version = "1.53", features = ["net", "macros"] }
+tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] }
+futures = "0.3"
+rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] }
+reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2", "stream"]}
+serde = { version = "1.0", features = ["derive"]}
+serde_json = "1.0"
+base64 = "0.23"
+x509-parser = "0.18"
+pem = "4.0"
+chrono = { version = "0.4", features = ["serde"] }
+hyper = { version = "1.11.1", features = ["server", "http1", "http2"] }
+opentelemetry = { git = "https://github.com/stalwartlabs/opentelemetry-rust" }
+opentelemetry_sdk = { git = "https://github.com/stalwartlabs/opentelemetry-rust" }
+opentelemetry-otlp = { git = "https://github.com/stalwartlabs/opentelemetry-rust", default-features = false, features = ["reqwest-client", "http-proto", "trace", "metrics", "logs", "internal-logs", "grpc-tonic", "tls-aws-lc", "tls-roots", "reqwest-rustls"] }
+opentelemetry-semantic-conventions = { git = "https://github.com/stalwartlabs/opentelemetry-rust" }
+prometheus = { version = "0.14", default-features = false }
+imagesize = "0.15"
+sha1 = "0.11"
+sha2 = "0.11"
+md5 = "0.8.1"
+whatlang = "0.18"
+idna = "1.1"
+decancer = "3.3.3"
+unicode-security = "0.1.2"
+infer = "0.22"
+bincode = { version = "2.0.1", features = ["serde"] }
+hostname = "0.4.2"
+zip = "8.6"
+xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
+psl = "2"
+aes-gcm-siv = "0.12.1"
+jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] }
+rsa = "0.9.10"
+p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pkcs8", "pem"] }
+p384 = { version = "0.13", features = ["ecdh"] }
+hashify = "0.2"
+rkyv = { version = "0.8.18", features = ["little_endian"] }
+tinyvec = { version = "1.12.0", features = ["alloc"] }
+compact_str = { version = "0.10.0", features = ["rkyv", "serde"] }
+lz4_flex = { version = "0.14", features = ["frame"], default-features = false }
+hickory-proto = "0.26.3"
+nohash-hasher = "0.2.0"
+quick_cache = "0.7"
+rasn = "0.28"
+rasn-pkix = "0.28"
+sequoia-openpgp = { version = "2.4", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] }
+zxcvbn = "3.1.1"
+pkcs8 = { version = "0.10.2", features = ["alloc", "std"] }
+quick-xml = "0.41"
+
+[target.'cfg(unix)'.dependencies]
+privdrop = "0.5.6"
+libc = "0.2.189"
+
+[target.'cfg(windows)'.dependencies]
+socket2 = "0.6"
+
+[features]
+test_mode = []
+dev_mode = []
+enterprise = []
+foundation = []
+
+[dev-dependencies]
+tokio = { version = "1.53", features = ["full"] }
+
+[lints]
+workspace = true
diff --git a/crates/common/build.rs b/crates/common/build.rs
new file mode 100644
index 0000000..c62f67d
--- /dev/null
+++ b/crates/common/build.rs
@@ -0,0 +1,234 @@
+use std::collections::{HashMap, HashSet};
+use std::env;
+use std::fs;
+use std::path::Path;
+
+fn main() {
+ let out_dir = env::var("OUT_DIR").unwrap();
+ let dest_path = Path::new(&out_dir).join("locales.rs");
+
+ // Read the YAML file
+ let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
+ let repo_root = Path::new(&manifest_dir).parent().unwrap().parent().unwrap();
+ let yaml_path = repo_root.join("resources/locales/i18n.yml");
+ let yaml_content =
+ fs::read_to_string(&yaml_path).unwrap_or_else(|_| panic!("Failed to read {yaml_path:?}"));
+
+ let locales = parse_yaml(&yaml_content);
+
+ let generated_code = generate_locale_code(&locales);
+
+ fs::write(&dest_path, generated_code).expect("Failed to write generated locales.");
+
+ println!("cargo:rerun-if-changed={}", yaml_path.display());
+}
+
+fn parse_yaml(content: &str) -> HashMap> {
+ let mut result: HashMap> = HashMap::new();
+ let mut current_key = None;
+
+ for line in content.lines() {
+ if let Some((key, value)) = line.split_once(':') {
+ let is_translation = key
+ .as_bytes()
+ .first()
+ .is_some_and(|&b| b.is_ascii_whitespace());
+ let key = key.trim();
+ if !key.starts_with('#') && !key.is_empty() {
+ if !is_translation {
+ current_key = result.entry(key.replace('.', "_")).or_default().into();
+ } else {
+ current_key
+ .as_mut()
+ .unwrap()
+ .insert(key.to_string(), value.trim().trim_matches('"').to_string());
+ }
+ }
+ }
+ }
+
+ result
+}
+
+fn const_name(language: &str) -> String {
+ language.to_uppercase().replace('-', "_")
+}
+
+const PLURAL_CATEGORIES: [&str; 6] = ["zero", "one", "two", "few", "many", "other"];
+
+const RTL_LANGUAGES: [&str; 10] = ["ar", "ckb", "dv", "fa", "he", "ps", "sd", "ug", "ur", "yi"];
+
+fn direction(language: &str) -> &'static str {
+ let tag = language.split(['-', '_']).next().unwrap_or(language);
+ if RTL_LANGUAGES.contains(&tag) {
+ "rtl"
+ } else {
+ "ltr"
+ }
+}
+
+fn split_plural_forms(value: &str) -> Option> {
+ value
+ .split(';')
+ .map(|segment| {
+ segment
+ .split_once('=')
+ .filter(|(name, _)| PLURAL_CATEGORIES.contains(name))
+ })
+ .collect()
+}
+
+fn plural_keys(locales: &HashMap>) -> HashSet {
+ let mut keys = HashSet::new();
+
+ for (key, translations) in locales {
+ if !translations
+ .values()
+ .any(|value| split_plural_forms(value).is_some())
+ {
+ continue;
+ }
+
+ for (language, value) in translations {
+ let Some(forms) = split_plural_forms(value) else {
+ panic!(
+ "{key}: {language} has no plural categories while other languages do: {value:?}"
+ );
+ };
+ let mut seen = HashSet::new();
+ for (name, _) in &forms {
+ if !seen.insert(*name) {
+ panic!("{key}: {language} repeats the plural category {name:?}");
+ }
+ }
+ if !seen.contains("other") {
+ panic!("{key}: {language} is missing the required \"other\" plural category");
+ }
+ }
+
+ keys.insert(key.clone());
+ }
+
+ keys
+}
+
+fn plural_forms_literal(value: &str) -> String {
+ let forms = split_plural_forms(value).expect("validated above");
+ let other = forms
+ .iter()
+ .find(|(name, _)| *name == "other")
+ .map(|(_, text)| *text)
+ .expect("validated above");
+
+ let mut literal = String::from("PluralForms {");
+ for category in PLURAL_CATEGORIES {
+ let text = forms
+ .iter()
+ .find(|(name, _)| *name == category)
+ .map_or(other, |(_, text)| *text);
+ literal.push_str(&format!(" {category}: {text:?},"));
+ }
+ literal.push_str(" }");
+ literal
+}
+
+fn generate_locale_code(locales: &HashMap>) -> String {
+ let mut code = String::new();
+ let plural = plural_keys(locales);
+
+ code.push_str("#[derive(Debug, Clone, Copy)]\n");
+ code.push_str("pub struct PluralForms {\n");
+ for category in PLURAL_CATEGORIES {
+ code.push_str(&format!(" pub {category}: &'static str,\n"));
+ }
+ code.push_str("}\n\n");
+
+ code.push_str("#[derive(Debug, Clone)]\n");
+ code.push_str("pub struct Locale {\n");
+ code.push_str(" pub name: &'static str,\n");
+ code.push_str(" pub direction: &'static str,\n");
+
+ for key in locales.keys() {
+ let field_type = if plural.contains(key) {
+ "PluralForms"
+ } else {
+ "&'static str"
+ };
+ code.push_str(&format!(" pub {key}: {field_type},\n"));
+ }
+
+ code.push_str("}\n\n");
+
+ let mut languages = std::collections::HashSet::new();
+ for translations in locales.values() {
+ for lang in translations.keys() {
+ languages.insert(lang.clone());
+ }
+ }
+
+ for lang in &languages {
+ code.push_str(&format!(
+ "pub static {}_LOCALES: Locale = Locale {{\n",
+ const_name(lang)
+ ));
+ code.push_str(&format!(" name: {lang:?},\n"));
+ code.push_str(&format!(" direction: {:?},\n", direction(lang)));
+
+ for (key, translations) in locales {
+ let value = translations
+ .get(lang)
+ .unwrap_or_else(|| panic!("Missing: {}", key));
+ if plural.contains(key) {
+ code.push_str(&format!(" {key}: {},\n", plural_forms_literal(value)));
+ } else {
+ code.push_str(&format!(" {key}: {value:?},\n"));
+ }
+ }
+
+ code.push_str("};\n\n");
+ }
+
+ let mut sorted: Vec<&String> = languages.iter().collect();
+ sorted.sort_unstable();
+ code.push_str(&format!(
+ "pub static ALL_LOCALES: [&Locale; {}] = [\n",
+ sorted.len()
+ ));
+ for lang in &sorted {
+ code.push_str(&format!(" &{}_LOCALES,\n", const_name(lang)));
+ }
+ code.push_str("];\n\n");
+
+ code.push_str("pub fn locale(name: &str) -> Option<&'static Locale> {\n");
+ code.push_str(" hashify::tiny_map_ignore_case!(name.as_bytes(),\n");
+ for lang in &languages {
+ code.push_str(&format!(
+ " \"{}\" => &{}_LOCALES,\n",
+ lang,
+ const_name(lang)
+ ));
+ }
+ code.push_str(" )\n");
+ code.push_str("}\n\n");
+
+ // Maps a bare language tag onto the regional locale shipped for it
+ let mut by_language: Vec<(&str, &str)> = languages
+ .iter()
+ .map(|lang| (lang.split('-').next().unwrap_or(lang), lang.as_str()))
+ .collect();
+ by_language.sort_unstable();
+ by_language.dedup_by_key(|(language, _)| *language);
+
+ code.push_str("pub fn locale_by_language(language: &str) -> Option<&'static Locale> {\n");
+ code.push_str(" hashify::tiny_map_ignore_case!(language.as_bytes(),\n");
+ for (language, lang) in by_language {
+ code.push_str(&format!(
+ " \"{}\" => &{}_LOCALES,\n",
+ language,
+ const_name(lang)
+ ));
+ }
+ code.push_str(" )\n");
+ code.push_str("}\n");
+ code
+}
diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs
new file mode 100644
index 0000000..4c6ce2d
--- /dev/null
+++ b/crates/common/src/auth/access_token.rs
@@ -0,0 +1,940 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ */
+
+use super::AccessToken;
+use crate::{
+ Server,
+ auth::{
+ AccessScope, AccessTo, AccessTokenInner, AccountTenantIds, Permissions, RECOVERY_ADMIN_ID,
+ permissions::{BuildPermissions, PermissionsListBuilder},
+ },
+ network::limiter::{ConcurrencyLimiter, LimiterResult},
+};
+use ahash::AHasher;
+use registry::{
+ schema::{
+ enums::Permission,
+ structs::{self, Account, Roles, UserRoles},
+ },
+ types::EnumImpl,
+};
+use std::{
+ hash::{Hash, Hasher},
+ net::IpAddr,
+ sync::Arc,
+};
+use store::{query::acl::AclQuery, rand, write::now};
+use tinyvec::TinyVec;
+use trc::{AddContext, StoreEvent};
+use types::{acl::Acl, collection::Collection};
+use utils::map::bitmap::{Bitmap, BitmapItem};
+use xxhash_rust::xxh3;
+
+impl Server {
+ async fn build_access_token(
+ &self,
+ account: Account,
+ account_id: u32,
+ revision: u64,
+ revision_account: u64,
+ ) -> trc::Result {
+ match account {
+ Account::User(account) => {
+ let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
+ let permissions = self
+ .effective_permissions(
+ &account.permissions,
+ match &account.roles {
+ UserRoles::User => {
+ self.core.network.security.default_role_ids_user.as_slice()
+ }
+ UserRoles::Admin => {
+ if tenant_id.is_none() {
+ self.core.network.security.default_role_ids_admin.as_slice()
+ } else {
+ self.core
+ .network
+ .security
+ .default_role_ids_tenant
+ .as_slice()
+ }
+ }
+ UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(),
+ },
+ tenant_id,
+ )
+ .await?;
+
+ let member_of = account
+ .member_group_ids
+ .iter()
+ .map(|m| m.id() as u32)
+ .collect::>();
+ let mut access_to: Vec = Vec::new();
+ for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
+ for acl_item in self
+ .store()
+ .acl_query(AclQuery::HasAccess { grant_account_id })
+ .await
+ .caused_by(trc::location!())?
+ {
+ if acl_item.to_account_id != account_id
+ && !member_of.contains(&acl_item.to_account_id)
+ {
+ let acl = Bitmap::::from(acl_item.permissions);
+ let collection = acl_item.to_collection;
+ if !collection.is_valid() {
+ return Err(trc::StoreEvent::DataCorruption
+ .ctx(trc::Key::Reason, "Corrupted collection found in ACL key.")
+ .details(format!("{acl_item:?}"))
+ .account_id(grant_account_id)
+ .caused_by(trc::location!()));
+ }
+
+ let mut collections: Bitmap = Bitmap::new();
+ if acl.contains(Acl::Read) {
+ collections.insert(collection);
+ }
+ if acl.contains(Acl::ReadItems)
+ && let Some(child_col) = collection.child_collection()
+ {
+ collections.insert(child_col);
+ }
+
+ if !collections.is_empty() {
+ if let Some(idx) = access_to
+ .iter()
+ .position(|a| a.account_id == acl_item.to_account_id)
+ {
+ access_to[idx].collections.union(&collections);
+ } else {
+ access_to.push(AccessTo {
+ account_id: acl_item.to_account_id,
+ collections,
+ });
+ }
+ }
+ }
+ }
+ }
+
+ let now = now();
+ let mut credential_version = 0;
+ let mut credential_scopes = Vec::with_capacity(account.credentials.len());
+
+ credential_scopes.push(AccessScope::new(permissions.finalize(), u32::MAX));
+
+ for credential in account.credentials {
+ match credential {
+ structs::Credential::Password(credential) => {
+ credential_version = xxh3::xxh3_64(credential.secret.as_bytes()).max(1);
+
+ if credential.expires_at.is_some() || !credential.allowed_ips.is_empty()
+ {
+ let credential_scope = &mut credential_scopes[0];
+ credential_scope.expires_at = credential
+ .expires_at
+ .map(|v| v.timestamp() as u64)
+ .unwrap_or(u64::MAX);
+ credential_scope.allowed_ips =
+ credential.allowed_ips.into_inner().into_boxed_slice();
+ }
+ }
+ structs::Credential::ApiKey(credential)
+ | structs::Credential::AppPassword(credential) => {
+ let credential_id = credential.credential_id.document_id();
+ let expires_at = credential
+ .expires_at
+ .map(|v| v.timestamp() as u64)
+ .unwrap_or(u64::MAX);
+ if expires_at > now {
+ let permissions = &credential_scopes[0].permissions;
+ let permissions = match credential.permissions {
+ structs::CredentialPermissions::Inherit => permissions.clone(),
+ structs::CredentialPermissions::Disable(list) => {
+ let mut permissions = permissions.clone();
+ permissions.clear_many(&Permissions::from_permission(
+ list.permissions.as_slice(),
+ ));
+ permissions
+ }
+ structs::CredentialPermissions::Replace(list) => {
+ let mut replace_permissions = Permissions::from_permission(
+ list.permissions.as_slice(),
+ );
+ replace_permissions.intersection(permissions);
+ replace_permissions
+ }
+ };
+ credential_scopes.push(AccessScope {
+ credential_id,
+ permissions,
+ expires_at,
+ allowed_ips: credential
+ .allowed_ips
+ .into_inner()
+ .into_boxed_slice(),
+ })
+ }
+ }
+ }
+ }
+
+ Ok(AccessTokenInner {
+ concurrent_imap_requests: self
+ .core
+ .imap
+ .rate_concurrent
+ .map(ConcurrencyLimiter::new),
+ concurrent_http_requests: self
+ .core
+ .jmap
+ .request_max_concurrent
+ .map(ConcurrencyLimiter::new),
+ concurrent_uploads: self
+ .core
+ .jmap
+ .upload_max_concurrent
+ .map(ConcurrencyLimiter::new),
+ obj_size: 0,
+ revision,
+ revision_account,
+ credential_version,
+ account_id,
+ tenant_id,
+ member_of,
+ access_to: access_to.into_boxed_slice(),
+ scopes: []
+ .into_iter()
+ .chain(credential_scopes)
+ .collect::>(),
+ }
+ .update_size())
+ }
+ Account::Group(account) => {
+ let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
+ let permissions = self
+ .effective_permissions(
+ &account.permissions,
+ account.roles.role_ids().unwrap_or(
+ self.core.network.security.default_role_ids_group.as_slice(),
+ ),
+ tenant_id,
+ )
+ .await?;
+
+ Ok(AccessTokenInner {
+ concurrent_imap_requests: self
+ .core
+ .imap
+ .rate_concurrent
+ .map(ConcurrencyLimiter::new),
+ concurrent_http_requests: self
+ .core
+ .jmap
+ .request_max_concurrent
+ .map(ConcurrencyLimiter::new),
+ concurrent_uploads: self
+ .core
+ .jmap
+ .upload_max_concurrent
+ .map(ConcurrencyLimiter::new),
+ obj_size: 0,
+ revision,
+ revision_account,
+ credential_version: 0,
+ account_id,
+ tenant_id,
+ member_of: Default::default(),
+ access_to: Default::default(),
+ scopes: Box::new([AccessScope::new(permissions.finalize(), u32::MAX)]),
+ }
+ .update_size())
+ }
+ }
+ }
+
+ pub async fn access_token(&self, account_id: u32) -> trc::Result> {
+ match self
+ .inner
+ .cache
+ .access_tokens
+ .get_value_or_guard_async(&account_id)
+ .await
+ {
+ Ok(token) => {
+ trc::event!(
+ Store(StoreEvent::CacheHit),
+ Key = account_id,
+ Collection = "accessToken",
+ );
+
+ Ok(token)
+ }
+ Err(guard) => {
+ trc::event!(
+ Store(StoreEvent::CacheMiss),
+ Key = account_id,
+ Collection = "accessToken",
+ );
+
+ let token: Arc = if let Some(account) =
+ self.registry().object::(account_id.into()).await?
+ {
+ let revision = rand::random::();
+ let revision_account = hash_account(&account);
+ self.build_access_token(account, account_id, revision, revision_account)
+ .await?
+ .into()
+ } else if account_id == RECOVERY_ADMIN_ID {
+ AccessTokenInner::new_admin().into()
+ } else {
+ return Err(trc::SecurityEvent::Unauthorized
+ .into_err()
+ .details("Account not found")
+ .account_id(account_id)
+ .caused_by(trc::location!()));
+ };
+
+ let _ = guard.insert(token.clone());
+ Ok(token)
+ }
+ }
+ }
+
+ pub(crate) async fn access_token_from_account(
+ &self,
+ account_id: u32,
+ account: Account,
+ ) -> trc::Result> {
+ let revision_account = hash_account(&account);
+ match self
+ .inner
+ .cache
+ .access_tokens
+ .get_value_or_guard_async(&account_id)
+ .await
+ {
+ Ok(token) => {
+ if token.revision_account == revision_account {
+ trc::event!(
+ Store(StoreEvent::CacheHit),
+ Key = account_id,
+ Collection = "accessToken",
+ );
+
+ Ok(token)
+ } else {
+ // Token is stale, rebuild it
+ trc::event!(
+ Store(StoreEvent::CacheStale),
+ Key = account_id,
+ Collection = "accessToken",
+ );
+
+ debug_assert!(
+ false,
+ "Token is stale, invalidation should have been triggered"
+ );
+ let revision = rand::random::();
+ let token: Arc = self
+ .build_access_token(account, account_id, revision, revision_account)
+ .await?
+ .into();
+ self.inner
+ .cache
+ .access_tokens
+ .update(account_id, token.clone());
+ Ok(token)
+ }
+ }
+ Err(guard) => {
+ trc::event!(
+ Store(StoreEvent::CacheMiss),
+ Key = account_id,
+ Collection = "accessToken",
+ );
+
+ let revision = rand::random::();
+ let token: Arc = self
+ .build_access_token(account, account_id, revision, revision_account)
+ .await?
+ .into();
+ let _ = guard.insert(token.clone());
+ Ok(token)
+ }
+ }
+ }
+}
+
+impl AccessToken {
+ pub fn new(inner: Arc, remote_ip: IpAddr) -> trc::Result {
+ AccessToken {
+ scope_idx: 0,
+ inner,
+ }
+ .assert_is_valid(remote_ip)
+ }
+
+ pub fn new_maybe_invalid(inner: Arc) -> Self {
+ AccessToken {
+ scope_idx: 0,
+ inner,
+ }
+ }
+
+ pub fn new_scoped(
+ inner: Arc,
+ credential_id: u32,
+ remote_ip: IpAddr,
+ ) -> trc::Result {
+ inner
+ .scopes
+ .iter()
+ .position(|scope| scope.credential_id == credential_id)
+ .ok_or_else(|| {
+ trc::SecurityEvent::Unauthorized
+ .into_err()
+ .ctx(trc::Key::AccountId, inner.account_id)
+ .ctx(trc::Key::Id, credential_id)
+ .reason("Credential expired or removed.")
+ })
+ .map(|scope_idx| AccessToken { scope_idx, inner })
+ .and_then(|token| token.assert_is_valid(remote_ip))
+ }
+
+ pub fn renew(
+ inner: Arc,
+ credential_id: Option,
+ remote_ip: IpAddr,
+ ) -> trc::Result {
+ if let Some(credential_id) = credential_id {
+ Self::new_scoped(inner, credential_id, remote_ip)
+ } else {
+ AccessToken {
+ scope_idx: 0,
+ inner,
+ }
+ .assert_is_valid(remote_ip)
+ }
+ }
+
+ pub fn state(&self) -> u32 {
+ // Hash state
+ let mut s = AHasher::default();
+ self.inner.member_of.hash(&mut s);
+ self.inner.access_to.hash(&mut s);
+ s.finish() as u32
+ }
+
+ #[inline(always)]
+ pub fn account_id(&self) -> u32 {
+ self.inner.account_id
+ }
+
+ #[inline(always)]
+ pub fn tenant_id(&self) -> Option {
+ self.inner.tenant_id
+ }
+
+ pub fn secondary_ids(&self) -> impl Iterator {
+ self.inner
+ .member_of
+ .iter()
+ .chain(self.inner.access_to.iter().map(|a| &a.account_id))
+ }
+
+ pub fn member_ids(&self) -> impl Iterator {
+ [self.inner.account_id]
+ .into_iter()
+ .chain(self.inner.member_of.iter().copied())
+ }
+
+ pub fn all_ids(&self) -> impl Iterator {
+ [self.inner.account_id]
+ .into_iter()
+ .chain(self.inner.member_of.iter().copied())
+ .chain(self.inner.access_to.iter().map(|a| a.account_id))
+ }
+
+ pub fn all_ids_by_collection(&self, collection: Collection) -> impl Iterator {
+ [self.inner.account_id]
+ .into_iter()
+ .chain(self.inner.member_of.iter().copied())
+ .chain(self.inner.access_to.iter().filter_map(move |a| {
+ if a.collections.contains(collection) {
+ Some(a.account_id)
+ } else {
+ None
+ }
+ }))
+ }
+
+ pub fn is_member(&self, account_id: u32) -> bool {
+ self.inner.account_id == account_id
+ || self.inner.member_of.contains(&account_id)
+ || self.has_permission(Permission::Impersonate)
+ }
+
+ pub fn is_account_id(&self, account_id: u32) -> bool {
+ self.inner.account_id == account_id
+ }
+
+ pub fn personal_id(&self, account_id: u32, collection: Collection) -> u32 {
+ let child_collection = collection.child_collection();
+ if self.is_account_id(account_id)
+ || self.inner.member_of.contains(&account_id)
+ || self.inner.access_to.iter().any(|a| {
+ a.account_id == account_id
+ && (a.collections.contains(collection)
+ || child_collection.is_some_and(|child| a.collections.contains(child)))
+ })
+ {
+ self.inner.account_id
+ } else {
+ account_id
+ }
+ }
+
+ #[inline(always)]
+ pub fn has_permission(&self, permission: Permission) -> bool {
+ self.inner
+ .scopes
+ .get(self.scope_idx)
+ .is_some_and(|scope| scope.permissions.get(permission as usize))
+ }
+
+ pub fn assert_is_valid(self, remote_ip: IpAddr) -> trc::Result {
+ if let Some(scope) = self.inner.scopes.get(self.scope_idx) {
+ let has_expired = scope.expires_at <= now();
+ let is_valid_ip = scope.allowed_ips.is_empty()
+ || scope
+ .allowed_ips
+ .iter()
+ .any(|ip_mask| ip_mask.matches(&remote_ip));
+
+ let mut access_token = self;
+ if has_expired {
+ if access_token.scope_idx > 0 {
+ return Err(trc::AuthEvent::CredentialExpired
+ .into_err()
+ .ctx(trc::Key::AccountId, access_token.inner.account_id)
+ .reason("Credential expired."));
+ } else {
+ trc::event!(
+ Auth(trc::AuthEvent::CredentialExpired),
+ AccountId = access_token.inner.account_id,
+ Reason = "Main credential expired, downgrading permissions.",
+ );
+ }
+
+ // Downgrade permissions to allow password change
+ let mut scopes = Vec::with_capacity(access_token.inner.scopes.len());
+ for (idx, scope) in access_token.inner.scopes.iter().enumerate() {
+ if idx == 0 {
+ let mut permissions = Permissions::new();
+
+ for permission in [
+ Permission::Authenticate,
+ Permission::AuthenticateWithAlias,
+ Permission::SysAccountPasswordGet,
+ Permission::SysAccountPasswordUpdate,
+ Permission::EmailReceive,
+ ] {
+ if scope.permissions.get(permission as usize) {
+ permissions.set(permission as usize);
+ }
+ }
+
+ scopes.push(AccessScope {
+ permissions,
+ credential_id: scope.credential_id,
+ expires_at: u64::MAX,
+ allowed_ips: scope.allowed_ips.clone(),
+ });
+ } else {
+ scopes.push(scope.clone());
+ }
+ }
+ let old_inner = &access_token.inner;
+ let inner = AccessTokenInner {
+ scopes: scopes.into_boxed_slice(),
+ account_id: old_inner.account_id,
+ tenant_id: old_inner.tenant_id,
+ member_of: old_inner.member_of.clone(),
+ access_to: old_inner.access_to.clone(),
+ concurrent_http_requests: old_inner.concurrent_http_requests.clone(),
+ concurrent_imap_requests: old_inner.concurrent_imap_requests.clone(),
+ concurrent_uploads: old_inner.concurrent_uploads.clone(),
+ revision_account: old_inner.revision_account,
+ revision: old_inner.revision,
+ credential_version: old_inner.credential_version,
+ obj_size: old_inner.obj_size,
+ };
+
+ access_token = AccessToken {
+ scope_idx: access_token.scope_idx,
+ inner: Arc::new(inner),
+ };
+ }
+
+ if is_valid_ip {
+ Ok(access_token)
+ } else {
+ Err(trc::SecurityEvent::IpUnauthorized
+ .into_err()
+ .ctx(trc::Key::AccountId, access_token.inner.account_id)
+ .reason("IP address not allowed."))
+ }
+ } else {
+ Err(trc::SecurityEvent::Unauthorized
+ .into_err()
+ .ctx(trc::Key::AccountId, self.inner.account_id)
+ .reason("Credential not valid."))
+ }
+ }
+
+ #[inline(always)]
+ pub fn credential_id(&self) -> Option {
+ self.inner
+ .scopes
+ .get(self.scope_idx)
+ .map(|scope| scope.credential_id)
+ }
+
+ #[inline(always)]
+ pub fn revision(&self) -> u64 {
+ self.inner.revision
+ }
+
+ pub fn assert_has_permissions(self, permissions: &[Permission]) -> trc::Result {
+ for permission in permissions {
+ if !self.has_permission(*permission) {
+ return Err(trc::SecurityEvent::Unauthorized
+ .into_err()
+ .details(permission.as_str())
+ .account_id(self.account_id()));
+ }
+ }
+
+ Ok(self)
+ }
+
+ pub fn assert_has_permission(self, permission: Permission) -> trc::Result {
+ if self.has_permission(permission) {
+ Ok(self)
+ } else {
+ Err(trc::SecurityEvent::Unauthorized
+ .into_err()
+ .details(permission.as_str())
+ .account_id(self.account_id()))
+ }
+ }
+
+ pub fn enforce_permission(&self, permission: Permission) -> trc::Result<()> {
+ if self.has_permission(permission) {
+ Ok(())
+ } else {
+ Err(trc::SecurityEvent::Unauthorized
+ .into_err()
+ .details(permission.as_str())
+ .account_id(self.account_id()))
+ }
+ }
+
+ pub fn permissions(&self) -> Vec {
+ if let Some(scope) = self.inner.scopes.get(self.scope_idx) {
+ scope.permissions.build_permissions_list()
+ } else {
+ vec![]
+ }
+ }
+
+ #[inline(always)]
+ pub fn access_scope(&self) -> Option<&AccessScope> {
+ self.inner.scopes.get(self.scope_idx)
+ }
+
+ pub(crate) fn permissions_bits(&self) -> &Permissions {
+ &self
+ .inner
+ .scopes
+ .get(self.scope_idx)
+ .unwrap_or(&self.inner.scopes[0])
+ .permissions
+ }
+
+ pub fn account_permissions(&self) -> &Permissions {
+ &self.inner.scopes[0].permissions
+ }
+
+ pub fn is_shared(&self, account_id: u32) -> bool {
+ !self.is_member(account_id)
+ && self
+ .inner
+ .access_to
+ .iter()
+ .any(|a| a.account_id == account_id)
+ }
+
+ pub fn shared_accounts(&self, collection: Collection) -> impl Iterator {
+ self.inner
+ .member_of
+ .iter()
+ .chain(self.inner.access_to.iter().filter_map(move |a| {
+ if a.collections.contains(collection) {
+ Some(&a.account_id)
+ } else {
+ None
+ }
+ }))
+ }
+
+ pub fn has_access(&self, to_account_id: u32, to_collection: impl Into) -> bool {
+ let to_collection = to_collection.into();
+ self.is_member(to_account_id)
+ || self
+ .inner
+ .access_to
+ .iter()
+ .any(|a| a.account_id == to_account_id && a.collections.contains(to_collection))
+ }
+
+ pub fn has_account_access(&self, to_account_id: u32) -> bool {
+ self.is_member(to_account_id)
+ || self
+ .inner
+ .access_to
+ .iter()
+ .any(|a| a.account_id == to_account_id)
+ }
+
+ pub fn is_http_request_allowed(&self) -> LimiterResult {
+ self.inner
+ .concurrent_http_requests
+ .as_ref()
+ .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
+ }
+
+ pub fn concurrent_http_requests(&self) -> u64 {
+ self.inner
+ .concurrent_http_requests
+ .as_ref()
+ .map(|limiter| limiter.max_concurrent())
+ .unwrap_or(0)
+ }
+
+ pub fn is_imap_request_allowed(&self) -> LimiterResult {
+ self.inner
+ .concurrent_imap_requests
+ .as_ref()
+ .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
+ }
+
+ pub fn is_upload_allowed(&self) -> LimiterResult {
+ self.inner
+ .concurrent_uploads
+ .as_ref()
+ .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed())
+ }
+
+ pub fn concurrent_uploads(&self) -> u64 {
+ self.inner
+ .concurrent_uploads
+ .as_ref()
+ .map(|limiter| limiter.max_concurrent())
+ .unwrap_or(0)
+ }
+
+ pub fn account_tenant_ids(&self) -> AccountTenantIds {
+ AccountTenantIds {
+ account_id: self.account_id(),
+ tenant_id: self.tenant_id(),
+ }
+ }
+
+ pub fn new_admin() -> AccessToken {
+ AccessToken {
+ scope_idx: 0,
+ inner: Arc::new(AccessTokenInner::new_admin()),
+ }
+ }
+
+ pub fn from_permissions(
+ account_id: u32,
+ set_permissions: impl IntoIterator,
+ ) -> AccessToken {
+ let mut permissions = Permissions::new();
+ for permission in set_permissions {
+ permissions.set(permission as usize);
+ }
+ AccessToken {
+ scope_idx: 0,
+ inner: Arc::new(AccessTokenInner {
+ account_id,
+ tenant_id: Default::default(),
+ member_of: Default::default(),
+ access_to: Default::default(),
+ scopes: Box::new([AccessScope::new(permissions, u32::MAX)]),
+ concurrent_http_requests: Default::default(),
+ concurrent_imap_requests: Default::default(),
+ concurrent_uploads: Default::default(),
+ revision: Default::default(),
+ revision_account: Default::default(),
+ credential_version: Default::default(),
+ obj_size: Default::default(),
+ }),
+ }
+ }
+
+ pub fn from_id_maybe_invalid(account_id: u32) -> Self {
+ AccessToken::new_maybe_invalid(Arc::new(AccessTokenInner::from_id(account_id)))
+ }
+}
+
+impl AccessTokenInner {
+ pub fn from_id(account_id: u32) -> Self {
+ Self {
+ account_id,
+ ..Default::default()
+ }
+ }
+
+ pub fn with_tenant_id(mut self, tenant_id: Option) -> Self {
+ self.tenant_id = tenant_id;
+ self
+ }
+
+ pub fn update_size(mut self) -> Self {
+ self.obj_size = (std::mem::size_of::()
+ + (self.member_of.len() * std::mem::size_of::())
+ + (self.access_to.len() * (std::mem::size_of::() + std::mem::size_of::()))
+ + (self.scopes.len() * std::mem::size_of::()))
+ as u64;
+ self
+ }
+
+ pub fn new_admin() -> Self {
+ AccessTokenInner {
+ account_id: RECOVERY_ADMIN_ID,
+ tenant_id: Default::default(),
+ member_of: Default::default(),
+ access_to: Default::default(),
+ scopes: Box::new([AccessScope::new(Permissions::all(), u32::MAX)]),
+ concurrent_http_requests: Default::default(),
+ concurrent_imap_requests: Default::default(),
+ concurrent_uploads: Default::default(),
+ revision: Default::default(),
+ revision_account: Default::default(),
+ credential_version: Default::default(),
+ obj_size: Default::default(),
+ }
+ }
+
+ pub fn revision(&self) -> u64 {
+ self.revision
+ }
+
+ pub fn revision_account(&self) -> u64 {
+ self.revision_account
+ }
+
+ pub fn credential_version(&self) -> u64 {
+ self.credential_version
+ }
+}
+
+impl AccessScope {
+ pub fn new(permissions: Permissions, credential_id: u32) -> Self {
+ Self {
+ permissions,
+ credential_id,
+ expires_at: u64::MAX,
+ allowed_ips: Default::default(),
+ }
+ }
+}
+
+fn hash_account(account: &Account) -> u64 {
+ let mut s = AHasher::default();
+
+ match account {
+ Account::User(account) => {
+ account.member_tenant_id.hash(&mut s);
+ match &account.roles {
+ UserRoles::User => {
+ 0u8.hash(&mut s);
+ }
+ UserRoles::Admin => {
+ 1u8.hash(&mut s);
+ }
+ UserRoles::Custom(custom_roles) => {
+ 2u8.hash(&mut s);
+ custom_roles.role_ids.as_slice().hash(&mut s);
+ }
+ }
+ hash_permissions(&mut s, &account.permissions);
+ for credential in account
+ .credentials
+ .iter()
+ .filter_map(|credential| credential.as_secondary_credential())
+ {
+ credential.credential_id.hash(&mut s);
+ credential.expires_at.hash(&mut s);
+ hash_credential_permissions(&mut s, &credential.permissions);
+ }
+ for group_id in account.member_group_ids.iter() {
+ group_id.hash(&mut s);
+ }
+ }
+ Account::Group(account) => {
+ account.member_tenant_id.hash(&mut s);
+ match &account.roles {
+ Roles::Default => {}
+ Roles::Custom(custom_roles) => {
+ custom_roles.role_ids.as_slice().hash(&mut s);
+ }
+ }
+ hash_permissions(&mut s, &account.permissions);
+ }
+ }
+
+ s.finish()
+}
+
+fn hash_permissions(hasher: &mut AHasher, permissions: &structs::Permissions) {
+ match permissions {
+ structs::Permissions::Inherit => {
+ 0u8.hash(hasher);
+ }
+ structs::Permissions::Merge(permissions) => {
+ 2u8.hash(hasher);
+ permissions.enabled_permissions.as_slice().hash(hasher);
+ permissions.disabled_permissions.as_slice().hash(hasher);
+ }
+ structs::Permissions::Replace(permissions) => {
+ 3u8.hash(hasher);
+ permissions.enabled_permissions.as_slice().hash(hasher);
+ permissions.disabled_permissions.as_slice().hash(hasher);
+ }
+ }
+}
+
+fn hash_credential_permissions(hasher: &mut AHasher, permissions: &structs::CredentialPermissions) {
+ match permissions {
+ structs::CredentialPermissions::Inherit => {
+ 0u8.hash(hasher);
+ }
+ structs::CredentialPermissions::Disable(permissions) => {
+ 2u8.hash(hasher);
+ permissions.permissions.as_slice().hash(hasher);
+ }
+ structs::CredentialPermissions::Replace(permissions) => {
+ 3u8.hash(hasher);
+ permissions.permissions.as_slice().hash(hasher);
+ }
+ }
+}
diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs
new file mode 100644
index 0000000..d24568c
--- /dev/null
+++ b/crates/common/src/auth/authentication.rs
@@ -0,0 +1,644 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ */
+
+use crate::{
+ Server,
+ auth::{
+ AccessToken, AuthRequest, DomainCache,
+ credential::{ApiKey, AppPassword},
+ oauth::GrantType,
+ },
+};
+use base64::{Engine, engine::general_purpose};
+use directory::{
+ Credentials, Directory, Recipient,
+ core::secret::{SecretVerificationResult, verify_mfa_secret_hash, verify_secret_hash},
+};
+use registry::schema::{
+ enums::Permission,
+ structs::{self, Credential},
+};
+use std::{net::IpAddr, sync::Arc};
+use store::write::now;
+use trc::AddContext;
+
+pub struct UsernameParts {
+ pub account: Username,
+ pub master_user: Option,
+}
+
+#[derive(PartialEq, Eq)]
+pub struct Username {
+ pub name: String,
+ pub domain_start: usize,
+}
+
+impl Server {
+ pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result {
+ match Box::pin(self.route_auth_request(req))
+ .await
+ .and_then(|token| token.assert_has_permission(Permission::Authenticate))
+ {
+ Ok(token) => Ok(token),
+ Err(err) => {
+ // Random delay to mitigate user enumeration attacks
+ #[cfg(not(feature = "test_mode"))]
+ {
+ use store::rand::{self, RngExt};
+
+ let delay = rand::rng().random_range(50..500);
+ tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
+ }
+
+ if matches!(
+ err.as_ref(),
+ trc::EventType::Auth(trc::AuthEvent::Failed)
+ | trc::EventType::Security(trc::SecurityEvent::IpUnauthorized)
+ ) && self.has_auth_fail2ban()
+ && self
+ .is_auth_fail2banned(req.remote_ip, req.username())
+ .await?
+ {
+ Err(trc::SecurityEvent::AuthenticationBan
+ .into_err()
+ .ctx(trc::Key::RemoteIp, req.remote_ip)
+ .ctx_opt(trc::Key::AccountName, req.username().map(|s| s.to_string())))
+ } else {
+ Err(err.ctx(trc::Key::RemoteIp, req.remote_ip))
+ }
+ }
+ }
+ }
+
+ async fn route_auth_request(&self, req: &AuthRequest) -> trc::Result {
+ match &req.credentials {
+ Credentials::Basic {
+ username,
+ secret,
+ mfa_token,
+ } => {
+ let mut username = UsernameParts::new(username);
+
+ // Try to authenticate as fallback admin if configured
+ if let Some((fallback_user, fallback_hash)) = &self.registry().recovery_admin()
+ && username.auth_as().address() == fallback_user
+ {
+ return if verify_secret_hash(fallback_hash, secret.as_bytes()).await? {
+ if username.is_master() {
+ let address = username.account().address();
+ if let Some(account_id) =
+ self.impersonated_account_id(username.account()).await?
+ {
+ trc::event!(
+ Auth(trc::AuthEvent::Success),
+ AccountName = address.to_string(),
+ AccountId = account_id,
+ SpanId = req.session_id,
+ Details = fallback_user.to_string(),
+ );
+
+ self.access_token(account_id)
+ .await
+ .and_then(|token| AccessToken::new(token, req.remote_ip))
+ } else {
+ Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, address.to_string())
+ .reason("Master user account not found for fallback admin authentication"))
+ }
+ } else {
+ trc::event!(
+ Auth(trc::AuthEvent::Success),
+ AccountName = fallback_user.to_string(),
+ SpanId = req.session_id,
+ );
+
+ Ok(AccessToken::new_admin())
+ }
+ } else {
+ Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, fallback_user.to_string())
+ .ctx(trc::Key::SpanId, req.session_id)
+ .reason("Fallback admin authentication failed"))
+ };
+ }
+
+ // Add domain if missing, use the default domain
+ self.add_missing_domain(&mut username.account);
+ if let Some(master_user) = &mut username.master_user {
+ self.add_missing_domain(master_user);
+ }
+
+ // Obtain domain
+ let auth_as = username.auth_as();
+ let auth_as_address = auth_as.address();
+ let auth_as_local = auth_as.local();
+ let auth_as_domain = auth_as.domain().unwrap();
+ let domain = self.resolve_domain(auth_as_domain).await?;
+
+ // Authenticate app passwords
+ if let Some(app_pass) = AppPassword::parse(secret) {
+ if username.is_master() {
+ return Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, auth_as_address.to_string())
+ .ctx(trc::Key::SpanId, req.session_id)
+ .reason("App passwords cannot be used for impersonation"));
+ }
+ return if let Some(account_id) =
+ self.account_id_from_parts(auth_as_local, domain.id).await?
+ {
+ self.validate_credential(
+ account_id,
+ app_pass.credential_id,
+ app_pass.secret.as_ref(),
+ req.remote_ip,
+ req.session_id,
+ )
+ .await
+ } else {
+ Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, auth_as_address.to_string())
+ .reason("App password authentication failed: account not found"))
+ };
+ }
+
+ // Obtain external directory, if any
+ let mut is_alias_login = false;
+ let token = if let Some(directory) = self.get_directory_for_cached_domain(&domain) {
+ let directory_account = if username.is_master() {
+ directory
+ .authenticate(&Credentials::Basic {
+ username: auth_as_address.to_string(),
+ secret: secret.clone(),
+ mfa_token: mfa_token.clone(),
+ })
+ .await?
+ } else {
+ directory.authenticate(&req.credentials).await?
+ };
+
+ is_alias_login = directory_account.email != auth_as_address;
+ self.build_directory_token(directory_account, req.remote_ip)
+ .await
+ } else if let Some(account_id) =
+ self.account_id_from_parts(auth_as_local, domain.id).await?
+ {
+ if let Some(account) = self
+ .registry()
+ .object::(account_id.into())
+ .await?
+ .and_then(|account| account.into_user())
+ {
+ let Some(credential) = account.password_credential() else {
+ return Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, auth_as_address.to_string())
+ .ctx(trc::Key::AccountId, account_id)
+ .ctx(trc::Key::SpanId, req.session_id)
+ .reason("Password credential not found for account"));
+ };
+
+ match verify_mfa_secret_hash(
+ credential.otp_auth.as_deref(),
+ mfa_token.as_deref(),
+ credential.secret.as_str(),
+ secret,
+ )
+ .await?
+ {
+ SecretVerificationResult::Valid => {
+ is_alias_login = account.name != auth_as_local;
+ self.access_token(account_id)
+ .await
+ .and_then(|token| AccessToken::new(token, req.remote_ip))
+ }
+ SecretVerificationResult::Invalid => Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, auth_as_address.to_string())
+ .ctx(trc::Key::AccountId, account_id)
+ .ctx(trc::Key::SpanId, req.session_id)
+ .reason("Authentication failed")),
+ SecretVerificationResult::MissingMfaToken => {
+ Err(trc::AuthEvent::MfaRequired
+ .into_err()
+ .ctx(trc::Key::AccountName, auth_as_address.to_string())
+ .ctx(trc::Key::AccountId, account_id)
+ .ctx(trc::Key::SpanId, req.session_id)
+ .reason("MFA token required"))
+ }
+ }
+ } else {
+ Err(trc::AuthEvent::Error
+ .into_err()
+ .ctx(trc::Key::AccountName, auth_as_address.to_string())
+ .ctx(trc::Key::AccountId, account_id)
+ .reason("Account not found in registry"))
+ }
+ } else {
+ Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, auth_as_address.to_string())
+ .reason("Account not found"))
+ }?;
+
+ // Enforce alias login restrictions
+ if is_alias_login && !token.has_permission(Permission::AuthenticateWithAlias) {
+ return Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, auth_as_address.to_string())
+ .ctx(trc::Key::AccountId, token.account_id())
+ .ctx(trc::Key::SpanId, req.session_id)
+ .reason("Authenticated using an email alias but account does not have AuthenticateAlias permission"));
+ }
+
+ // Validate master user access
+ if username.is_master() {
+ token.assert_has_permissions(&[
+ Permission::Impersonate,
+ Permission::Authenticate,
+ ])?;
+ let address = username.account().address();
+ let master_address = auth_as_address;
+ if let Some(account_id) =
+ self.impersonated_account_id(username.account()).await?
+ {
+ trc::event!(
+ Auth(trc::AuthEvent::Success),
+ AccountName = address.to_string(),
+ AccountId = account_id,
+ SpanId = req.session_id,
+ Details = master_address.to_string(),
+ );
+
+ self.access_token(account_id)
+ .await
+ .map(AccessToken::new_maybe_invalid)
+ } else {
+ Err(trc::AuthEvent::Failed
+ .into_err()
+ .ctx(trc::Key::AccountName, address.to_string())
+ .details(master_address.to_string())
+ .reason("Master user account not found"))
+ }
+ } else {
+ trc::event!(
+ Auth(trc::AuthEvent::Success),
+ AccountName = auth_as_address.to_string(),
+ AccountId = token.account_id(),
+ SpanId = req.session_id,
+ );
+
+ Ok(token)
+ }
+ }
+ Credentials::Bearer { username, token } => {
+ // Handle API key authentication
+ if let Some(key) = ApiKey::parse(token) {
+ return self
+ .validate_credential(
+ key.account_id,
+ key.credential_id,
+ key.secret.as_ref(),
+ req.remote_ip,
+ req.session_id,
+ )
+ .await;
+ }
+
+ #[cfg(feature = "dev_mode")]
+ if std::env::var("API_TOKEN_ADMIN").is_ok_and(|admin_token| &admin_token == token) {
+ return Ok(AccessToken::new_admin());
+ }
+
+ // Obtain external directory, if any. When no username is supplied
+ // (e.g. HTTP bearer auth), peek at the JWT claims to find the
+ // user's domain so per-domain OIDC directories are reachable.
+ let directory = if let Some(username) = username.as_deref().map(UsernameParts::new)
+ {
+ if let Some(domain_name) = username.auth_as().domain() {
+ self.get_directory_for_domain(domain_name).await?
+ } else if let Some(domain_name) = extract_jwt_domain(token) {
+ self.get_directory_for_domain(&domain_name).await?
+ } else {
+ self.get_default_directory()
+ }
+ } else if let Some(domain_name) = extract_jwt_domain(token) {
+ self.get_directory_for_domain(&domain_name).await?
+ } else {
+ self.get_default_directory()
+ };
+
+ // Try external directory authentication first if supported, then fallback to internal OAuth.
+ let mut external_error = None;
+ if let Some(directory) = directory
+ && directory.has_bearer_token_support()
+ {
+ match directory.authenticate(&req.credentials).await {
+ Ok(result) => {
+ return self.build_directory_token(result, req.remote_ip).await;
+ }
+ Err(err) => {
+ external_error = Some(err);
+ }
+ }
+ }
+
+ // Internal OAuth
+ match self
+ .validate_access_token(GrantType::AccessToken.into(), token)
+ .await
+ {
+ Ok(token_info) => self
+ .access_token(token_info.account_id)
+ .await
+ .and_then(|token| AccessToken::new(token, req.remote_ip)),
+ Err(err) => {
+ if let Some(external_error) = external_error {
+ Err(external_error)
+ } else {
+ Err(err)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ async fn impersonated_account_id(&self, username: &Username) -> trc::Result