Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
806e63d071 | ||
|
|
06943fd473 | ||
|
|
362bd8b282 | ||
|
|
91481965bc | ||
|
|
5ced44ec13 | ||
|
|
dd8998f178 | ||
|
|
6ec2304fc2 | ||
|
|
d6f9ef1243 | ||
|
|
b0cb73a924 | ||
|
|
373822c2ae | ||
|
|
12157f47bf | ||
|
|
e41742a26c | ||
|
|
1e01b34384 | ||
|
|
95f640b24c | ||
|
|
826a13ab39 | ||
|
|
2f55b1e3e1 | ||
|
|
d75e9bc769 | ||
|
|
08fd08e6fe | ||
|
|
ecc030aa3f | ||
|
|
8844fc9836 | ||
|
|
ef7823d6de | ||
|
|
8d475e2b07 | ||
|
|
0277b5b6a8 | ||
|
|
22c8eb4af6 | ||
|
|
bd3c4bf964 | ||
|
|
7eca17665a | ||
|
|
b6327ffb98 | ||
|
|
d8fc47d765 | ||
|
|
ddd1bbf9b3 | ||
|
|
0b01956535 | ||
|
|
045dda109b | ||
|
|
1c678fabed | ||
|
|
6bbe2448c4 | ||
|
|
490b15e8c6 | ||
|
|
f2e0cb6326 | ||
|
|
8f9d253939 | ||
|
|
fedd6ed161 | ||
|
|
a3dc7e017c | ||
|
|
e327df818a | ||
|
|
d6aa4d543a | ||
|
|
4cd7b895e9 | ||
|
|
37bf96409d | ||
|
|
f72c67864e | ||
|
|
312a833d78 | ||
|
|
0fe75b280b | ||
|
|
05be820be4 | ||
|
|
5a7cc5cc5a | ||
|
|
b4082d5bb2 | ||
|
|
6efac64b37 | ||
|
|
8cc12b8f56 | ||
|
|
a2337f6ad8 | ||
|
|
e4b6413f46 | ||
|
|
3c417f070c | ||
|
|
e3de0bd500 | ||
|
|
06b89111df | ||
|
|
4c4821b5db | ||
|
|
e14fc36785 | ||
|
|
506865ca67 | ||
|
|
f83157464c | ||
|
|
9544fa5f12 | ||
|
|
298264aeb8 | ||
|
|
3a2f60189f | ||
|
|
31239ed9be | ||
|
|
6a98dd22fd | ||
|
|
25b51069a9 | ||
|
|
cd402a6ce4 | ||
|
|
453a62115b | ||
|
|
c0fc0083ff | ||
|
|
8a3e0b9954 | ||
|
|
5e5bec31b7 | ||
|
|
04ec57058a | ||
|
|
3416a41de9 | ||
|
|
d9995cd0b4 | ||
|
|
5f32d3d82c | ||
|
|
0215255280 | ||
|
|
8d55652587 | ||
|
|
270fb3d32c | ||
|
|
25fd6404f2 | ||
|
|
350f4f4197 | ||
|
|
006190d523 | ||
|
|
1e2db95577 | ||
|
|
88f9474b24 | ||
|
|
52299ce8ef | ||
|
|
ad94efb65b | ||
|
|
9f4c0c3351 | ||
|
|
e014521fb6 | ||
|
|
cf9474ce35 | ||
|
|
2360e40733 | ||
|
|
c9531c577c | ||
|
|
fb789bc36f | ||
|
|
f70eb184c2 | ||
|
|
6566f4c2d3 | ||
|
|
24ee502532 | ||
|
|
650ba0020b | ||
|
|
32227722a7 | ||
|
|
d64249b46d | ||
|
|
2d5bda086f | ||
|
|
d15f64ada6 | ||
|
|
906d17a48b | ||
|
|
9618a0278c | ||
|
|
486ab2f0d0 | ||
|
|
1527ebffc8 | ||
|
|
4dd46b156c | ||
|
|
c6db19de19 | ||
|
|
96ec790d58 | ||
|
|
133036a6c5 | ||
|
|
72c409f0f5 | ||
|
|
d4b39c06d6 | ||
|
|
6e58c22807 | ||
|
|
6e59f59d18 | ||
|
|
c647744470 | ||
|
|
2e33b4c467 | ||
|
|
087c856a46 | ||
|
|
9c37af7b07 | ||
|
|
450a38f7cd | ||
|
|
d98c425a9a | ||
|
|
b37c422e7d | ||
|
|
7726665a48 | ||
|
|
99e98f82f3 | ||
|
|
2263aa494f | ||
|
|
d7e9e94794 | ||
|
|
96bc7b53d7 | ||
|
|
e2f17f6f49 | ||
|
|
e4915dd496 | ||
|
|
e45c43100f | ||
|
|
18e493bcd1 | ||
|
|
34eedfa9af | ||
|
|
95e56303f7 | ||
|
|
9689ac8aae | ||
|
|
8487f561f6 | ||
|
|
16351abdbf | ||
|
|
d90270b204 | ||
|
|
48805c8072 | ||
|
|
ecc6c42bf1 | ||
|
|
d0fbbbc44e | ||
|
|
fd0fe43ef7 | ||
|
|
82108ebd97 | ||
|
|
ea83631609 | ||
|
|
0613b5bf29 | ||
|
|
2d72e870c4 | ||
|
|
4618f7656e | ||
|
|
bf70ba9df0 | ||
|
|
2a741f6407 | ||
|
|
94bf42cfda | ||
|
|
f41e3d2631 | ||
|
|
d739ad625d | ||
|
|
8e02300000 | ||
|
|
326c122231 | ||
|
|
26a017f1c4 | ||
|
|
0a9218f622 | ||
|
|
f9f442072b | ||
|
|
5a87a101c3 | ||
|
|
046f8b7e58 | ||
|
|
c81e4f1aa9 | ||
|
|
bc6a605629 | ||
|
|
798f10e4aa | ||
|
|
e81214956b | ||
|
|
401814cf3b | ||
|
|
0c86208455 | ||
|
|
3c8238d6fd | ||
|
|
558b1bcce2 | ||
|
|
55fb3535d0 | ||
|
|
7b274c7c7a | ||
|
|
d448a72d6c | ||
|
|
a2f0852437 | ||
|
|
7b05322577 | ||
|
|
259b625c3e | ||
|
|
732fdac78b | ||
|
|
5499971c54 | ||
|
|
fe09961383 | ||
|
|
037843a79d | ||
|
|
bbd6980cff | ||
|
|
b78d452d29 | ||
|
|
bc4bad8466 | ||
|
|
418d3fafce | ||
|
|
7f382565e8 | ||
|
|
1f14b818ef | ||
|
|
c2871304f1 | ||
|
|
8cbbc9cc6c | ||
|
|
9da1ef3ead | ||
|
|
bb71a2d063 | ||
|
|
89d2d0128e | ||
|
|
608bac62be | ||
|
|
35ce2a8d3d | ||
|
|
d6c88e809a | ||
|
|
a195589e51 | ||
|
|
8551d5417d | ||
|
|
aeba426203 | ||
|
|
04c79f6c5d | ||
|
|
696b3713ed | ||
|
|
5a21763605 | ||
|
|
b0525d10d4 | ||
|
|
4558804752 | ||
|
|
c1ef19849e | ||
|
|
458eb118b4 | ||
|
|
330cecfb04 | ||
|
|
b4fd3d3ab4 | ||
|
|
0c334a113a | ||
|
|
3a93de451c | ||
|
|
f5af5913cf | ||
|
|
8b22ea9aab | ||
|
|
fbcdff68da | ||
|
|
0b922e175f | ||
|
|
77d9804583 | ||
|
|
ccf9fa34f0 | ||
|
|
5393524dc7 | ||
|
|
46abc1c083 | ||
|
|
3310149fcc | ||
|
|
be75a02181 | ||
|
|
14125a0799 | ||
|
|
e720623895 | ||
|
|
03b5a6c388 | ||
|
|
10331e14d9 | ||
|
|
7095968282 | ||
|
|
0cb7404330 | ||
|
|
9d06dce473 | ||
|
|
e21944a031 | ||
|
|
c5f2e2c7f2 | ||
|
|
984ebc185d | ||
|
|
6242d4dee1 | ||
|
|
f29a504ead | ||
|
|
0845c93c4c | ||
|
|
88885f316a | ||
|
|
dab4808baa | ||
|
|
8d90bca6b4 | ||
|
|
5befef7ed7 | ||
|
|
49ea07eeaa | ||
|
|
0057fce558 | ||
|
|
682b5d77ee | ||
|
|
f5d26a1f61 | ||
|
|
25dfe714cb | ||
|
|
0fdcbbc778 | ||
|
|
c8fe822586 | ||
|
|
c145858bbe | ||
|
|
0f1fbcff93 | ||
|
|
cea7545481 | ||
|
|
a3fd236c36 | ||
|
|
cfe5595882 | ||
|
|
f63b28d54b | ||
|
|
d143e711d4 | ||
|
|
40f0ad5fdb | ||
|
|
8faf9002c2 | ||
|
|
b4d89e94dc | ||
|
|
5575d540b8 | ||
|
|
ef1b52030b | ||
|
|
f09566b56e | ||
|
|
e05880eefc | ||
|
|
55af4eab8f | ||
|
|
c3cecf9916 | ||
|
|
a89fc2b26e | ||
|
|
462721ce7f | ||
|
|
2c23ea980b | ||
|
|
2518605126 | ||
|
|
c8fab0dd81 | ||
|
|
d82ff15921 | ||
|
|
00a57eabec | ||
|
|
de1b33e2aa | ||
|
|
d079e2ad88 | ||
|
|
c99375bea6 | ||
|
|
645b8b510f |
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
server/data
|
||||
@@ -0,0 +1,59 @@
|
||||
# ---- ihasmail server configuration ----
|
||||
|
||||
# Base URL of your Stalwart server (scheme + host, no path). ihasmail discovers
|
||||
# the JMAP session at <STALWART_URL>/.well-known/jmap.
|
||||
STALWART_URL=https://mail.example.com
|
||||
|
||||
# Random secret used to derive encryption keys for persisted sessions.
|
||||
# Generate with: openssl rand -base64 48
|
||||
APP_SECRET=change-me
|
||||
|
||||
# Listen address
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# Set to "1" when running behind a TLS-terminating reverse proxy (trusts
|
||||
# X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev.
|
||||
TRUST_PROXY=1
|
||||
# Peers whose X-Forwarded-* headers are believed. Unset means loopback and the
|
||||
# private ranges, which covers a reverse proxy on the same host or Docker
|
||||
# network. A request from anywhere else is attributed to its socket address,
|
||||
# whatever the headers claim -- otherwise anyone could pick their own key for
|
||||
# the login rate limiter.
|
||||
# TRUSTED_PROXIES=10.0.0.0/8,192.168.1.5
|
||||
SECURE_COOKIES=auto
|
||||
|
||||
# Session lifetime (idle timeout) in seconds. "Remember me" extends to SESSION_REMEMBER_TTL.
|
||||
SESSION_TTL=43200
|
||||
SESSION_REMEMBER_TTL=2592000
|
||||
|
||||
# Where to persist sessions so restarts don't log everyone out (optional).
|
||||
# Leave it empty to hold sessions in memory only, which is what an immutable
|
||||
# instance does -- see IMMUTABLE below.
|
||||
SESSION_FILE=./data/sessions.json
|
||||
|
||||
# Assert that this instance is running as an immutable container: read-only
|
||||
# root filesystem, no durable state of its own. It is checked rather than
|
||||
# taken on trust -- the server refuses to start if SESSION_FILE is set, or if
|
||||
# the filesystem it is installed on turns out to be writable. Off by default.
|
||||
# Running one looks like:
|
||||
# docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
# The cost today is that a restart signs everyone out, since there is nowhere
|
||||
# left to keep the sessions. Removing that cost is what the OAuth work is for.
|
||||
# IMMUTABLE=1
|
||||
|
||||
# Upstream timeouts / limits
|
||||
UPSTREAM_TIMEOUT=30000
|
||||
MAX_UPLOAD_BYTES=52428800
|
||||
|
||||
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
|
||||
IMAGE_PROXY=1
|
||||
|
||||
# Branding
|
||||
APP_NAME=ihasmail
|
||||
|
||||
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which
|
||||
# asks whoever runs a modified version to offer *that* version's source -- so if
|
||||
# you have patched it, point this at your own tree. Shown on the sign-in page
|
||||
# and in Settings > About.
|
||||
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail
|
||||
@@ -0,0 +1,28 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# Lets CI be run by hand against any ref, including a specific commit.
|
||||
# Without this there is no way to re-run a check that never started: a run
|
||||
# GitHub queues and then orphans -- as it did to every run created during the
|
||||
# Actions outage on 2026-08-26 -- can be neither rerun ("already running")
|
||||
# nor cancelled ("already completed"), and the workflow has no other trigger
|
||||
# to reach for. Useful too for putting a check on a commit that predates a CI
|
||||
# change, without pushing an empty commit to move it.
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci --ignore-scripts
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
- name: Docker build
|
||||
run: docker build -t ihasmail:ci .
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
server/data/
|
||||
.vite/
|
||||
coverage/
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**johnellisATlinuxDOTcom**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Contributing to ihasmail
|
||||
|
||||
Thanks for your interest in contributing to **ihasmail** — a Gmail-style, JMAP-only webmail client for [Stalwart Mail Server](https://stalw.art/). Contributions of all kinds are welcome: bug reports, feature requests, code, documentation, and testing.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
By participating in this project, you agree to treat other contributors with respect. Be constructive, be patient with newcomers, and keep discussion focused on the project. Harassment or abusive behavior toward other contributors will not be tolerated.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- ihasmail speaks **JMAP only** — it does not support IMAP/POP3/SMTP fallback paths. Keep this in mind when proposing features.
|
||||
- ihasmail has **no database of its own** — all state lives in Stalwart via JMAP. Contributions should not introduce a separate persistence layer without discussion first.
|
||||
- This project is licensed under **AGPL-3.0**. Any code you contribute will be distributed under this license, including for hosted/SaaS deployments.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before opening a new issue, please search [existing issues](https://github.com/Coffey-Labs/ihasmail/issues) to see if it's already been reported. When filing a bug report, include:
|
||||
|
||||
- A clear, descriptive title
|
||||
- Steps to reproduce the issue
|
||||
- Expected behavior vs. actual behavior
|
||||
- Your environment: browser/OS, Stalwart version, and how ihasmail is deployed (Docker, bare metal, etc.)
|
||||
- Relevant logs, console errors, or screenshots
|
||||
- Whether the issue is reproducible against a fresh Stalwart instance
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
Open an issue describing:
|
||||
|
||||
- The problem you're trying to solve (not just the solution)
|
||||
- How it fits with ihasmail's JMAP-only, Gmail-style design philosophy
|
||||
- Any relevant JMAP RFC references (RFC 8620, RFC 8621) if the feature touches protocol behavior
|
||||
|
||||
For larger changes, please open an issue to discuss the approach **before** submitting a pull request — this saves everyone time if the direction needs adjusting.
|
||||
|
||||
### Submitting Pull Requests
|
||||
|
||||
1. **Fork** the repository and create your branch from `main`.
|
||||
2. **Name your branch** descriptively, e.g. `fix/thread-view-scroll` or `feat/search-filters`.
|
||||
3. **Keep PRs focused** — one logical change per PR. Large, unrelated changes bundled together are harder to review and more likely to be rejected.
|
||||
4. **Write clear commit messages** describing what changed and why.
|
||||
5. **Test your changes** against a real (or local) Stalwart instance where possible, since JMAP behavior can be subtle.
|
||||
6. **Update documentation** if your change affects setup, configuration, or user-facing behavior.
|
||||
7. **Open the pull request** against `main`, filling out the PR template with:
|
||||
- A summary of the change
|
||||
- Related issue number(s), if any
|
||||
- Screenshots/GIFs for UI changes
|
||||
- Any manual testing you performed
|
||||
|
||||
### Code Style
|
||||
|
||||
- Match the existing formatting and naming conventions used elsewhere in the codebase.
|
||||
- Keep functions small and single-purpose where practical.
|
||||
- Prefer clarity over cleverness — this is a mail client people rely on for their inbox.
|
||||
- Comment non-obvious JMAP interactions, especially around state/`changes` handling, since JMAP's delta-sync model can be easy to get subtly wrong.
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/ihasmail.git
|
||||
cd ihasmail
|
||||
```
|
||||
2. Point your local instance at a running Stalwart Mail Server (a test/dev instance is strongly recommended — do not develop against a production mailbox).
|
||||
3. Follow the setup instructions in the repository's `README.md` for installing dependencies and running the app locally.
|
||||
4. Verify your changes don't break existing JMAP calls by exercising core flows: login, list/read mail, send, search, and folder/label operations.
|
||||
|
||||
## Review Process
|
||||
|
||||
- A maintainer will review your PR and may request changes.
|
||||
- Please respond to review feedback in a timely manner; PRs with no activity for an extended period may be closed and can be reopened once updated.
|
||||
- Once approved, a maintainer will merge the PR.
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **johnellisATlinuxDOTcom** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
|
||||
|
||||
## Questions?
|
||||
|
||||
If you're unsure whether something is a good fit, open an issue and ask — discussion is welcome before you invest time in a PR.
|
||||
|
||||
Thanks again for helping improve ihasmail!
|
||||
@@ -0,0 +1,9 @@
|
||||
# Example reverse proxy (Caddy) in front of ihasmail.
|
||||
# TLS is automatic. ihasmail sets Secure cookies and HSTS when X-Forwarded-Proto is https.
|
||||
mail.example.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
# Keep SSE (push) connections open
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,51 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
# ---- build stage ----
|
||||
FROM node:22-alpine AS build
|
||||
# What this build calls itself: 2.16.<PR>, worked out by whoever runs the
|
||||
# build. It cannot be worked out in here -- .dockerignore keeps .git out of the
|
||||
# context on purpose, and git is not installed either. `node scripts/version.mjs`
|
||||
# in a checkout prints the right answer; ihasmail-deploy.sh passes it through.
|
||||
# Left empty, the build falls back to the base version from package.json.
|
||||
ARG IHASMAIL_VERSION=""
|
||||
ENV IHASMAIL_VERSION=$IHASMAIL_VERSION
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY server/package.json server/
|
||||
COPY web/package.json web/
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
COPY pyproject.toml README.md /app/
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
COPY app /app/app
|
||||
COPY .env.example /app/.env.example
|
||||
|
||||
ENV PORT=8000
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"]
|
||||
# ---- runtime stage ----
|
||||
FROM node:22-alpine AS runtime
|
||||
# Re-declared: an ARG does not cross stages.
|
||||
ARG IHASMAIL_VERSION=""
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
STATIC_DIR=/app/web/dist \
|
||||
SESSION_FILE=/data/sessions.json \
|
||||
IHASMAIL_VERSION=$IHASMAIL_VERSION
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY server/package.json server/
|
||||
# config.ts reads the version through this at startup. With IHASMAIL_VERSION
|
||||
# set it never looks further; without it, it falls back to package.json rather
|
||||
# than failing, since there is no git in here to ask.
|
||||
COPY scripts/ ./scripts/
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/web/dist ./web/dist
|
||||
RUN mkdir -p /data && chown -R node:node /data /app
|
||||
USER node
|
||||
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
|
||||
# goes, but Docker acts on it: a container started without `-v` gets an
|
||||
# anonymous volume mounted there anyway, and that mount stays writable even
|
||||
# under `--read-only`. So the directive quietly put a writable hole in a
|
||||
# container meant to be immutable, and left an orphaned volume behind every
|
||||
# time one was replaced -- while never persisting anything across a redeploy,
|
||||
# since each new container got a fresh empty volume of its own. Deployments
|
||||
# that want the sessions to survive say so themselves: docker-compose.yml and
|
||||
# deploy.example.sh both mount a *named* volume at /data, which is unaffected.
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Known issues and pending QA
|
||||
|
||||
What was checked, against which server, and when. For a failure you are hitting
|
||||
right now, start with [Troubleshooting](https://docs.ihasmail.org/troubleshooting/);
|
||||
for what is not built yet, see [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
The live instance runs **0.16.20**, upgraded from 0.16.19 on 2026-08-31 with
|
||||
eight seconds of downtime, and as of **2026-08-26 there is nothing left
|
||||
pending**. Every entry below was exercised against 0.16.19 on the date it
|
||||
names, and the dates still say so: the upgrade was read against the
|
||||
0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session
|
||||
capabilities, blob, quota, submission or registry paths these entries describe.
|
||||
The calendar entries below carrying a 2026-08-31 date are the exception: those
|
||||
were exercised against the live 0.16.20 directly.
|
||||
What remains here is not a list of unknowns but of things worth knowing — where
|
||||
Stalwart departs from a spec, where a setting has to be turned on for a feature
|
||||
to work, and what ihasmail deliberately does not do.
|
||||
|
||||
Entries keep saying what was checked and when, because this section has been
|
||||
wrong before: the 0.16 registry path was once recorded as verified live when a
|
||||
capability looked for in the wrong place meant it had never run at all.
|
||||
|
||||
Some entries record what a live **0.15.5** proved before that server was
|
||||
upgraded on 2026-08-25. They are kept where the finding is about ihasmail
|
||||
rather than about 0.15 — a byte cap that still applies, a flow that still
|
||||
works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
|
||||
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
|
||||
|
||||
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
|
||||
- **Sharing a mail folder is accepted and does nothing.** `Mailbox/set` with a `shareWith` map is applied, `Mailbox/get` reads it back, and the folder never appears for the account it was shared with — **confirmed live on 0.16.19 (2026-08-27)** with a folder shared read-only to another account on the same server, which never saw it. Stalwart's own sharing documentation lists calendars, address books and file storage; mail folders are not among them. Nothing reports a failure at any point, which is the whole problem: the share is stored, so a client that trusts what it reads back shows it as live for ever. The entry point is withdrawn. A folder that is *already* shared still offers **Stop sharing**, because a share nobody can see is exactly the one you want to be able to clear, and there is no other way to. File sharing is unaffected and works end to end.
|
||||
- **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message.
|
||||
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
|
||||
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock now omits it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server.
|
||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||||
- **Settings live in the account's Files, not the browser** — every preference used to sit in `localStorage`, so none of them followed anyone between devices. The sharpest edge was the default identity: with none set the address that sorts first wins, so someone who set it at work found it unset at home and mail went out from an address the recipient might not recognise ([#54](https://github.com/Coffey-Labs/ihasmail/issues/54)). They are now a `settings.json` in the `ihasmail` folder in JMAP Files, beside the signature images already kept there — which keeps ihasmail itself stateless: no volume, no database, nothing to back up separately, and the settings are covered by whatever backs up the mail store. `x:AccountSettings` was the other candidate and does not fit; its schema is `locale`/`timeZone`/`description` with no free-form field, and writing it needs `sysAccountSettingsSet`, where the built-in user role carries only the `…Get` half. `localStorage` stays on as a *cache* rather than the source of truth, so the first frame paints from it and the file corrects it a moment later; a browser with no cache shows defaults for that one frame, which is the trade for not gating the whole app on a round trip. Settings that describe *this* screen or browser deliberately stay local — list-pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per-device and would be a claim about somewhere else it cannot make. That split is written as a list of exceptions, so a setting added later syncs by default. Writes are coalesced behind a three-second debounce, since `update()` fires on every frame of a splitter drag, and a tab going away or a sign-out flushes first. The `ihasmail` folder is now hidden from the Files view, contents and all: hiding the folder alone would be worse than showing it, because the tree attaches a node whose parent is missing to the root, so the signature images — visible there since signatures shipped — would have spilled into the top level. **Confirmed live on 0.16.19 (2026-08-26)**: settings set in Chrome came back on a fresh login in Firefox and in an incognito session, both of which start with an empty cache, so each read the account's file rather than anything local. Confirmed again on the deployed instance rather than only a pre-deployment build. Requires 0.16, which ihasmail now requires everywhere — `FileNode/query` cannot see directories before that, and sign-in refuses an older server outright. Two limits worth knowing: conflicts are last-write-wins, and a change made on one device does not reach another that already has ihasmail open until it signs in again.
|
||||
- **Files on 0.16** — the pre-0.16 quirks this entry used to describe are gone with the support for them: `FileNode/query` masking directories out of its own results, `nodeType` not existing, and rights being a single `mayWrite`. What is left is what has actually been exercised on 0.16.19. Finding and creating a folder, creating a node with `nodeType`, uploading and downloading its blob, and pointing an existing node at a new one all ran live on 2026-08-26, as a side effect of the settings file. Rename, move and delete are **confirmed live on 0.16.19 (2026-08-26)** as well, which closes this out: what had been confirmed on 0.15.5 (2026-08-24) was the older code path, and that path no longer exists. Two fallbacks went with the removal and are worth knowing about: `ensureFolder` and `findInFolder` now filter on `parentId`/`isTopLevel` alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query; and a refused filter or sort no longer drops the view into fetching every node in the account, which would have hidden a real fault behind a performance cliff nobody would notice.
|
||||
- **Self-service credentials** — the registry path is **confirmed live** against Stalwart 0.16.19 (2026-08-25): app passwords created and revoked, password changed, 2FA enabled and disabled, with the browser session surviving the switch to an app password. The 0.15 REST path was confirmed live too, on 0.15.5 (2026-08-24), and has since been removed along with the rest of 0.15 support. The mock enforces the same rules the real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it). Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
||||
- **Scheduled send needs one setting turned on, and says nothing when it is off.** Stalwart advertises the delay in the account's `urn:ietf:params:jmap:submission` capability — `maxDelayedSend: 2592000` (30 days) and `FUTURERELEASE` among its `submissionExtensions`, and note it is the *account* capability, not the session-level one, which is empty. But the MTA only honours a hold when `futureRelease` is set under the session's MTA extensions, and [that setting defaults to `false`](https://stalw.art/docs/ref/object/mta-extensions/). With it off, Stalwart takes the `HOLDUNTIL` parameter, skips the hold and sends the message immediately **without an error** — the capability still says thirty days. So set `futureRelease` (to the longest hold you want to allow) before relying on this; a value shorter than 30 days is fine, and a request past it is refused honestly, with a `forbiddenMailFrom` naming the limit. `npm run dev:mock:no-future-release` reproduces the silent-drop case. ihasmail asks for the delay the way JMAP requires — a `HOLDUNTIL` parameter on the envelope's `mailFrom`, since RFC 8621 makes `sendAt` read-only and server-derived — and files the held message in a **Scheduled** folder, because `onSuccessUpdateEmail` would otherwise drop it in Sent the moment the submission is created. Nothing moves it out when the hold expires, so ihasmail reconciles the folder on the way in: released messages to Sent, cancelled ones back to Drafts. Three fixes this depends on landed in **0.16.17**, below the live instance's 0.16.19: `HOLDUNTIL` taking RFC 3339 date-times again (0.16.16 had it wanting Unix timestamps), `EmailSubmission/query` on `undoStatus` agreeing with `/get` about held submissions, and `EmailSubmission/get` without `ids` iterating the right index. The hold itself is now **confirmed against the live 0.16.19** (2026-08-25), once `futureRelease` was set to `30d` there: a submission carrying a `HOLDUNTIL` ten minutes out came back `pending`, with `sendAt` equal to the time asked for and a `250 2.1.5 Queued` from the MTA, rather than going out at once. Worth repeating that the capability is no evidence either way — it advertised `maxDelayedSend: 2592000` and `FUTURERELEASE` while the setting was still off. Only a submission tells you. The rest of the journey is **confirmed live too (2026-08-26)**: a hold expired and was delivered, and the **Scheduled** folder reconciled on the way in — a released message moved to Sent, a cancelled one back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.
|
||||
- **Stalwart 0.16 and RFC 8984 disagree about the calendar vocabulary, and the server only says so half the time.** A participant's address lives in `calendarAddress`, not RFC 8984's `sendTo`/`email`; the organizer is `organizerCalendarAddress`, not `replyTo`; and a recurrence is a single `recurrenceRule`, not a `recurrenceRules` array. Addressed the RFC's way, `CalendarEvent/set` **keeps the event and discards the whole participant map without an error** — guests disappeared on save and no invitation was ever sent, which is what [#26](https://github.com/Coffey-Labs/ihasmail/issues/26) reported. The array form of the rule is refused honestly, with `invalidProperties`, so recurring events could not be created at all and existing ones showed no repeat ([#30](https://github.com/Coffey-Labs/ihasmail/issues/30)). ihasmail now writes Stalwart's names and reads either, and the mock refuses what the real server refuses, since advertising the RFC spelling is precisely how this got as far as a live server. Verified against 0.16.19 on 2026-08-25, end to end: participants, organizer and rule all survive a create, an update and a re-read; an invitation to an external Gmail address arrived as an invite card, and the decline came back and was applied to the event (`needs-action` → `declined`, sequence 1). Cancelling the event notified the guest too. Adding guests to an event that had none, and clearing them again with `null`, both work on the update path, as does RSVP — which patches `participants/{key}/participationStatus` (and `participationComment`) rather than sending the whole map. That patch had to be aimed at the base event: through 0.16.19 `CalendarEvent/set` refused a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 0.16.20 accepts one, so that resolution is now a choice rather than the only option — an RSVP aimed at an occurrence would answer for that date alone. It still resolves the base, which is the answer people mean. Adding a *new* participant by patch is refused as well (`Patch operation failed`), so a changed guest list is written as the whole `participants` property. One more thing to know when reading this code: an expanded occurrence carries a `recurrenceId` but *no* rule of its own, and `baseEventId` is set on everything an expanded query returns — a one-off included, whose own id differs from its base — so neither is a test for recurrence.
|
||||
- **An override can move an occurrence, and then `start` and `recurrenceId` mean two different times.** The slot stays where the rule put it and only the clock time moves. **Confirmed live on 0.16.20 (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00 came back `start: 2027-06-14T14:00:00` with `recurrenceId` still `2027-06-14T09:00:00`. This is the right behaviour and it is the reason `recurrenceId` is the handle ihasmail holds: it is the one name for an instance that survives *both* a renumbering and a move, so a mutation can always be re-resolved from it. Worth recording because the mock got it wrong in the other direction — it overwrote an override's `start` with the slot time, so a moved occurrence did not move, and per-occurrence *time* editing looked broken against the mock and correct against the server. Found by asking a real server rather than by reading the mock, which is the only way this kind of disagreement ever surfaces.
|
||||
|
||||
- **A synthetic id is only true until the next write, and a stale one is wrong rather than invalid.** Stalwart's expanded-occurrence ids encode a position in the series, and writing a `recurrenceOverrides` entry adds a component that renumbers it. **Confirmed live on 0.16.20 (2026-08-31)**: a five-week series came back as `e i m q u` over 03-01 … 03-29; one override written to 03-08 left the *same five ids* addressing 03-01, 03-15, 03-29, 03-08 and 03-22. Nothing was rejected and nothing reported a change — `i` simply meant a week later than it had a moment earlier. So an id cached across a write silently points at another date, and a delete meant for one occurrence removes a different one. This is the second time the same shape of problem has cost a live debugging session, and it is worth saying plainly why it is dangerous: the failure is not a `notFound` a client would notice, it is a confident answer about the wrong day. ihasmail therefore never mutates an occurrence by an id it is holding. `recurrenceId` is the stable name for a slot in a series — it is the date — so `updateEvent` and `destroyEvent` look the current id up by it immediately before they act, and refuse outright if the date is no longer in the series rather than falling back to the id in hand. The mock renumbers too, by a different permutation to the real server's but with the property that matters, since a mock that kept ids stable would agree with precisely the belief that is wrong.
|
||||
|
||||
- **A per-occurrence patch made only of inherited properties creates an override that loses the title.** The twelve properties 0.16.20 drops from a per-occurrence patch are dropped *after* it has decided to write an override, so a patch consisting only of them still writes one — and that override carries the `start` and `duration` the server fills in and nothing else. **Confirmed live on 0.16.20 (2026-08-31)**: `{"privacy": "private"}` aimed at one occurrence answered `updated`, left `privacy` untouched on the series, and left that date with no title at all. A successful response, a silently discarded change, and real data loss on a third property nobody mentioned. ihasmail narrows a per-occurrence patch before sending it and sends nothing when narrowing empties it, which was written as a point of principle — a request whose response could only be a meaningless "updated" is worse than no request — and turns out to prevent this. Worth remembering as the argument for the principle.
|
||||
|
||||
- **Recurring events can be edited and deleted one date at a time, since 0.16.20.** A write aimed at a synthetic id was refused outright through 0.16.19; 0.16.20 turns it into a `recurrenceOverrides` entry instead, so "this occurrence" and "the whole series" are now two different things ihasmail asks about before acting. **Confirmed live on 0.16.20 (2026-08-31)** end to end against a five-week series: a legal patch landed on the override with `start` and `duration` filled in by the server; `useDefaultAlerts` was refused with *"This property cannot be modified on a single occurrence."*; a destroy removed one date and left the series; and a base event and one of its instances in the same request were refused together, both ids, with *"A base event and its instances cannot be modified in the same request."* The scope is chosen before the editor opens rather than on save, because it decides which event the form is about — one populated from the master shows the *series'* start date, so editing Wednesday would have offered to move Monday. Two entries below are the sharp edges this turned up.
|
||||
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
|
||||
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand. Confirmed live on 0.16.19 (2026-08-25), once the capability was looked for where Stalwart advertises it; a locale request that is merely refused no longer downgrades the detected generation.
|
||||
@@ -1,16 +1,661 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2025 John Coffey
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, version 3 of the License.
|
||||
Preamble
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.PHONY: run dev test build
|
||||
|
||||
run:
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
dev:
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
test:
|
||||
pytest
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
@@ -1,60 +1,231 @@
|
||||
<p align="center">
|
||||
<img src="web/public/img/logo.png" alt="ihasmail" width="150">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="Licence: AGPL-3.0-or-later" src="https://img.shields.io/badge/licence-AGPL--3.0--or--later-2dd4bf?style=flat-square"></a>
|
||||
<a href="https://stalw.art" target="_blank" rel="noreferrer"><img alt="Requires Stalwart 0.16 or newer; tested against 0.16.20" src="https://img.shields.io/badge/Stalwart-0.16.20-6366f1?style=flat-square"></a>
|
||||
<a href="https://docs.ihasmail.org" target="_blank" rel="noreferrer"><img alt="Documentation: docs.ihasmail.org" src="https://img.shields.io/badge/docs-docs.ihasmail.org-0ea5e9?style=flat-square"></a>
|
||||
<a href="https://coffeylabs.org" target="_blank" rel="noreferrer"><img alt="by Coffey Labs" src="https://img.shields.io/badge/by-Coffey%20Labs-0f766e?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
# ihasmail
|
||||
|
||||

|
||||
**Immutable webmail for [Stalwart Mail Server](https://stalw.art) — a container
|
||||
with nothing to persist, and a Gmail-class client on top of it.**
|
||||
|
||||
A polished, FastAPI + HTMX/Jinja webmail for Stalwart, with JMAP mail/contacts/calendar, Sieve UI, DAV browsing, and reverse-proxy friendly deploy.
|
||||
Mail, calendars, contacts, files and filters in a responsive single-page app
|
||||
that works equally well on a desktop monitor and a phone. It talks only JMAP
|
||||
(plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no
|
||||
database, and with `IMMUTABLE=1` no writable filesystem either. Everything
|
||||
durable belongs to Stalwart; the container is disposable.
|
||||
|
||||
A production-leaning, **FastAPI** + **HTMX/Jinja** webmail for [Stalwart Mail Server](https://stalw.art/), using **JMAP** for mail, contacts, and calendar, plus simple **WebDAV/CalDAV** helpers. Authenticates with the user's Stalwart mailbox (like Roundcube). Designed to run behind a reverse proxy.
|
||||
| | |
|
||||
| --- | --- |
|
||||
| 🌐 **[ihasmail.org](https://ihasmail.org)** | What it is, what it looks like, the full feature list |
|
||||
| 📘 **[docs.ihasmail.org](https://docs.ihasmail.org)** | [Installing](https://docs.ihasmail.org/install/) · [Configuring](https://docs.ihasmail.org/configure/) · [Using it](https://docs.ihasmail.org/using/) · [Shortcuts](https://docs.ihasmail.org/shortcuts/) · [Rebranding](https://docs.ihasmail.org/rebranding/) · [Troubleshooting](https://docs.ihasmail.org/troubleshooting/) |
|
||||
| 🧪 **[KNOWN-ISSUES.md](KNOWN-ISSUES.md)** | What was verified live, and where Stalwart departs from a spec |
|
||||
| 🛣 **[ROADMAP.md](ROADMAP.md)** | What ihasmail does not do, and why |
|
||||
|
||||
## Features
|
||||
- Login with Stalwart mailbox (HTTP Basic against JMAP session or bearer token if provided)
|
||||
- Inbox listing, read messages (plain text), compose & send via JMAP (`Email`, `EmailSubmission`)
|
||||
- Contacts/Directory via JMAP `Contact`
|
||||
- Calendar view via JMAP `CalendarEvent`
|
||||
- WebDAV browser (read-only sample) and CalDAV endpoints (external DAV clients)
|
||||
- CSRF on POST, signed session cookie, proxy-friendly
|
||||
- Dockerfile + docker-compose for easy deploy
|
||||
This file is for people working *on* ihasmail. Everything about running it
|
||||
lives in the docs.
|
||||
|
||||
> HTML rendering and attachment streaming are stubbed—extend using the JMAP `downloadUrl` and sanitize HTML before display.
|
||||
## Screenshots
|
||||
|
||||
## Quick Start (Docker)
|
||||
*Taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Inbox & conversation (dark)**  | **Inbox & conversation (light)**  |
|
||||
| **Composer**  | **Calendar**  |
|
||||
| **Contacts**  | **Sieve filter builder**  |
|
||||
|
||||
More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#screenshots).
|
||||
|
||||
## What's in it
|
||||
|
||||
- **Mail** — three-pane Gmail-style layout, conversation view, virtualised list, labels, undo, Gmail search operators and keyboard shortcuts, Sieve rules from a message's context menu, sanitised HTML with remote images blocked, read receipts, invitations and RSVP, multi-composer rich-text editing with signatures, scheduled send and undo send
|
||||
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
|
||||
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
||||
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
||||
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
|
||||
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
|
||||
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
|
||||
|
||||
The long version is on [ihasmail.org](https://ihasmail.org/#features); how to
|
||||
drive each one is in [Using ihasmail](https://docs.ihasmail.org/using/).
|
||||
|
||||
## Requires Stalwart 0.16 or newer
|
||||
|
||||
Sign-in refuses anything older, by name. 0.16 replaced the REST management API
|
||||
with JMAP registry objects, changed the shape of `FileNode`, split its rights up
|
||||
and moved configuration into the store; supporting both generations meant a
|
||||
wrong guess had somewhere to fall back to, so it failed *quietly* — and that
|
||||
reached production. With one supported generation a wrong guess is a loud error
|
||||
on the first call.
|
||||
|
||||
- Still on 0.15? The last release that runs on it is tagged [`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
- Upgrading? [stalwart-migrator](https://github.com/Coffey-Labs/stalwart-migrator) does it in place, checkpointing every phase and validating afterwards. The live instance moved 0.15.5 → 0.16.19 with eight seconds of downtime and nothing lost.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
# 1) Configure environment
|
||||
cp .env.example .env
|
||||
# Edit JMAP_BASE, CALDAV_BASE, WEBDAV_BASE, APP_SECRET
|
||||
|
||||
# 2) Build & run
|
||||
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
|
||||
docker compose up --build -d
|
||||
|
||||
# 3) Reverse proxy (Nginx/Caddy) to http://127.0.0.1:8080
|
||||
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
- `APP_SECRET` – random string for signing cookies (required)
|
||||
- `JMAP_BASE` – e.g., `https://mail.example.com/jmap`
|
||||
- `CALDAV_BASE` – e.g., `https://mail.example.com/caldav/`
|
||||
- `WEBDAV_BASE` – e.g., `https://mail.example.com/webdav/`
|
||||
- `COOKIE_NAME` – cookie name (default: `stalwart_webmail`)
|
||||
- `TRUST_PROXY` – `1` to honor `X-Forwarded-*` (default: `1`)
|
||||
- `UPSTREAM_TIMEOUT` – seconds for upstream HTTP (default: `15`)
|
||||
Users sign in with their Stalwart mailbox credentials. **An account with
|
||||
two-factor authentication needs an app password**, created in Stalwart's own
|
||||
settings — Stalwart accepts a TOTP code only through an OAuth flow and offers no
|
||||
password grant, so no client holding a username and password can exchange them
|
||||
plus a code for a token.
|
||||
|
||||
Full instructions, TLS, and every environment variable:
|
||||
[Installing](https://docs.ihasmail.org/install/) ·
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
|
||||
### Running immutably
|
||||
|
||||
The server writes to exactly one path, the optional `SESSION_FILE`. Clear it
|
||||
and there is nothing left to write, so the container can run with no writable
|
||||
filesystem at all:
|
||||
|
||||
## Dev
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
uvicorn app.main:app --reload
|
||||
pytest
|
||||
docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
```
|
||||
|
||||
## Security & Hardening
|
||||
- Prefer **bearer tokens** if Stalwart issues them; update `jmap_session()` to store `accessToken`
|
||||
- Set explicit `accountId` from the JMAP session `primaryAccounts`
|
||||
- Add mailbox/folder navigation via `Mailbox/query` + `Mailbox/get`
|
||||
- Sanitize HTML bodies (e.g., `bleach`) before rendering
|
||||
- Add Sieve UI via `urn:ietf:params:jmap:sieve`
|
||||
- Consider rate limiting and security headers in the reverse proxy
|
||||
- Serve static assets via proxy/CDN
|
||||
`IMMUTABLE=1` is an assertion the server checks at startup rather than a switch
|
||||
that changes what it does: it refuses to start if `SESSION_FILE` is still set,
|
||||
or if the filesystem it is installed on turns out to be writable after all.
|
||||
Without it the same misconfiguration is silent — sessions are held in memory
|
||||
and persisting them is best-effort, so a read-only `/data` costs one warning at
|
||||
the first sign-in and nothing else until the instance is replaced and everyone
|
||||
is signed out.
|
||||
|
||||
That sign-out is the standing cost of this mode today, since sessions have
|
||||
nowhere to live across a restart. Removing it means moving the session upstream
|
||||
into a token Stalwart itself issues and can revoke, which is what the OAuth work
|
||||
in [ROADMAP.md](ROADMAP.md) is for.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart
|
||||
React SPA • session cookie ⇄ Basic auth
|
||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
||||
```
|
||||
|
||||
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views`, `src/lib` (sanitiser, search parser, Sieve codec, locale-aware dates, vCard, …).
|
||||
- `server/` — Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, seals the credentials with a key derived from the cookie secret, proxies JMAP/blob/SSE, serves the SPA under a strict CSP. `src/mock/` is an in-memory fake Stalwart for development and demos.
|
||||
|
||||
Capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`,
|
||||
`contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`),
|
||||
`quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own
|
||||
`urn:stalwart:jmap` (read-only). Features degrade gracefully when one is
|
||||
missing.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
|
||||
npm run dev # real Stalwart (STALWART_URL in .env) — server :8080, Vite :5173
|
||||
npm run dev:mock # built-in mock Stalwart ([email protected] / demo), mock on :8788
|
||||
npm run dev:mock:no-future-release # mock that advertises FUTURERELEASE and drops every hold
|
||||
|
||||
npm run typecheck # tsc for both packages
|
||||
npm test # vitest (web) + node:test (server)
|
||||
npm run build # web/dist + server/dist
|
||||
npm start # serve the production build
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in dev, or http://localhost:8080 for the production
|
||||
build. Running it for real is covered in
|
||||
[Installing](https://docs.ihasmail.org/install/) and
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
|
||||
### The mock
|
||||
|
||||
An in-memory fake Stalwart 0.16 — enough JMAP to develop and demo against
|
||||
without a real mailbox. It reproduces the things a naive fake would get wrong,
|
||||
because each cost a live debugging session: `urn:stalwart:jmap` advertised
|
||||
**per-account** rather than session-level, identity signatures capped at 2047
|
||||
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
|
||||
RFC 8984's. Two switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
|
||||
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
|
||||
the sign-in refusal can be tested.
|
||||
|
||||
### Version numbers
|
||||
|
||||
`ihasmail v2026.8.30+pr129` — the date of the commit this was built from, and
|
||||
the pull request that commit arrived through. A commit that did not arrive
|
||||
through one carries its short SHA instead: `2026.8.30+g1fa6578`. It all comes
|
||||
from git at build time; nothing writes a version into the tree, and
|
||||
`package.json` sits at `0.0.0` because it is no longer the source of anything.
|
||||
|
||||
The date is the commit's own rather than today's, so rebuilding an old commit
|
||||
gives the version it had the first time.
|
||||
|
||||
```bash
|
||||
node scripts/version.mjs # the version for the current checkout
|
||||
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2026.8.30 .
|
||||
```
|
||||
|
||||
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
|
||||
out for itself — pass it in. Left out, the build reports `0.0.0`, which is meant
|
||||
to look wrong: a version with no `+pr` or `+g` means whoever built the image did
|
||||
not pass one.
|
||||
|
||||
The version says nothing about Stalwart, deliberately. It used to: `2.16.x` had
|
||||
`16` for the 0.16 generation it targeted, which leaves nowhere to go once
|
||||
Stalwart reaches 1.0 — `2.1` sorts *below* the `2.16` already deployed, so every
|
||||
image and About screen would read as a downgrade. Which Stalwart a build needs is
|
||||
stated where it can be precise, in the badge at the top of this file and in
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md), rather than compressed into one digit.
|
||||
|
||||
The pull request lives after the `+`, as build metadata, because it is
|
||||
provenance rather than a rank: at the rate they merge here it climbs without
|
||||
bound and says nothing about how new a build is. Everything after the `+` is
|
||||
ignored when versions are compared, which is the right reading — two builds from
|
||||
the same day differ in where they came from, not in age. Nothing here depends on
|
||||
that comparison: images are pruned oldest-first by creation time, and a rollback
|
||||
names a git ref.
|
||||
|
||||
### Deploying
|
||||
|
||||
[`deploy.example.sh`](deploy.example.sh) is a single-host Docker deploy: it
|
||||
fetches, refuses anything held back by `.deploy-hold`, shows what is about to be
|
||||
introduced and asks, rebuilds with the right version baked in, replaces the
|
||||
container, waits for healthy, then prunes all but the newest
|
||||
`IHASMAIL_KEEP_VERSIONS` images — never the one actually running.
|
||||
|
||||
```bash
|
||||
./deploy.sh # origin/main, asks before shipping new commits
|
||||
./deploy.sh --dry-run # run the guards and stop
|
||||
./deploy.sh v2026.8.30 --yes # a named ref, no prompt (there is no tty over ssh)
|
||||
```
|
||||
|
||||
`--yes` does not override a hold; clearing one means deleting its line.
|
||||
|
||||
## Contributing
|
||||
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
||||
[SECURITY.md](SECURITY.md) — please report vulnerabilities privately.
|
||||
|
||||
## License
|
||||
GPL-3.0-or-later
|
||||
|
||||
Copyright (C) 2026 Coffey Labs — AGPL-3.0-or-later. See
|
||||
[LICENSE](LICENSE).
|
||||
|
||||
ihasmail was relicensed from GPL-3.0 to AGPL-3.0 on 2026-08-25: webmail is
|
||||
nearly always run as a network service rather than handed to anyone as a binary,
|
||||
and the AGPL's section 13 closes that gap.
|
||||
|
||||
That offer has to point at *your* source, not this one. If you run a modified
|
||||
ihasmail, set `SOURCE_URL` to your own repository — the sign-in page and
|
||||
Settings › About both show it. See
|
||||
[Rebranding](https://docs.ihasmail.org/rebranding/).
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Roadmap / not yet
|
||||
|
||||
Things ihasmail does not do, and why. An issue number here says where the entry
|
||||
came from, not that it is tracked elsewhere — a report can be closed because the
|
||||
bug in it was fixed while the larger thing it asked for stays on this page. What
|
||||
is genuinely open lives in [the issue tracker](https://github.com/Coffey-Labs/ihasmail/issues);
|
||||
the rest is here because the answer is "no", not "not yet".
|
||||
|
||||
See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about.
|
||||
|
||||
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
|
||||
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
|
||||
- Translations (strings are English-only for now)
|
||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
ihasmail is under active development. Security fixes are applied to the latest release on the `main` branch. Older tags/releases are not guaranteed to receive backported fixes.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------- | ------------------ |
|
||||
| `main` (latest) | :white_check_mark: |
|
||||
| Older releases | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public GitHub issue for security vulnerabilities.** Public issues are visible to everyone, including potential attackers, before a fix is available.
|
||||
|
||||
Instead, report security issues privately by emailing:
|
||||
|
||||
**johnellisATlinuxDOTcom**
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
- A description of the vulnerability and its potential impact
|
||||
- Steps to reproduce, or a proof-of-concept
|
||||
- The version/commit of ihasmail affected
|
||||
- The version of Stalwart Mail Server you were testing against, if relevant
|
||||
- Whether the issue is in ihasmail itself, in how it talks to Stalwart over JMAP, or in a dependency
|
||||
|
||||
### What to Expect
|
||||
|
||||
- **Acknowledgment:** You should receive a response within a few days confirming the report was received.
|
||||
- **Assessment:** The issue will be triaged and its severity assessed. Because ihasmail holds no data of its own and relies entirely on Stalwart's store over JMAP, some reports may need to be routed to or coordinated with the [Stalwart Mail Server](https://github.com/stalwartlabs/mail-server) project if the root cause lives there rather than in ihasmail's client code.
|
||||
- **Fix & disclosure:** Once a fix is ready, a new release will be published. We'll coordinate with you on public disclosure timing and credit, if you'd like to be credited.
|
||||
|
||||
### Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Authentication and session handling in ihasmail
|
||||
- Cross-site scripting (XSS), CSRF, or injection issues in the webmail UI
|
||||
- Improper handling of JMAP responses that could lead to data leakage between accounts
|
||||
- Dependency vulnerabilities that are actually exploitable in ihasmail's usage
|
||||
|
||||
Out of scope (please report upstream instead):
|
||||
|
||||
- Vulnerabilities in Stalwart Mail Server itself — report those to the [Stalwart project](https://github.com/stalwartlabs/mail-server)
|
||||
- Vulnerabilities in third-party libraries with no demonstrated impact on ihasmail
|
||||
- Issues requiring physical access to a user's device or an already-compromised Stalwart instance
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow coordinated disclosure: please give us a reasonable window to investigate and release a fix before any public disclosure. In turn, we'll keep you updated on progress and won't leave you waiting indefinitely.
|
||||
|
||||
Thank you for helping keep ihasmail and its users safe.
|
||||
@@ -1,9 +0,0 @@
|
||||
import os, secrets
|
||||
|
||||
APP_SECRET = os.getenv("APP_SECRET") or secrets.token_urlsafe(32)
|
||||
COOKIE_NAME = os.getenv("COOKIE_NAME", "stalwart_webmail")
|
||||
JMAP_BASE = os.getenv("JMAP_BASE", "https://mail.example.com/jmap")
|
||||
CALDAV_BASE = os.getenv("CALDAV_BASE", "https://mail.example.com/caldav/")
|
||||
WEBDAV_BASE = os.getenv("WEBDAV_BASE", "https://mail.example.com/webdav/")
|
||||
TRUST_PROXY = os.getenv("TRUST_PROXY", "1") == "1"
|
||||
UPSTREAM_TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT", "15"))
|
||||
@@ -1,41 +0,0 @@
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import httpx
|
||||
from urllib.parse import urljoin
|
||||
from . import config
|
||||
|
||||
DAV_PROPFIND = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
async def propfind(ac: httpx.AsyncClient, base: str, path: Optional[str], auth: Tuple[str,str]) -> List[Dict[str, Any]]:
|
||||
href = urljoin(base, path or "/")
|
||||
r = await ac.request("PROPFIND", href, content=DAV_PROPFIND, headers={"Depth": "1"}, auth=auth)
|
||||
if r.status_code not in (207, 200):
|
||||
raise RuntimeError(f"WebDAV error {r.status_code}")
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.fromstring(r.text)
|
||||
ns = {"d":"DAV:"}
|
||||
items: List[Dict[str, Any]] = []
|
||||
for resp in tree.findall("d:response", ns):
|
||||
href_el = resp.find("d:href", ns)
|
||||
prop = resp.find("d:propstat/d:prop", ns)
|
||||
if href_el is None or prop is None:
|
||||
continue
|
||||
name = prop.find("d:displayname", ns)
|
||||
cl = prop.find("d:getcontentlength", ns)
|
||||
rtype = prop.find("d:resourcetype", ns)
|
||||
is_collection = rtype is not None and rtype.find("d:collection", ns) is not None
|
||||
items.append({
|
||||
"href": href_el.text,
|
||||
"name": (name.text if name is not None and name.text else href_el.text.rstrip("/").split("/")[-1] or "/"),
|
||||
"type": "directory" if is_collection else "file",
|
||||
"size": int(cl.text) if (cl is not None and cl.text and cl.text.isdigit()) else None
|
||||
})
|
||||
if items:
|
||||
items = items[1:]
|
||||
return items
|
||||
@@ -1,31 +0,0 @@
|
||||
from typing import Any, Dict, List, Tuple
|
||||
import httpx
|
||||
from . import config
|
||||
|
||||
def client() -> httpx.AsyncClient:
|
||||
limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
|
||||
return httpx.AsyncClient(timeout=config.UPSTREAM_TIMEOUT, limits=limits, trust_env=True)
|
||||
|
||||
async def get_session(ac: httpx.AsyncClient, base: str, username: str, password: str) -> Dict[str, Any]:
|
||||
r = await ac.get(base, auth=(username, password))
|
||||
if r.status_code == 401:
|
||||
raise PermissionError("Invalid credentials")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def call(ac: httpx.AsyncClient, api_url: str, auth: Tuple[str,str] | None, method_calls: List[list]) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"using": [
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:contacts",
|
||||
"urn:ietf:params:jmap:calendars"
|
||||
],
|
||||
"methodCalls": method_calls
|
||||
}
|
||||
kwargs: Dict[str, Any] = {"json": payload}
|
||||
if auth:
|
||||
kwargs["auth"] = auth
|
||||
r = await ac.post(api_url, **kwargs)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
@@ -1,34 +0,0 @@
|
||||
import bleach
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
from . import config
|
||||
from .routes import auth, mail, contacts, calendar, webdav, sieve
|
||||
|
||||
app = FastAPI(title="Stalwart Webmail (Python)")
|
||||
|
||||
if config.TRUST_PROXY:
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
||||
app.add_middleware(SessionMiddleware, secret_key=config.APP_SECRET, session_cookie=config.COOKIE_NAME, same_site="lax", https_only=True)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root(request: Request):
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/mail" if request.session.get("user") else "/login")
|
||||
|
||||
# Routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(mail.router)
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(calendar.router)
|
||||
app.include_router(webdav.router)
|
||||
app.include_router(sieve.router)
|
||||
|
||||
|
||||
@app.get("/healthz", include_in_schema=False)
|
||||
async def healthz():
|
||||
return {"ok": True}
|
||||
@@ -1,45 +0,0 @@
|
||||
from fastapi import APIRouter, Request, Form, HTTPException
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import PlainTextResponse
|
||||
from .. import config, jmap
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from jinja2 import FileSystemLoader, Environment, select_autoescape
|
||||
import pathlib, base64, os
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
def make_csrf(session: dict) -> str:
|
||||
token = base64.urlsafe_b64encode(os.urandom(24)).decode()
|
||||
session["csrf"] = token
|
||||
return token
|
||||
|
||||
def check_csrf(session: dict, token: str):
|
||||
if not token or token != session.get("csrf"):
|
||||
raise HTTPException(status_code=400, detail="CSRF token invalid")
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_form(request: Request):
|
||||
csrf = make_csrf(request.session)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "csrf": csrf, "jmap_base": config.JMAP_BASE})
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...), jmap_base: str = Form(...), csrf: str = Form(...)):
|
||||
check_csrf(request.session, csrf)
|
||||
async with jmap.client() as ac:
|
||||
try:
|
||||
session = await jmap.get_session(ac, jmap_base, username, password)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
api_url = session.get("apiUrl") or jmap_base
|
||||
download_url = session.get("downloadUrl") or ""
|
||||
primary = session.get("primaryAccounts") or {}
|
||||
request.session["user"] = {"username": username, "jmap_base": jmap_base, "api_url": api_url, "auth": (username, password), "download_url": download_url, "primary": primary, "session": session}
|
||||
return RedirectResponse("/mail", status_code=303)
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
@@ -1,39 +0,0 @@
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import pathlib, datetime
|
||||
from .. import jmap
|
||||
from ..utils import fmt_when
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
def require_user(request: Request):
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
raise HTTPException(status_code=401)
|
||||
return user
|
||||
|
||||
@router.get("/calendar", response_class=HTMLResponse)
|
||||
async def calendar(request: Request, user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
account_id = None
|
||||
now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
|
||||
until = now + datetime.timedelta(days=30)
|
||||
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
||||
["CalendarEvent/query", {"accountId": account_id, "limit": 200, "sort":[{"property":"start","isAscending": True}]}, "q1"],
|
||||
["CalendarEvent/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"CalendarEvent/query","path":"ids"}, "properties":["id","title","start","end","location"]}, "g1"]
|
||||
])
|
||||
events = []
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "CalendarEvent/get":
|
||||
for e in data.get("list", []):
|
||||
try:
|
||||
s = datetime.datetime.fromisoformat((e.get("start") or "").replace("Z","+00:00"))
|
||||
if s < now - datetime.timedelta(days=1) or s > until:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
events.append({"title": e.get("title") or "(no title)", "start": fmt_when(e.get("start")), "end": fmt_when(e.get("end")), "loc": e.get("location")})
|
||||
return templates.TemplateResponse("calendar.html", {"request": request, "events": events, "user": user})
|
||||
@@ -1,35 +0,0 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import pathlib
|
||||
from .. import jmap
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
def require_user(request: Request):
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
raise HTTPException(status_code=401)
|
||||
return user
|
||||
|
||||
@router.get("/contacts", response_class=HTMLResponse)
|
||||
async def contacts(request: Request, q: Optional[str] = None, user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
account_id = None
|
||||
filter_cond = {"text": q} if q else {}
|
||||
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
||||
["Contact/query", {"accountId": account_id, "filter": filter_cond, "limit": 100}, "c1"],
|
||||
["Contact/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Contact/query","path":"ids"}, "properties":["id","firstName","lastName","emails","company"]}, "c2"]
|
||||
])
|
||||
contacts = []
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "Contact/get":
|
||||
for c in data.get("list", []):
|
||||
emails = [e.get("email","") for e in (c.get("emails") or [])]
|
||||
contacts.append({"name": f"{c.get('firstName','')} {c.get('lastName','')}".strip() or (emails[0] if emails else ""),
|
||||
"email": ", ".join(emails),
|
||||
"org": c.get("company")})
|
||||
return templates.TemplateResponse("contacts.html", {"request": request, "contacts": contacts, "q": q, "user": user})
|
||||
@@ -1,316 +0,0 @@
|
||||
import json
|
||||
import io
|
||||
import bleach
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Form, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import pathlib
|
||||
from .. import jmap
|
||||
from ..utils import human_size, fmt_when
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
def require_user(request: Request):
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
raise HTTPException(status_code=401)
|
||||
return user
|
||||
|
||||
def make_csrf(session: dict) -> str:
|
||||
import os, base64
|
||||
token = base64.urlsafe_b64encode(os.urandom(24)).decode()
|
||||
session["csrf"] = token
|
||||
return token
|
||||
|
||||
def check_csrf(session: dict, token: str):
|
||||
if not token or token != session.get("csrf"):
|
||||
raise HTTPException(status_code=400, detail="CSRF token invalid")
|
||||
|
||||
@router.get("/mail", response_class=HTMLResponse)
|
||||
async def inbox(request: Request, q: Optional[str] = None, mailbox: Optional[str] = None, user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
primary = user.get("primary", {})
|
||||
account_id = primary.get("urn:ietf:params:jmap:mail")
|
||||
boxes, inbox_id = await get_mailboxes(ac, api, tuple(user["auth"]), account_id)
|
||||
box_id = mailbox or inbox_id
|
||||
filt = {"text": q} if q else ({"inMailbox": box_id} if box_id else {})
|
||||
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
||||
["Email/query", {"accountId": account_id, "filter": filt, "sort": [{"property":"receivedAt","isAscending": False}], "limit": 50}, "c1"],
|
||||
["Email/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Email/query","path":"ids"}, "properties": ["id","subject","from","size","receivedAt"]}, "c2"]
|
||||
])
|
||||
emails = []
|
||||
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "Email/get":
|
||||
for e in data.get("list", []):
|
||||
from_str = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])])
|
||||
emails.append({"id": e["id"], "subject": e.get("subject") or "(no subject)", "from": from_str, "when": fmt_when(e.get("receivedAt")), "size": human_size(e.get("size"))})
|
||||
return templates.TemplateResponse("mail.html", {"request": request, "messages": emails, "q": q, "user": user, "mailboxes": boxes, "selected": box_id})
|
||||
|
||||
@router.get("/mail/{email_id}", response_class=HTMLResponse)
|
||||
async def read_message(request: Request, email_id: str, user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
||||
["Email/get", {"ids": [email_id], "properties": ["id","subject","from","to","receivedAt","size","keywords","preview","bodyStructure","htmlBody","textBody"]}, "c1"]
|
||||
])
|
||||
msg = {"id": email_id, "subject":"", "from":"", "to":[], "when":"", "textBody":"", "htmlBody":"", "attachments":[]}
|
||||
bstruct = None
|
||||
cid_map = {}
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "Email/get":
|
||||
lst = data.get("list", [])
|
||||
if lst:
|
||||
e = lst[0]
|
||||
msg["subject"] = e.get("subject") or msg["subject"]
|
||||
msg["from"] = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])]) or msg["from"]
|
||||
msg["to"] = [a.get("email","") for a in (e.get("to") or [])] or msg["to"]
|
||||
msg["when"] = fmt_when(e.get("receivedAt")) or msg["when"]
|
||||
if "textBody" in e:
|
||||
msg["textBody"] = e.get("textBody") or msg["textBody"]
|
||||
if "htmlBody" in e:
|
||||
raw_html = e.get("htmlBody")
|
||||
if raw_html:
|
||||
msg["htmlBody"] = bleach.clean(raw_html, tags=bleach.sanitizer.ALLOWED_TAGS.union({"p","span","div","br","hr","pre","code","blockquote","ul","ol","li","table","thead","tbody","tr","th","td","img","a","b","i","strong","em"}), attributes={"a":["href","title"],"img":["src","alt","title","width","height"]}, strip=True)
|
||||
bstruct = bstruct or e.get("bodyStructure")
|
||||
def walk_cid(bs):
|
||||
if not isinstance(bs, dict): return
|
||||
cid = bs.get("cid")
|
||||
if cid and bs.get("blobId"):
|
||||
cid_map[cid.strip("<>")] = {"blobId": bs["blobId"], "name": bs.get("name") or "inline"}
|
||||
for p in bs.get("subParts", []) or []:
|
||||
walk_cid(p)
|
||||
if bstruct:
|
||||
walk_cid(bstruct)
|
||||
|
||||
def walk_bs(bs, out):
|
||||
if not isinstance(bs, dict): return
|
||||
if bs.get("disposition") == "attachment":
|
||||
out.append({"name": bs.get("name") or "attachment", "type": bs.get("type") or "application/octet-stream", "size": bs.get("size"), "blobId": bs.get("blobId")})
|
||||
for p in bs.get("subParts", []) or []:
|
||||
walk_bs(p, out)
|
||||
att = []
|
||||
walk_bs(bstruct, att)
|
||||
msg["attachments"] = att
|
||||
# Inline CID images via internal route
|
||||
if msg.get("htmlBody") and cid_map:
|
||||
import re as _re
|
||||
def _repl(m):
|
||||
cid = m.group(1)
|
||||
return f'src="/mail/{email_id}/cid/{cid}"'
|
||||
msg["htmlBody"] = _re.sub(r'src=\"cid:([^\"]+)\"', _repl, msg["htmlBody"]) # cid_rewrite
|
||||
return templates.TemplateResponse("message.html", {"request": request, "msg": msg, "user": user})
|
||||
|
||||
@router.get("/compose", response_class=HTMLResponse)
|
||||
async def compose_form(request: Request, user=Depends(require_user)):
|
||||
csrf = make_csrf(request.session)
|
||||
return templates.TemplateResponse("compose.html", {"request": request, "csrf": csrf, "user": user})
|
||||
|
||||
@router.post("/compose")
|
||||
async def compose_send(request: Request, to: str = Form(...), subject: str = Form(""), body: str = Form(""), csrf: str = Form(...), action: str = Form("send"), files: list[UploadFile] = File(default=[]), user=Depends(require_user)):
|
||||
check_csrf(request.session, csrf)
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
primary = user.get("primary", {})
|
||||
account_id = primary.get("urn:ietf:params:jmap:mail")
|
||||
# Upload attachments if any
|
||||
upload_url = user.get("upload_url")
|
||||
blobs = []
|
||||
form = await request.form()
|
||||
for k, v in form.multi_items():
|
||||
if k == 'preblob':
|
||||
try:
|
||||
b = json.loads(v)
|
||||
if b.get('blobId'): blobs.append(b)
|
||||
except Exception:
|
||||
pass
|
||||
if files:
|
||||
for f in files:
|
||||
data = await f.read()
|
||||
if upload_url:
|
||||
url = upload_url.replace("{accountId}", account_id or "")
|
||||
ru = await ac.post(url, content=data, headers={"Content-Type": f.content_type or "application/octet-stream"}, auth=tuple(user["auth"]))
|
||||
ru.raise_for_status()
|
||||
up = ru.json()
|
||||
blobs.append({"blobId": up.get("blobId"), "type": f.content_type or "application/octet-stream", "name": f.filename, "size": len(data)})
|
||||
email_creation_id = "k1"
|
||||
submission_creation_id = "k2"
|
||||
create_email = {
|
||||
"accountId": account_id,
|
||||
"create": {
|
||||
email_creation_id: {
|
||||
"mailboxIds": {},
|
||||
"from": [{"email": user["username"]}],
|
||||
"to": [{"email": x.strip()} for x in to.split(",") if x.strip()],
|
||||
"subject": subject,
|
||||
"textBody": body,
|
||||
"attachments": [{"blobId": b["blobId"], "type": b["type"], "name": b["name"]} for b in blobs]
|
||||
}
|
||||
}
|
||||
}
|
||||
# Move to Drafts if requested, else submit and move to Sent
|
||||
special = await get_special_mailboxes(ac, api, tuple(user["auth"]), account_id)
|
||||
sent_id = special.get("sent")
|
||||
drafts_id = special.get("drafts")
|
||||
calls = []
|
||||
calls.append(["Email/set", create_email, "s1"])
|
||||
if action == "draft":
|
||||
if drafts_id:
|
||||
calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {drafts_id: True}}}}, "sdraft"])
|
||||
else:
|
||||
calls.append(["EmailSubmission/set", {"accountId": account_id, "create": {submission_creation_id: {"emailId": {"resultOf":"s1","name":"Email/set","path": f"created/{email_creation_id}/id"}}}}, "s2"])
|
||||
if sent_id:
|
||||
calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {sent_id: True}}}}, "ssent"])
|
||||
await jmap.call(ac, api, tuple(user["auth"]), calls)
|
||||
return RedirectResponse("/mail", status_code=303)
|
||||
|
||||
async def get_mailboxes(ac, api, auth, account_id):
|
||||
res = await jmap.call(ac, api, auth, [
|
||||
["Mailbox/query", {"accountId": account_id, "sort":[{"property":"sortOrder","isAscending": True},{"property":"name","isAscending": True}], "limit": 200}, "q1"],
|
||||
["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","name","role","totalEmails","unreadEmails"]}, "g1"]
|
||||
])
|
||||
boxes = []
|
||||
inbox_id = None
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "Mailbox/get":
|
||||
for b in data.get("list", []):
|
||||
boxes.append({"id": b["id"], "name": b.get("name",""), "role": b.get("role"), "total": b.get("totalEmails",0), "unread": b.get("unreadEmails",0)})
|
||||
if b.get("role") == "inbox":
|
||||
inbox_id = b["id"]
|
||||
return boxes, inbox_id or (boxes[0]["id"] if boxes else None)
|
||||
|
||||
@router.get("/mail/{email_id}/attach/{index}")
|
||||
async def download_attachment(request: Request, email_id: str, index: int, user=Depends(require_user)):
|
||||
atts = request.query_params.get("atts")
|
||||
# Re-fetch message to resolve bodyStructure (simple approach; could cache)
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
||||
["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"]
|
||||
])
|
||||
bstruct = None
|
||||
cid_map = {}
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "Email/get":
|
||||
lst = data.get("list", [])
|
||||
if lst:
|
||||
bstruct = lst[0].get("bodyStructure")
|
||||
parts = []
|
||||
def walk(bs, out):
|
||||
if not isinstance(bs, dict): return
|
||||
if bs.get("disposition") == "attachment":
|
||||
out.append(bs)
|
||||
for p in bs.get("subParts", []) or []:
|
||||
walk(p, out)
|
||||
walk(bstruct, parts)
|
||||
if index < 0 or index >= len(parts):
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
p = parts[index]
|
||||
blob = p.get("blobId")
|
||||
name = p.get("name") or "attachment"
|
||||
ctype = p.get("type") or "application/octet-stream"
|
||||
|
||||
# Build download URL from session template
|
||||
tmpl = user.get("download_url") or ""
|
||||
primary = user.get("primary", {})
|
||||
account_id = primary.get("urn:ietf:params:jmap:mail")
|
||||
url = tmpl
|
||||
if "{accountId}" in url:
|
||||
url = url.replace("{accountId}", account_id or "")
|
||||
if "{blobId}" in url:
|
||||
url = url.replace("{blobId}", blob or "")
|
||||
if "{name}" in url:
|
||||
from urllib.parse import quote
|
||||
url = url.replace("{name}", quote(name))
|
||||
# Fallback naive pattern if template missing
|
||||
if not url or "{" in url:
|
||||
from urllib.parse import urljoin, quote
|
||||
base = user.get("jmap_base")
|
||||
url = urljoin(base, f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}")
|
||||
|
||||
async with jmap.client() as ac:
|
||||
r = await ac.get(url, auth=tuple(user["auth"]))
|
||||
r.raise_for_status()
|
||||
return StreamingResponse(io.BytesIO(r.content), media_type=ctype, headers={"Content-Disposition": f'attachment; filename="{name}"'})
|
||||
|
||||
|
||||
async def get_special_mailboxes(ac, api, auth, account_id):
|
||||
res = await jmap.call(ac, api, auth, [
|
||||
["Mailbox/query", {"accountId": account_id, "limit": 200}, "q1"],
|
||||
["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","role","name"]}, "g1"]
|
||||
])
|
||||
sent_id = drafts_id = inbox_id = None
|
||||
boxes = {}
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "Mailbox/get":
|
||||
for b in data.get("list", []):
|
||||
boxes[b["id"]] = b
|
||||
role = b.get("role")
|
||||
if role == "sent": sent_id = b["id"]
|
||||
if role == "drafts": drafts_id = b["id"]
|
||||
if role == "inbox": inbox_id = b["id"]
|
||||
return {"sent": sent_id, "drafts": drafts_id, "inbox": inbox_id, "all": boxes}
|
||||
|
||||
|
||||
@router.get("/mail/{email_id}/cid/{cid}")
|
||||
async def fetch_cid(request: Request, email_id: str, cid: str, user=Depends(require_user)):
|
||||
# Walk bodyStructure to find matching cid, then download via downloadUrl
|
||||
async with jmap.client() as ac:
|
||||
api = user["api_url"]
|
||||
res = await jmap.call(ac, api, tuple(user["auth"]), [
|
||||
["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"]
|
||||
])
|
||||
bstruct = None
|
||||
for name, data, _ in res.get("methodResponses", []):
|
||||
if name == "Email/get":
|
||||
lst = data.get("list", [])
|
||||
if lst:
|
||||
bstruct = lst[0].get("bodyStructure")
|
||||
target = None
|
||||
def walk(bs):
|
||||
nonlocal target
|
||||
if not isinstance(bs, dict) or target is not None: return
|
||||
if bs.get("cid") and bs.get("cid").strip("<>") == cid:
|
||||
target = bs
|
||||
return
|
||||
for p in bs.get("subParts", []) or []:
|
||||
walk(p)
|
||||
walk(bstruct)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Inline part not found")
|
||||
blob = target.get("blobId")
|
||||
ctype = target.get("type") or "application/octet-stream"
|
||||
name = target.get("name") or "inline"
|
||||
tmpl = user.get("download_url") or ""
|
||||
primary = user.get("primary", {})
|
||||
account_id = primary.get("urn:ietf:params:jmap:mail")
|
||||
from urllib.parse import quote, urljoin
|
||||
if tmpl and "{accountId}" in tmpl and "{blobId}" in tmpl:
|
||||
url = tmpl.replace("{accountId}", account_id or "").replace("{blobId}", blob or "")
|
||||
if "{name}" in url:
|
||||
url = url.replace("{name}", quote(name))
|
||||
else:
|
||||
url = urljoin(user.get("jmap_base"), f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}")
|
||||
async with jmap.client() as ac:
|
||||
r = await ac.get(url, auth=tuple(user["auth"]))
|
||||
r.raise_for_status()
|
||||
return StreamingResponse(io.BytesIO(r.content), media_type=ctype)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(request: Request, file: UploadFile = File(...), user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
primary = user.get("primary", {})
|
||||
account_id = user.get("active_account") or primary.get("urn:ietf:params:jmap:mail")
|
||||
upload_url = user.get("upload_url")
|
||||
if not upload_url or not account_id:
|
||||
raise HTTPException(status_code=400, detail="Upload not available")
|
||||
url = upload_url.replace("{accountId}", account_id)
|
||||
data = await file.read()
|
||||
r = await ac.post(url, content=data, headers={"Content-Type": file.content_type or "application/octet-stream"}, auth=tuple(user["auth"]))
|
||||
r.raise_for_status()
|
||||
up = r.json()
|
||||
return JSONResponse({"blobId": up.get("blobId"), "type": file.content_type or "application/octet-stream", "name": file.filename, "size": len(data)})
|
||||
@@ -1,21 +0,0 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import pathlib
|
||||
from .. import dav, jmap, config
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
def require_user(request: Request):
|
||||
user = request.session.get("user")
|
||||
if not user:
|
||||
raise HTTPException(status_code=401)
|
||||
return user
|
||||
|
||||
@router.get("/webdav", response_class=HTMLResponse)
|
||||
async def webdav_browse(request: Request, path: Optional[str]=None, user=Depends(require_user)):
|
||||
async with jmap.client() as ac:
|
||||
items = await dav.propfind(ac, config.WEBDAV_BASE, path, tuple(user["auth"]))
|
||||
return templates.TemplateResponse("webdav.html", {"request": request, "items": items, "base": config.WEBDAV_BASE, "user": user})
|
||||
@@ -1,30 +0,0 @@
|
||||
:root { color-scheme: light dark; --header-bg: #f6f7f9; --header-fg: #111; --card-bg: #fff; }
|
||||
@media (prefers-color-scheme: dark) { :root { --header-bg: #0f172a; --header-fg: #e5e7eb; --card-bg: #0b1222; } }
|
||||
body { margin:0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
header, footer { padding: 10px 14px; border-bottom: 1px solid #4443; background: var(--header-bg); color: var(--header-fg); }
|
||||
main { padding: 14px; max-width: 1100px; margin: 0 auto; }
|
||||
nav a { margin-right: 12px; }
|
||||
.btn { display:inline-block; padding:6px 10px; border:1px solid #6665; border-radius:8px; text-decoration:none; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { padding: 8px; border-bottom: 1px solid #6662; text-align: left; vertical-align: top; }
|
||||
.muted { color: #888; }
|
||||
input, textarea, select { padding:6px 8px; width:100%; box-sizing: border-box; }
|
||||
form .row { display:grid; grid-template-columns: 160px 1fr; gap: 8px; align-items: center; margin-bottom:10px; }
|
||||
.msg { cursor:pointer; }
|
||||
.pill { display:inline-block; font-size:12px; padding:2px 6px; border:1px solid #6663; border-radius:999px; margin-right:6px;}
|
||||
.nowrap { white-space: nowrap; }
|
||||
.right { text-align:right; }
|
||||
.toolbar { display:flex; gap:8px; align-items:center; margin:8px 0; }
|
||||
.panel { border:1px solid #6663;padding:10px;border-radius:8px;margin:10px 0;white-space:pre-wrap }
|
||||
|
||||
#dropzone{padding:16px;border:2px dashed #6665;border-radius:8px;text-align:center;margin:10px 0}
|
||||
|
||||
.brand { display:flex; align-items:center; gap:10px; }
|
||||
.brand .logo { height:28px; vertical-align:middle; }
|
||||
.brand-link { text-decoration:none; color:inherit; }
|
||||
header nav { margin-top:6px; }
|
||||
.badge { display:inline-block; padding:0 6px; border-radius:10px; font-size:12px; background:#6662; margin-left:6px; }
|
||||
|
||||
.card{background:var(--card-bg); border:1px solid #6663; border-radius:12px; padding:18px; box-shadow:0 2px 6px #0001;}
|
||||
.center{display:grid; place-items:center; min-height:60vh;}
|
||||
.logo-lg{height:64px;}
|
||||
|
Before Width: | Height: | Size: 186 KiB |
@@ -1,37 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title or "ihasmail" }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://unpkg.com;">
|
||||
<link rel="icon" href="/static/img/logo.png">
|
||||
<link rel="preconnect" href="https://unpkg.com">
|
||||
<script defer src="https://unpkg.com/[email protected]"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<a href="/" class="brand-link"><img src="/static/img/logo.png" alt="ihasmail" class="logo"> <strong>ihasmail</strong></a>
|
||||
</div>
|
||||
<nav>
|
||||
{% if user %}
|
||||
<span class="muted">Signed in as {{ user.get("username") }}</span>
|
||||
<a class="btn" href="/mail">Inbox</a>
|
||||
<a class="btn" href="/compose">Compose</a>
|
||||
<a class="btn" href="/calendar">Calendar</a>
|
||||
<a class="btn" href="/contacts">Contacts</a>
|
||||
<a class="btn" href="/webdav">WebDAV</a>
|
||||
<a class="btn" href="/logout">Logout</a>
|
||||
{% else %}
|
||||
<a class="btn" href="/login">Login</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="muted">ihasmail • JMAP • Sieve • DAV • FastAPI • reverse-proxy ready</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Calendar (JMAP & CalDAV)</h1>
|
||||
<p class="muted">Listing upcoming events via JMAP. CalDAV endpoints available for DAV clients.</p>
|
||||
<table>
|
||||
<tr><th>When</th><th>Summary</th><th>Where</th></tr>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ e.start }} – {{ e.end }}</td>
|
||||
<td>{{ e.title }}</td>
|
||||
<td>{{ e.loc or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Compose</h1>
|
||||
<form method="post" action="/compose">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="row"><label>To</label><input name="to" required></div>
|
||||
<div class="row"><label>Subject</label><input name="subject"></div>
|
||||
<div class="row"><label>Body</label><textarea name="body" rows="14"></textarea></div>
|
||||
<button class="btn">Send</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Contacts (Directory via JMAP)</h1>
|
||||
<div class="toolbar">
|
||||
<form>
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search name/email…">
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Email</th><th>Org</th></tr>
|
||||
{% for c in contacts %}
|
||||
<tr>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.email }}</td>
|
||||
<td>{{ c.org or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,24 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="center"><div class="card" style="min-width:320px; max-width:420px;">
|
||||
<div style="text-align:center;margin-bottom:8px"><img class="logo-lg" src="/static/img/logo.png" alt="ihasmail"></div>
|
||||
<h2 style="text-align:center;margin-top:0">Sign in</h2>
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="row">
|
||||
<label>Username</label>
|
||||
<input name="username" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>JMAP Base</label>
|
||||
<input name="jmap_base" value="{{ jmap_base }}">
|
||||
</div>
|
||||
<button class="btn" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<p class="muted">Credentials are sent to your JMAP server to obtain a session/auth token; they are not stored on the server.</p>
|
||||
{% endblock %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Inbox</h1>
|
||||
<div class="toolbar">
|
||||
<form method="get" action="/mail">
|
||||
<select name="mailbox" onchange="this.form.submit()">
|
||||
{% for b in mailboxes %}
|
||||
<option value="{{ b.id }}" {% if b.id == selected %}selected{% endif %}>{{ b.name }}{% if b.unread %} ({{ b.unread }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input name="q" placeholder="Search (from, subject, text…)" value="{{ q or '' }}">
|
||||
</form>
|
||||
<a class="btn" href="/compose">Compose</a>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th class="nowrap">When</th><th>From</th><th>Subject</th><th class="right">Size</th></tr>
|
||||
{% for m in messages %}
|
||||
<tr class="msg" onclick="location.href='/mail/{{ m.id }}'">
|
||||
<td class="nowrap">{{ m.when }}</td>
|
||||
<td>{{ m.from }}</td>
|
||||
<td>{{ m.subject }}</td>
|
||||
<td class="right">{{ m.size }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,25 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ msg.subject or "(no subject)" }}</h1>
|
||||
<p><span class="pill">From</span> {{ msg.from }} <span class="pill">To</span> {{ msg.to|join(", ") }}</p>
|
||||
<p class="muted">{{ msg.when }}</p>
|
||||
{% if msg.htmlBody %}
|
||||
<div class="panel">{{ (msg.htmlBody | safe) }}</div>
|
||||
{% elif msg.textBody %}
|
||||
<div class="panel">{{ msg.textBody }}</div>
|
||||
{% else %}
|
||||
<div class="panel muted">(no body)</div>
|
||||
{% endif %}
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="/compose?reply={{ msg.id }}">Reply</a>
|
||||
<a class="btn" href="/compose?forward={{ msg.id }}">Forward</a>
|
||||
</div>
|
||||
{% if msg.attachments %}
|
||||
<h3>Attachments</h3>
|
||||
<ul>
|
||||
{% for a in msg.attachments %}
|
||||
<li>{{ a.name }} ({{ a.type }}, {{ a.size }} bytes)</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>WebDAV</h1>
|
||||
<p class="muted">Browsing {{ base }}</p>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Type</th><th class="right">Size</th></tr>
|
||||
{% for i in items %}
|
||||
<tr>
|
||||
<td>{{ i.name }}</td>
|
||||
<td>{{ i.type }}</td>
|
||||
<td class="right">{% if i.size is not none %}{{ i.size }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
import datetime
|
||||
|
||||
def human_size(n: int | None) -> str:
|
||||
if n is None: return ""
|
||||
units = ["B","KB","MB","GB","TB","PB"]
|
||||
i = 0
|
||||
x = float(n)
|
||||
while x >= 1024 and i < len(units)-1:
|
||||
x /= 1024.0
|
||||
i += 1
|
||||
return f"{x:.0f} {units[i]}"
|
||||
|
||||
def fmt_when(iso: str | None) -> str:
|
||||
if not iso: return ""
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(iso.replace("Z","+00:00")).astimezone()
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
return iso or ""
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: ihasmail
|
||||
description: ihasmail — JMAP webmail for Stalwart (FastAPI)
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.2.0"
|
||||
@@ -1,7 +0,0 @@
|
||||
Thanks for installing ihasmail!
|
||||
|
||||
Get the service URL by running these commands:
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "ihasmail.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}/
|
||||
|
||||
If using Ingress and DNS, browse to the configured host (e.g., https://ihasmail.example.com).
|
||||
@@ -1,20 +0,0 @@
|
||||
{{- define "ihasmail.name" -}}
|
||||
{{- .Chart.Name -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "ihasmail.fullname" -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "ihasmail.labels" -}}
|
||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||
app.kubernetes.io/name: {{ include "ihasmail.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "ihasmail.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "ihasmail.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
@@ -1,60 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "ihasmail.fullname" . }}
|
||||
labels:
|
||||
{{- include "ihasmail.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "ihasmail.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "ihasmail.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: APP_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "ihasmail.fullname" . }}-secret
|
||||
key: APP_SECRET
|
||||
- name: JMAP_BASE
|
||||
value: {{ .Values.env.JMAP_BASE | quote }}
|
||||
- name: CALDAV_BASE
|
||||
value: {{ .Values.env.CALDAV_BASE | quote }}
|
||||
- name: WEBDAV_BASE
|
||||
value: {{ .Values.env.WEBDAV_BASE | quote }}
|
||||
- name: COOKIE_NAME
|
||||
value: {{ .Values.env.COOKIE_NAME | quote }}
|
||||
- name: TRUST_PROXY
|
||||
value: {{ .Values.env.TRUST_PROXY | quote }}
|
||||
- name: UPSTREAM_TIMEOUT
|
||||
value: {{ .Values.env.UPSTREAM_TIMEOUT | quote }}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "ihasmail.fullname" . }}-secret
|
||||
type: Opaque
|
||||
stringData:
|
||||
APP_SECRET: {{ .Values.env.APP_SECRET | quote }}
|
||||
@@ -1,30 +0,0 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "ihasmail.fullname" . }}
|
||||
{{- if .Values.ingress.className }}
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
spec:
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "ihasmail.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "ihasmail.fullname" . }}
|
||||
labels:
|
||||
{{- include "ihasmail.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: 8000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "ihasmail.selectorLabels" . | nindent 4 }}
|
||||
@@ -1,32 +0,0 @@
|
||||
image:
|
||||
repository: ghcr.io/your-org/ihasmail
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8000
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
hosts:
|
||||
- host: ihasmail.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
|
||||
env:
|
||||
APP_SECRET: "CHANGE_ME"
|
||||
JMAP_BASE: "https://mail.example.com/jmap"
|
||||
CALDAV_BASE: "https://mail.example.com/caldav/"
|
||||
WEBDAV_BASE: "https://mail.example.com/webdav/"
|
||||
COOKIE_NAME: "ihasmail"
|
||||
TRUST_PROXY: "1"
|
||||
UPSTREAM_TIMEOUT: "15"
|
||||
|
||||
resources: {}
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/bash
|
||||
# Redeploy ihasmail on a single-host Docker setup, from a git checkout.
|
||||
#
|
||||
# Copy it, or run it as-is and set the variables below in the environment.
|
||||
# Nothing here is specific to any one host: the defaults describe the shape of
|
||||
# a deployment rather than anyone's particular one.
|
||||
#
|
||||
# Usage: ./deploy.sh [git-ref] [-y|--yes] [-n|--dry-run]
|
||||
#
|
||||
# Three guards stand between a careless run and production:
|
||||
#
|
||||
# .deploy-hold commits that must not reach prod yet, one per line. If the
|
||||
# target contains one that is not already deployed, the deploy
|
||||
# is refused outright -- `--yes` does not override it. Clearing
|
||||
# a hold means deleting its line, which is a deliberate edit.
|
||||
#
|
||||
# confirmation anything introducing new commits is listed first and has to
|
||||
# be confirmed. Over SSH, where there is no terminal to answer
|
||||
# on, that means passing --yes: a bare `deploy.sh` cannot ship
|
||||
# whatever main happens to have picked up since the last
|
||||
# release.
|
||||
#
|
||||
# --dry-run checks the hold list, says what it would deploy, and stops
|
||||
# before building or touching the container. It does not ask
|
||||
# for confirmation: there is nothing to agree to when nothing
|
||||
# changes, and needing a terminal would make it useless over
|
||||
# SSH -- which is where wanting to look before leaping is most
|
||||
# likely.
|
||||
#
|
||||
# The container is replaced rather than restarted, because the image is rebuilt
|
||||
# from the new checkout. Data lives in a named volume and survives that; the
|
||||
# environment file is never read here, only handed to Docker.
|
||||
set -euo pipefail
|
||||
|
||||
# --- what to deploy, and where ----------------------------------------------
|
||||
# The checkout to deploy from. It must be a git clone: the version number is
|
||||
# read from its history (see scripts/version.mjs).
|
||||
APP="${IHASMAIL_APP:-$HOME/apps/ihasmail}"
|
||||
# Environment file passed to the container. Keep it outside the repo's tracked
|
||||
# files -- it holds APP_SECRET and the upstream URL. Never read by this script.
|
||||
ENVF="${IHASMAIL_ENV:-$APP/.env.production}"
|
||||
# Commits held back from production, one per line; blank or missing is fine.
|
||||
HOLD="${IHASMAIL_HOLD:-$APP/.deploy-hold}"
|
||||
# Container name, and where to publish it. The default binds to loopback only,
|
||||
# for a reverse proxy in front (see Caddyfile.example / nginx.example.conf).
|
||||
NAME="${IHASMAIL_NAME:-ihasmail}"
|
||||
BIND="${IHASMAIL_BIND:-127.0.0.1:8090}"
|
||||
# Named volume for /data (sessions). Unused when running immutably.
|
||||
VOLUME="${IHASMAIL_VOLUME:-ihasmail-data}"
|
||||
# Run the container immutably: read-only root filesystem, no volume, sessions
|
||||
# held in memory only. See "Running immutably" in the README. The server is told
|
||||
# the same thing through IMMUTABLE=1 and checks it, so a half-applied switch --
|
||||
# the flag without the read-only filesystem, or a SESSION_FILE still pointing
|
||||
# somewhere -- refuses to start here instead of looking fine until the next
|
||||
# redeploy signs everyone out.
|
||||
#
|
||||
# The standing cost is that sessions do not outlive a deploy, because there is
|
||||
# nowhere left to keep them. Going back is this variable and nothing else:
|
||||
#
|
||||
# IHASMAIL_IMMUTABLE=0 ./ihasmail-deploy.sh --yes
|
||||
#
|
||||
# The named volume is never touched either way, so whatever was in it when the
|
||||
# switch was thrown is still there to come back to.
|
||||
IMMUTABLE="${IHASMAIL_IMMUTABLE:-0}"
|
||||
# Image repository. Each build is tagged with its version as well, so an
|
||||
# earlier one can be run again without rebuilding it.
|
||||
IMAGE_REPO="${IHASMAIL_IMAGE:-ihasmail}"
|
||||
# How long to wait for the new container to report healthy, in seconds.
|
||||
HEALTH_TIMEOUT="${IHASMAIL_HEALTH_TIMEOUT:-30}"
|
||||
# How many past versions to keep as images, for rolling back to. Each is around
|
||||
# 650 MB, and a deploy adds one, so left alone they accumulate a gigabyte every
|
||||
# couple of releases -- and `docker image prune` will not touch them, because
|
||||
# they are tagged. 0 keeps every version.
|
||||
KEEP_VERSIONS="${IHASMAIL_KEEP_VERSIONS:-3}"
|
||||
|
||||
# --- run from a copy, if this script lives in the checkout it resets ---------
|
||||
# `git reset --hard` below rewrites the working tree, and this script may be
|
||||
# part of it. Bash does not read a script all at once -- it reads as it goes,
|
||||
# by byte offset -- so a file replaced underneath it makes the shell stop
|
||||
# wherever it had reached. Silently, and with exit status 0: a deploy that
|
||||
# stopped halfway would report success. Re-exec from a copy outside the tree so
|
||||
# the file being run cannot change while it runs.
|
||||
SELF="$(readlink -f "$0")"
|
||||
APP_REAL="$(readlink -f "$APP" 2>/dev/null || printf '%s' "$APP")"
|
||||
if [ -z "${IHASMAIL_REEXEC:-}" ] && [ "${SELF#"$APP_REAL"/}" != "$SELF" ]; then
|
||||
COPY="$(mktemp "${TMPDIR:-/tmp}/ihasmail-deploy.XXXXXX")"
|
||||
cat "$SELF" > "$COPY"
|
||||
chmod +x "$COPY"
|
||||
IHASMAIL_REEXEC=1 exec "$COPY" "$@"
|
||||
fi
|
||||
# The copy has served its purpose once we exit; the shell has finished reading
|
||||
# it by then.
|
||||
if [ -n "${IHASMAIL_REEXEC:-}" ]; then
|
||||
trap 'rm -f "$SELF"' EXIT
|
||||
fi
|
||||
|
||||
REF=""
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
-n|--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help) awk 'NR > 1 { if (/^#/) print; else exit }' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
*)
|
||||
if [ -n "$REF" ]; then echo "give at most one git-ref (got '$REF' and '$arg')" >&2; exit 2; fi
|
||||
REF="$arg" ;;
|
||||
esac
|
||||
done
|
||||
REF="${REF:-origin/main}"
|
||||
|
||||
cd "$APP"
|
||||
git fetch --quiet origin
|
||||
|
||||
if ! TARGET=$(git rev-parse --verify --quiet "${REF}^{commit}"); then
|
||||
echo "!! no such commit: $REF" >&2
|
||||
exit 2
|
||||
fi
|
||||
CURRENT=$(git rev-parse --verify HEAD)
|
||||
|
||||
# --- guard 1: commits held back from production -----------------------------
|
||||
if [ -f "$HOLD" ]; then
|
||||
blocked=""
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line="${line%%#*}"
|
||||
line="$(printf '%s' "$line" | tr -d '[:space:]')"
|
||||
[ -z "$line" ] && continue
|
||||
if ! held=$(git rev-parse --verify --quiet "${line}^{commit}"); then
|
||||
echo " (hold list names '$line', which this checkout does not know -- ignoring)" >&2
|
||||
continue
|
||||
fi
|
||||
# Only a problem if the target carries it and production does not already.
|
||||
if git merge-base --is-ancestor "$held" "$TARGET" && ! git merge-base --is-ancestor "$held" "$CURRENT"; then
|
||||
blocked="${blocked} $(git log --oneline -1 "$held")"$'\n'
|
||||
fi
|
||||
done < "$HOLD"
|
||||
if [ -n "$blocked" ]; then
|
||||
echo "!! refusing to deploy $REF: it contains commits held back from production:" >&2
|
||||
printf '%s' "$blocked" >&2
|
||||
echo " listed in $HOLD -- delete the line to clear the hold, or deploy a ref without it." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- guard 2: say what is being introduced, and get a yes --------------------
|
||||
NEW=$(git log --oneline "$CURRENT..$TARGET")
|
||||
if [ -n "$NEW" ]; then
|
||||
echo "==> $(git log --oneline -1 "$CURRENT") -> $(git log --oneline -1 "$TARGET")"
|
||||
echo "==> introduces:"
|
||||
printf '%s\n' "$NEW" | sed 's/^/ /'
|
||||
else
|
||||
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
|
||||
fi
|
||||
|
||||
# A dry run has now said everything it has to say, so it stops here -- before
|
||||
# the confirmation rather than after it. Asking whether to go ahead with
|
||||
# something that is not going to happen is noise at a terminal; over SSH it was
|
||||
# worse, because the refusal came out *instead of* the report above and a dry
|
||||
# run could not be used from another machine at all. Which is the machine you
|
||||
# are most likely to be on when you want one.
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$NEW" ] && [ "$ASSUME_YES" -ne 1 ]; then
|
||||
if [ -t 0 ]; then
|
||||
read -r -p "deploy these to production? [y/N] " reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) echo "aborted."; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
echo "!! refusing: this introduces new commits and there is no terminal to confirm on." >&2
|
||||
echo " re-run with --yes if that is what you mean, or name the ref you want." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
git reset --hard --quiet "$TARGET"
|
||||
|
||||
# The version is worked out here, from the checkout, because the image build
|
||||
# cannot: .dockerignore keeps .git out of the build context. Without this the
|
||||
# build falls back to the base version in package.json and every deployment
|
||||
# reports the same number -- see "Version numbers" in the README.
|
||||
# Drop the oldest versioned images, keeping the newest KEEP_VERSIONS of them.
|
||||
#
|
||||
# Only ever runs after the new container reports healthy, so a rollback target
|
||||
# is never removed while the thing replacing it is still unproven. The image in
|
||||
# use is excluded outright rather than relied on to sort newest -- docker
|
||||
# refuses to remove an image a container is using, but being refused is not the
|
||||
# same as not having tried.
|
||||
prune_old_images() {
|
||||
[ "$KEEP_VERSIONS" -gt 0 ] || return 0
|
||||
local in_use stale
|
||||
in_use="$(docker inspect "$NAME" --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
# Newest first, tags only, skipping the moving ":current" pointer.
|
||||
stale="$(docker images "$IMAGE_REPO" --format '{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}' \
|
||||
| grep -v ":current" \
|
||||
| sort -k2 -r \
|
||||
| cut -f1 \
|
||||
| grep -vxF "$in_use" \
|
||||
| tail -n +"$((KEEP_VERSIONS + 1))")"
|
||||
[ -n "$stale" ] || return 0
|
||||
echo "==> removing $(printf '%s\n' "$stale" | wc -l) old image(s), keeping the newest $KEEP_VERSIONS"
|
||||
printf '%s\n' "$stale" | xargs -r docker rmi >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
VERSION="$(node scripts/version.mjs)"
|
||||
# A Docker tag may not contain "+", and every version has one now:
|
||||
# 2026.8.30+pr129, or +g1fa6578 for a commit that did not come through a pull
|
||||
# request. The image is tagged with the "+" turned into "-"; what the build is
|
||||
# *told* it is keeps the real form, so About and /api/health still report it
|
||||
# correctly.
|
||||
TAG="${VERSION//+/-}"
|
||||
echo "==> building $(git log --oneline -1) as v$VERSION"
|
||||
docker build \
|
||||
--build-arg IHASMAIL_VERSION="$VERSION" \
|
||||
-t "$IMAGE_REPO:$TAG" \
|
||||
-t "$IMAGE_REPO:current" \
|
||||
.
|
||||
|
||||
RUN_ARGS=(-d --name "$NAME" --restart unless-stopped -p "$BIND:8080" --env-file "$ENVF")
|
||||
if [ "$IMMUTABLE" = "1" ]; then
|
||||
# -e wins over --env-file, so this clears a SESSION_FILE set there or baked
|
||||
# into the image, rather than needing the environment file edited to match.
|
||||
RUN_ARGS+=(--read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE=)
|
||||
echo "==> restarting container -- immutable: read-only, no volume, sessions in memory"
|
||||
echo " (everyone signed in is signed out; IHASMAIL_IMMUTABLE=0 puts it back)"
|
||||
else
|
||||
RUN_ARGS+=(-v "$VOLUME:/data")
|
||||
echo "==> restarting container"
|
||||
fi
|
||||
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
docker run "${RUN_ARGS[@]}" "$IMAGE_REPO:$TAG" >/dev/null
|
||||
|
||||
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
|
||||
if health=$(curl -sf "http://$BIND/api/health"); then
|
||||
echo "==> healthy: $health"
|
||||
prune_old_images
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "!! did not become healthy after ${HEALTH_TIMEOUT}s; logs:" >&2
|
||||
docker logs "$NAME" 2>&1 | tail -20 >&2
|
||||
echo "!! the previous image is still tagged, if you need it back:" >&2
|
||||
docker images "$IMAGE_REPO" --format ' {{.Repository}}:{{.Tag}} {{.CreatedSince}}' | head -5 >&2
|
||||
exit 1
|
||||
@@ -1,11 +1,18 @@
|
||||
services:
|
||||
ihasmail:
|
||||
build: .
|
||||
image: ihasmail:latest
|
||||
env_file: .env
|
||||
image: ihasmail:2
|
||||
restart: unless-stopped
|
||||
networks: [edge]
|
||||
ports:
|
||||
- "127.0.0.1:8080:8000"
|
||||
networks:
|
||||
edge: {}
|
||||
- "8080:8080"
|
||||
environment:
|
||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||
APP_NAME: ${APP_NAME:-ihasmail}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail}
|
||||
TRUST_PROXY: "1"
|
||||
IMAGE_PROXY: "1"
|
||||
volumes:
|
||||
- ihasmail-data:/data
|
||||
volumes:
|
||||
ihasmail-data:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The light inbox shot, with no Emulation.setDeviceMetricsOverride at all --
|
||||
* the window is simply launched at the size we want. The emulation layer is the
|
||||
* prime suspect for the mixed-theme frames every other approach produced.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const OUT = process.argv[2] ?? ".";
|
||||
const PORT = 9334;
|
||||
const chrome = spawn("google-chrome-stable", [
|
||||
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--window-size=1420,790", "--force-device-scale-factor=1",
|
||||
"--user-data-dir=/tmp/ihasmail-light-profile", "about:blank",
|
||||
], { stdio: "ignore" });
|
||||
|
||||
const json = async (p) => { for (let i = 0; i < 60; i++) { try { return await (await fetch(`http://127.0.0.1:${PORT}${p}`)).json(); } catch { await sleep(250); } } throw new Error("no chrome"); };
|
||||
const version = await json("/json/version");
|
||||
let id = 1; const pending = new Map();
|
||||
const ws = new WebSocket(version.webSocketDebuggerUrl);
|
||||
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
||||
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && pending.has(x.id)) { const { resolve, reject } = pending.get(x.id); pending.delete(x.id); x.error ? reject(new Error(JSON.stringify(x.error))) : resolve(x.result); } };
|
||||
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const i = id++; pending.set(i, { resolve, reject }); ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); });
|
||||
|
||||
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
|
||||
const cmd = (m, p) => send(m, p, sessionId);
|
||||
await cmd("Page.enable"); await cmd("Runtime.enable");
|
||||
const evaluate = async (expression) => {
|
||||
const r = await cmd("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "eval failed");
|
||||
return r.result.value;
|
||||
};
|
||||
const waitFor = async (expr, what, ms = 20000) => {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) { if (await evaluate(`!!(${expr})`)) return; await sleep(200); }
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
};
|
||||
|
||||
try {
|
||||
await cmd("Page.navigate", { url: "http://localhost:5173/" });
|
||||
await sleep(1500);
|
||||
console.log("viewport:", await evaluate(`window.innerWidth + 'x' + window.innerHeight`));
|
||||
await evaluate(`
|
||||
window.__set = (el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
|
||||
window.__btn = (t, r=document) => [...r.querySelectorAll('button')].find(b => b.textContent.trim() === t);
|
||||
`);
|
||||
await evaluate(`(() => {
|
||||
const i = [...document.querySelectorAll('input')];
|
||||
window.__set(i.find(x => x.type === 'text' || x.type === 'email'), '[email protected]');
|
||||
window.__set(document.querySelector('input[type=password]'), 'demo');
|
||||
window.__btn('Sign in').click();
|
||||
})()`);
|
||||
await waitFor("document.querySelectorAll('.msg-row').length > 2", "the message list");
|
||||
await sleep(1500);
|
||||
await evaluate(`(() => { const r = document.querySelectorAll('.msg-row'); if (r[1]) r[1].click(); })()`);
|
||||
await sleep(1500);
|
||||
|
||||
// The app's own control, the way a user switches theme.
|
||||
await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('button')].find(x => /light mode/i.test(x.getAttribute('aria-label') || x.title || ''));
|
||||
if (b) b.click(); else document.documentElement.dataset.theme = 'light';
|
||||
})()`);
|
||||
await sleep(2000);
|
||||
const bg = await evaluate(`getComputedStyle(document.body).backgroundColor`);
|
||||
const topbar = await evaluate(`getComputedStyle(document.querySelector('.topbar')).backgroundColor`);
|
||||
console.log("body:", bg, "topbar:", topbar);
|
||||
if (parseInt(bg.match(/\d+/)[0], 10) < 200) throw new Error("page is not rendering light");
|
||||
|
||||
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
|
||||
await writeFile(`${OUT}/inbox-light.jpg`, Buffer.from(data, "base64"));
|
||||
console.log("wrote inbox-light.jpg");
|
||||
} finally { ws.close(); chrome.kill(); }
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Regenerates most of the README screenshots from the mock server.
|
||||
*
|
||||
* Drives headless Chrome over CDP, so the viewport is exactly the size the
|
||||
* images already use rather than whatever a window happens to be.
|
||||
*
|
||||
* npm run dev:mock # in another terminal
|
||||
* node docs/screenshots.mjs docs/screenshots
|
||||
* node docs/screenshots-light.mjs docs/screenshots
|
||||
*
|
||||
* Restart the mock before a run. The filters shot creates rules, so a second
|
||||
* run against the same mock shows them twice.
|
||||
*
|
||||
* The files shot was taken by hand until 2026-08-27, and had gone stale twice
|
||||
* over by the time anyone noticed. Anything the docs show should be generated
|
||||
* from the mock, or it describes whatever the app looked like on the day
|
||||
* somebody had a screenshot tool open.
|
||||
*
|
||||
* Two shots are deliberately not taken here:
|
||||
*
|
||||
* - **mobile**, because at the tail of this sequence the app would not render
|
||||
* the message list at 500px within the wait. A short run of its own is
|
||||
* reliable, and it is a screenshot, not a mystery worth solving.
|
||||
*
|
||||
* - **inbox-light**, because of setDeviceMetricsOverride. Swapping the theme
|
||||
* under the emulation layer captures a *mixed* frame: the panes that
|
||||
* re-rendered come out light while the rest of the chrome stays dark, with
|
||||
* the DOM and computed styles insisting the whole page is light. The app is
|
||||
* not at fault -- update() calls applyTheme() synchronously and the CSS does
|
||||
* flip --bg to #f6f8fa. The compositor simply does not repaint everything a
|
||||
* CSS-variable change touches while metrics are overridden. Launching Chrome
|
||||
* at --window-size and never calling setDeviceMetricsOverride renders it
|
||||
* correctly, which is what docs/screenshots-light.mjs does.
|
||||
*
|
||||
* assertTheme() stays either way: without it this script wrote a dark
|
||||
* screenshot under a light caption and reported success, and that is how the
|
||||
* README came to show the same theme twice for months.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const OUT = process.argv[2];
|
||||
if (!OUT) { console.error("usage: node shots.mjs <out-dir>"); process.exit(2); }
|
||||
await mkdir(OUT, { recursive: true });
|
||||
|
||||
const PORT = 9333;
|
||||
const chrome = spawn("google-chrome-stable", [
|
||||
"--headless=new", `--remote-debugging-port=${PORT}`, "--hide-scrollbars",
|
||||
"--no-first-run", "--no-default-browser-check", "--disable-gpu",
|
||||
`--user-data-dir=/tmp/ihasmail-shots-profile`, "about:blank",
|
||||
], { stdio: "ignore" });
|
||||
|
||||
const json = async (path) => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try { return await (await fetch(`http://127.0.0.1:${PORT}${path}`)).json(); }
|
||||
catch { await sleep(250); }
|
||||
}
|
||||
throw new Error("Chrome did not come up");
|
||||
};
|
||||
const version = await json("/json/version");
|
||||
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const ws = new WebSocket(version.webSocketDebuggerUrl);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
|
||||
}
|
||||
};
|
||||
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||
});
|
||||
|
||||
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
|
||||
const cmd = (m, p) => send(m, p, sessionId);
|
||||
await cmd("Page.enable");
|
||||
await cmd("Runtime.enable");
|
||||
|
||||
let current = { width: 1420, height: 703, mobile: false };
|
||||
const metrics = (width, height, mobile = false) => {
|
||||
current = { width, height, mobile };
|
||||
return cmd("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile });
|
||||
};
|
||||
|
||||
/**
|
||||
* Forces the whole page to repaint.
|
||||
*
|
||||
* Headless only repaints the layers that changed, and a theme swap changes CSS
|
||||
* variables rather than any single element — so the capture came back with the
|
||||
* message pane in the new theme and the rest of the app in the old one. Nudging
|
||||
* the viewport by a pixel and back invalidates everything.
|
||||
*/
|
||||
const repaint = async () => {
|
||||
// Detaching and reattaching the body invalidates every layer; nudging the
|
||||
// viewport did not, and the capture kept coming back with mixed themes.
|
||||
await evaluate(`(() => { const b = document.body; b.style.display = 'none'; void b.offsetHeight; b.style.display = ''; })()`);
|
||||
await sleep(500);
|
||||
};
|
||||
|
||||
const go = async (url) => { await cmd("Page.navigate", { url }); await sleep(1200); };
|
||||
const evaluate = async (expression) => {
|
||||
const r = await cmd("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? "eval failed");
|
||||
return r.result.value;
|
||||
};
|
||||
/** Polls a predicate inside the page until it is true, or gives up loudly. */
|
||||
const waitFor = async (jsExpr, what, ms = 15000) => {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate(`!!(${jsExpr})`)) return;
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
};
|
||||
/**
|
||||
* Pins the theme, because setting it once is not enough.
|
||||
*
|
||||
* The app re-runs applyTheme() from its own setting whenever the settings store
|
||||
* stirs, and that overwrote a plain attribute set during the settle before the
|
||||
* capture — twice, silently, producing a "light" screenshot of the dark theme.
|
||||
* A MutationObserver puts it back faster than anything can take it away.
|
||||
*
|
||||
* The check is the rendered background colour: the attribute is what lied.
|
||||
*/
|
||||
const themeTest = (want) => want === "light"
|
||||
? "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) > 200"
|
||||
: "parseInt(getComputedStyle(document.body).backgroundColor.match(/\\d+/)[0], 10) < 60";
|
||||
|
||||
const setTheme = async (want) => {
|
||||
await evaluate(`(() => {
|
||||
const html = document.documentElement;
|
||||
const want = ${JSON.stringify(want)};
|
||||
if (window.__themePin) window.__themePin.disconnect();
|
||||
window.__themePin = new MutationObserver(() => { if (html.dataset.theme !== want) html.dataset.theme = want; });
|
||||
window.__themePin.observe(html, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
html.dataset.theme = want;
|
||||
})()`);
|
||||
await waitFor(themeTest(want), `the ${want} theme to actually render`);
|
||||
await repaint();
|
||||
};
|
||||
|
||||
/** Refuses to write the file unless the page still looks the way it should. */
|
||||
const assertTheme = async (want) => {
|
||||
if (!(await evaluate(themeTest(want)))) throw new Error(`page is not rendering the ${want} theme at capture time`);
|
||||
};
|
||||
const shot = async (name) => {
|
||||
const { data } = await cmd("Page.captureScreenshot", { format: "jpeg", quality: 82 });
|
||||
await writeFile(`${OUT}/${name}`, Buffer.from(data, "base64"));
|
||||
console.log(" wrote", name);
|
||||
};
|
||||
|
||||
// Helpers injected into the page: React-controlled inputs need the native setter.
|
||||
const HELPERS = `
|
||||
window.__set = (el, v) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(el, v); el.dispatchEvent(new Event('input',{bubbles:true})); };
|
||||
window.__btn = (txt, root=document) => [...root.querySelectorAll('button')].find(b => b.textContent.trim() === txt);
|
||||
window.__click = (sel) => { const el = document.querySelector(sel); if (el) el.click(); return !!el; };
|
||||
window.__sel = (el, v) => { el.value = v; el.dispatchEvent(new Event('change', { bubbles: true })); };
|
||||
`;
|
||||
|
||||
try {
|
||||
console.log("chrome:", version.Browser);
|
||||
|
||||
// --- login (taller, as the existing shot is) ---
|
||||
await metrics(1420, 759);
|
||||
await go("http://localhost:5173/");
|
||||
await evaluate(HELPERS);
|
||||
await sleep(600);
|
||||
await shot("login.jpg");
|
||||
|
||||
// --- sign in (a fresh profile prefills nothing, so both fields) ---
|
||||
await evaluate(`(() => {
|
||||
const inputs = [...document.querySelectorAll('input')];
|
||||
const user = inputs.find(i => i.type === 'text' || i.type === 'email');
|
||||
const pw = document.querySelector('input[type=password]');
|
||||
window.__set(user, '[email protected]');
|
||||
window.__set(pw, 'demo');
|
||||
window.__btn('Sign in').click();
|
||||
})()`);
|
||||
await waitFor("document.querySelector('.msg-row') || document.querySelector('.nav-item')", "the app after sign-in");
|
||||
await sleep(1500);
|
||||
|
||||
// --- inbox, dark, with a conversation open ---
|
||||
await metrics(1420, 703);
|
||||
await go("http://localhost:5173/mail");
|
||||
await evaluate(HELPERS);
|
||||
await waitFor("document.querySelectorAll('.msg-row').length > 2", "the message list");
|
||||
await evaluate(`(() => { const r = document.querySelectorAll('.msg-row'); if (r[1]) r[1].click(); })()`);
|
||||
await sleep(1800);
|
||||
await shot("inbox-dark.jpg");
|
||||
|
||||
// --- the reply composer, still on the dark theme ---
|
||||
await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('button')].find(x => /^reply$/i.test(x.getAttribute('aria-label')||'') || /^reply$/i.test(x.textContent.trim()));
|
||||
if (b) b.click();
|
||||
})()`);
|
||||
await sleep(1800);
|
||||
await shot("compose.jpg");
|
||||
|
||||
// The recipient picker, taken here because the composer is already open. The
|
||||
// site claims you can pick recipients by reading the address books rather
|
||||
// than remembering a name, and this is that claim photographed. Doing it from
|
||||
// a later step meant navigating back to the mail list, which turned out not
|
||||
// to be reliable once the run had been through Files.
|
||||
await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('button')].find(x => x.getAttribute('aria-label') === 'Choose from address books');
|
||||
if (b) b.click();
|
||||
})()`);
|
||||
await waitFor("/Choose recipients/.test(document.body.innerText)", "the recipient picker");
|
||||
await evaluate(`(() => {
|
||||
// Two ticked, so the shot shows a selection rather than an empty list.
|
||||
for (const b of [...document.querySelectorAll('.menu-item input[type=checkbox]')].slice(0, 2)) b.click();
|
||||
})()`);
|
||||
await sleep(1500);
|
||||
await shot("recipients.jpg");
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Cancel'); if (c) c.click(); })()`);
|
||||
await sleep(600);
|
||||
|
||||
await evaluate(`(() => { const c = [...document.querySelectorAll('button')].find(b => /close|discard/i.test(b.getAttribute('aria-label')||'')); if (c) c.click(); })()`);
|
||||
await sleep(800);
|
||||
|
||||
// (inbox-light is captured by docs/screenshots-light.mjs -- see the header)
|
||||
|
||||
|
||||
// --- calendar ---
|
||||
await go("http://localhost:5173/calendar");
|
||||
await waitFor("document.querySelector('.cal-grid, .calendar, [class*=cal]')", "the calendar");
|
||||
await evaluate(HELPERS);
|
||||
// The README caption promises the month view.
|
||||
await evaluate(`(() => { const b = window.__btn('Month'); if (b) b.click(); })()`);
|
||||
await sleep(1800);
|
||||
await shot("calendar.jpg");
|
||||
|
||||
// --- contacts ---
|
||||
await go("http://localhost:5173/contacts");
|
||||
await waitFor("document.querySelector('[class*=contact]')", "the contact list");
|
||||
// Open someone, so the detail pane is not an empty "Select a contact".
|
||||
await evaluate(`(() => {
|
||||
const hit = [...document.querySelectorAll('div, li, button, a')]
|
||||
.filter(e => (e.textContent || '').trim().startsWith('Ada Lovelace'))
|
||||
.sort((a, b) => a.textContent.length - b.textContent.length)[0];
|
||||
if (hit) (hit.closest('li, button, a, [class*=row], [class*=item]') || hit).click();
|
||||
})()`);
|
||||
await waitFor("!/Select a contact/.test(document.body.innerText)", "the contact detail pane", 8000);
|
||||
await sleep(1800);
|
||||
await shot("contacts.jpg");
|
||||
|
||||
// --- files ---
|
||||
// Was the one shot taken by hand, which is why it outlived two rewrites of
|
||||
// the view it was meant to show. The tree makes it worth automating: opening
|
||||
// a folder is now the difference between a screenshot of a file manager and a
|
||||
// screenshot of a list.
|
||||
await go("http://localhost:5173/files");
|
||||
await waitFor("document.querySelector('.files-table, .files-layout')", "the files view");
|
||||
await evaluate(`(() => {
|
||||
// Expand the tree and open a folder, so the shot shows the pane doing its job.
|
||||
const twisty = document.querySelector('.sidebar .nav-twisty');
|
||||
if (twisty) twisty.click();
|
||||
const folder = [...document.querySelectorAll('.sidebar .nav-item')].find(e => /Documents/.test(e.textContent || ""));
|
||||
if (folder) folder.click();
|
||||
})()`);
|
||||
await sleep(1800);
|
||||
await shot("files.jpg");
|
||||
|
||||
// --- filters, with rules that actually say something ---
|
||||
await go("http://localhost:5173/settings/filters");
|
||||
await evaluate(HELPERS);
|
||||
await waitFor("[...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'New rule')", "the filters editor");
|
||||
await evaluate(`(async () => {
|
||||
const wait = (ms=350) => new Promise(r => setTimeout(r, ms));
|
||||
const rules = [
|
||||
{ name: 'Newsletters', field: 'list-id', op: 'exists', value: '', folder: 'Newsletters' },
|
||||
{ name: 'From the boss', field: 'from', op: 'contains', value: '[email protected]', folder: 'Work' },
|
||||
{ name: 'Receipts', field: 'subject', op: 'contains', value: 'invoice', folder: 'Archive' },
|
||||
{ name: 'Build failures',field: 'subject', op: 'matches', value: '*FAILED*', folder: 'Work' },
|
||||
];
|
||||
for (const r of rules) {
|
||||
window.__btn('New rule').click(); await wait();
|
||||
const d = document.querySelector('.dialog');
|
||||
window.__set(d.querySelector('input.input'), r.name); await wait(120);
|
||||
const row = d.querySelector('.rule-row');
|
||||
const sels = row.querySelectorAll('select');
|
||||
window.__sel(sels[0], r.field); await wait(120);
|
||||
const sels2 = d.querySelector('.rule-row').querySelectorAll('select');
|
||||
if (sels2[1]) { window.__sel(sels2[1], r.op); await wait(120); }
|
||||
const val = [...d.querySelector('.rule-row').querySelectorAll('input.input')].pop();
|
||||
if (val && r.value) { window.__set(val, r.value); await wait(120); }
|
||||
const arow = d.querySelector('.rule-row.actions');
|
||||
const asels = arow.querySelectorAll('select');
|
||||
if (asels[1]) { window.__sel(asels[1], r.folder); await wait(120); }
|
||||
window.__btn('Done', d).click(); await wait();
|
||||
}
|
||||
const save = window.__btn('Save filters'); if (save && !save.disabled) save.click();
|
||||
await wait(1500);
|
||||
// Clear the "Filters saved" toast so it does not sit over a rule.
|
||||
document.querySelectorAll('.toast, [class*=toast]').forEach(t => t.remove());
|
||||
})()`);
|
||||
await sleep(1200);
|
||||
await shot("filters.jpg");
|
||||
|
||||
// (mobile is captured separately by shots-mobile.mjs)
|
||||
|
||||
console.log("done");
|
||||
} finally {
|
||||
ws.close();
|
||||
chrome.kill();
|
||||
}
|
||||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,19 @@
|
||||
# Example nginx location block for ihasmail behind TLS termination.
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name mail.example.com;
|
||||
# ssl_certificate ...; ssl_certificate_key ...;
|
||||
|
||||
client_max_body_size 60m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Server-Sent Events (push notifications)
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "ihasmail",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"build": "npm run build -w web && npm run build -w server",
|
||||
"start": "node server/dist/index.js",
|
||||
"typecheck": "npm run typecheck -w web && npm run typecheck -w server",
|
||||
"test": "npm run test -w web && npm run test -w server",
|
||||
"lint": "npm run typecheck",
|
||||
"mock": "npm run mock -w server",
|
||||
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"dev:mock:no-future-release": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:no-future-release -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ihasmail"
|
||||
version = "0.2.0"
|
||||
description = "ihasmail — JMAP webmail for Stalwart (FastAPI, HTMX/Jinja)"
|
||||
authors = [{name = "John Coffey", email = "[email protected]"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "GPL-3.0-or-later"}
|
||||
dependencies = [
|
||||
"fastapi>=0.111",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"httpx>=0.27",
|
||||
"jinja2>=3.1",
|
||||
"bleach>=6.1",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.2",
|
||||
"anyio>=4.4",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q"
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
|
||||
export const UNVERSIONED: string;
|
||||
export function formatVersion(commit: { date: string; subject?: string; sha: string }): string;
|
||||
export function versionFromGit(): string | null;
|
||||
export function resolveVersion(): string;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Work out this build's version: `2026.8.30+pr129`.
|
||||
*
|
||||
* 2026.8.30 the date of the commit this was built from
|
||||
* +pr129 the pull request it arrived through
|
||||
*
|
||||
* The date leads because ihasmail's version used to be `2.16.<pr>`, where `16`
|
||||
* was the Stalwart generation it targeted -- and Stalwart 1.0 will leave that
|
||||
* with nowhere to go. `2.1` would sort *below* the `2.16` already deployed, so
|
||||
* every image and About screen would read as a downgrade. Tying our
|
||||
* numbering to somebody else's was the mistake; which Stalwart a build needs is
|
||||
* said properly in the README badge and KNOWN-ISSUES, where it can be precise
|
||||
* ("0.16 or newer; tested against 0.16.20") rather than one digit.
|
||||
*
|
||||
* The pull request moved into build metadata, after the `+`, because it is
|
||||
* provenance rather than a position in a sequence: at a hundred merges a week
|
||||
* it climbs without bound and says nothing about how new a build is. SemVer
|
||||
* ignores everything after the `+` when comparing versions, which is the right
|
||||
* reading -- two builds from the same day differ in where they came from, not
|
||||
* in rank. Nothing here relies on that comparison anyway: images are pruned
|
||||
* oldest-first by creation time and rollbacks name a git ref.
|
||||
*
|
||||
* A commit that did not arrive through a pull request carries its short SHA
|
||||
* instead -- `2026.8.30+g1fa6578` -- which is honest about being some commit on
|
||||
* that day rather than claiming a pull request it was only built after.
|
||||
*
|
||||
* The date is the commit's own, not today's, so rebuilding an old commit gives
|
||||
* the same answer it gave the first time. It comes from the commit object,
|
||||
* timezone included, so two machines agree.
|
||||
*
|
||||
* Nothing writes a version back into the tree: a committed one would always be
|
||||
* describing a merge that had not happened yet, and every branch would collide
|
||||
* on the same line. `package.json` no longer carries it either -- npm wants the
|
||||
* field, so it stays at `0.0.0`, which is what an unversioned build reports and
|
||||
* is meant to look wrong.
|
||||
*
|
||||
* `.dockerignore` excludes `.git`, so an image build cannot run any of this.
|
||||
* It takes the answer through `--build-arg IHASMAIL_VERSION=...` instead, and
|
||||
* whoever builds is responsible for computing it -- see ihasmail-deploy.sh.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/** What a build with nothing to go on reports, and it should look wrong. */
|
||||
export const UNVERSIONED = "0.0.0";
|
||||
|
||||
function git(...args) {
|
||||
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
}
|
||||
|
||||
const PR_SUBJECT = /^Merge pull request #(\d+)\b/;
|
||||
|
||||
/**
|
||||
* The version for a commit, from the three things about it that decide one.
|
||||
* Pure, so the rules can be exercised without a repository staged to produce
|
||||
* them: `{ date: "2026-08-30", subject: "Merge pull request #129 from ...",
|
||||
* sha: "1fa6578" }` gives `2026.8.30+pr129`.
|
||||
*
|
||||
* Leading zeros are stripped because a version field may not carry them, so
|
||||
* September is `9` rather than `09`.
|
||||
*/
|
||||
export function formatVersion({ date, subject = "", sha }) {
|
||||
const [y, m, d] = date.split("-");
|
||||
const calendar = `${Number(y)}.${Number(m)}.${Number(d)}`;
|
||||
const pr = PR_SUBJECT.exec(subject)?.[1];
|
||||
return pr ? `${calendar}+pr${pr}` : `${calendar}+g${sha}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The version for the commit checked out here, or null when there is no git to
|
||||
* ask -- an unpacked tarball, or the Docker build context.
|
||||
*/
|
||||
export function versionFromGit() {
|
||||
let head;
|
||||
let date;
|
||||
try {
|
||||
head = git("rev-parse", "--short", "HEAD");
|
||||
// %cs is the committer date in the commit's own timezone, which is stored
|
||||
// in the commit -- so this does not depend on the clock or zone of whoever
|
||||
// is building.
|
||||
date = git("show", "-s", "--format=%cs", "HEAD");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
let subject = "";
|
||||
try {
|
||||
subject = git("show", "-s", "--format=%s", "HEAD");
|
||||
} catch {
|
||||
/* no subject to read; fall through to the SHA */
|
||||
}
|
||||
return formatVersion({ date, subject, sha: head });
|
||||
}
|
||||
|
||||
/** Whatever the environment was told, else git, else an answer that looks wrong. */
|
||||
export function resolveVersion() {
|
||||
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return versionFromGit() ?? UNVERSIONED;
|
||||
}
|
||||
|
||||
// `node scripts/version.mjs` prints it, for shell scripts and CI.
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
process.stdout.write(resolveVersion() + "\n");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@ihasmail/server",
|
||||
"version": "2.16.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "tsx --test src/*.test.ts src/**/*.test.ts",
|
||||
"mock": "tsx src/mock/index.ts",
|
||||
"mock:no-future-release": "MOCK_NO_FUTURE_RELEASE=1 tsx src/mock/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"hono": "^4.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* End-to-end self-service credential flows against the mock, which enforces
|
||||
* the same rules a real 0.16 server does: the current password is checked,
|
||||
* password policy is applied, and once 2FA is on every request wants a fresh
|
||||
* TOTP code — except one authenticating with an app password.
|
||||
*/
|
||||
|
||||
const PORT = 18797;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-account-flows";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||
|
||||
const app = createApp();
|
||||
let cookie = "";
|
||||
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
|
||||
const res = await app.request(path, {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
|
||||
});
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
if (setCookie) cookie = setCookie.split(";")[0]!;
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
before(async () => {
|
||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.status, 200, "login should succeed against the mock");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart advertises `urn:stalwart:jmap` only per-account, never in the
|
||||
* session-level capabilities. Looking for it at the top level alone reported
|
||||
* every real 0.16 server as older than 0.16 — and now that the same check
|
||||
* decides whether a sign-in is allowed at all, that mistake would lock
|
||||
* everyone out rather than merely misroute credentials.
|
||||
*/
|
||||
test("the session is accepted on a server that advertises the registry per-account", async () => {
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.edition, "oss");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look");
|
||||
assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server");
|
||||
});
|
||||
|
||||
test("the registry reports an account with nothing set up yet", async () => {
|
||||
const res = await call("/api/account/security");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.otpEnabled, false);
|
||||
assert.deepEqual(res.body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
||||
assert.equal(created.status, 200);
|
||||
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
||||
assert.ok(created.body.id);
|
||||
|
||||
const list = await call("/api/account/security");
|
||||
assert.equal(list.body.appPasswords.length, 1);
|
||||
assert.equal(list.body.appPasswords[0].description, "Thunderbird");
|
||||
assert.equal(list.body.appPasswords[0].secret, undefined, "the secret is never listed again");
|
||||
|
||||
const revoked = await post("/api/account/app-passwords/revoke", { id: created.body.id });
|
||||
assert.equal(revoked.status, 200);
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("an app password needs a name", async () => {
|
||||
const res = await post("/api/account/app-passwords", { description: " " });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error, "missing_fields");
|
||||
});
|
||||
|
||||
test("the wrong current password is refused with the server's reason", async () => {
|
||||
const res = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
|
||||
assert.equal(res.status, 403);
|
||||
assert.match(res.body.message, /Current secret is incorrect/);
|
||||
});
|
||||
|
||||
test("the server's password policy is surfaced verbatim", async () => {
|
||||
const res = await post("/api/account/password", { current: "demo-password", next: "short" });
|
||||
assert.equal(res.status, 400);
|
||||
assert.match(res.body.message, /at least 8 characters/);
|
||||
});
|
||||
|
||||
test("a password unchanged from the old one is rejected before we ask upstream", async () => {
|
||||
const res = await post("/api/account/password", { current: "demo-password", next: "demo-password" });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error, "unchanged");
|
||||
});
|
||||
|
||||
test("changing the password keeps this session working", async () => {
|
||||
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
|
||||
assert.equal(res.status, 200);
|
||||
// The stored credential was re-sealed, so the next proxied call still passes
|
||||
// upstream authentication with the new password.
|
||||
assert.equal((await call("/api/auth/session")).status, 200);
|
||||
assert.equal((await call("/api/account/security")).status, 200);
|
||||
});
|
||||
|
||||
test("enabling 2FA rejects a code the new secret did not produce", async () => {
|
||||
const begin = await post("/api/account/2fa/begin", {});
|
||||
assert.equal(begin.status, 200);
|
||||
assert.match(begin.body.url, /^otpauth:\/\/totp\//);
|
||||
const res = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.code, undefined);
|
||||
assert.match(res.body.message, /doesn't match/);
|
||||
assert.equal((await call("/api/account/security")).body.otpEnabled, false, "nothing was stored");
|
||||
});
|
||||
|
||||
test("enabling 2FA switches the session onto an app password so it survives", async () => {
|
||||
const begin = await post("/api/account/2fa/begin", {});
|
||||
const params = parseOtpauthUrl(begin.body.url);
|
||||
assert.ok(params);
|
||||
const res = await post("/api/account/2fa/enable", {
|
||||
url: begin.body.url,
|
||||
code: totpCode(params),
|
||||
current: "a-brand-new-password",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.sessionKept, true);
|
||||
|
||||
const state = await call("/api/account/security");
|
||||
assert.equal(state.status, 200, "the session still authenticates upstream");
|
||||
assert.equal(state.body.otpEnabled, true);
|
||||
assert.equal(state.body.appPasswords.length, 1, "one app password was minted for this browser");
|
||||
assert.match(state.body.appPasswords[0].description, /\(/, "it is named after the browser");
|
||||
});
|
||||
|
||||
test("with 2FA on, a password change needs the current code too", async () => {
|
||||
const withoutCode = await post("/api/account/password", { current: "a-brand-new-password", next: "yet-another-password" });
|
||||
assert.equal(withoutCode.status, 403);
|
||||
assert.match(withoutCode.body.message, /OTP code is required/);
|
||||
});
|
||||
|
||||
test("2FA is switched off with the password and a current code", async () => {
|
||||
const state = await call("/api/account/security");
|
||||
assert.equal(state.body.otpEnabled, true);
|
||||
// The enrolment secret is known only to the client, so disabling uses a code
|
||||
// from the authenticator - here, the one the mock stored.
|
||||
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
|
||||
const params = parseOtpauthUrl(stored!);
|
||||
assert.ok(params);
|
||||
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
|
||||
});
|
||||
|
||||
test("credential endpoints reject unauthenticated callers", async () => {
|
||||
const saved = cookie;
|
||||
cookie = "";
|
||||
assert.equal((await call("/api/account/security")).status, 401);
|
||||
assert.equal((await post("/api/account/password", { current: "a", next: "b" })).status, 401);
|
||||
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
|
||||
cookie = saved;
|
||||
});
|
||||
|
||||
/**
|
||||
* A sign-in carrying a two-factor code that the server rejects is almost never
|
||||
* "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers
|
||||
* no password grant, so the concatenated form ihasmail sends cannot work — and
|
||||
* saying "invalid credentials" sends the user to check a password that is fine.
|
||||
*
|
||||
* Reported as #75: 2FA sign-in failed with a bare 401 while an app password
|
||||
* worked, which is Stalwart's documented route and gave no hint of itself.
|
||||
*/
|
||||
test("a rejected sign-in carrying a TOTP code explains itself", async () => {
|
||||
const saved = cookie;
|
||||
cookie = "";
|
||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password", totp: "123456" });
|
||||
cookie = saved;
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials");
|
||||
assert.match(res.body.message, /app password/i, "points at the route that does work");
|
||||
assert.match(res.body.message, /probably fine/i, "does not blame the password");
|
||||
});
|
||||
|
||||
test("a rejected sign-in without a code is still a plain credential failure", async () => {
|
||||
// The explanation must not leak onto ordinary typos.
|
||||
const saved = cookie;
|
||||
cookie = "";
|
||||
const res = await post("/api/auth/login", { username: "[email protected]", password: "wrong" });
|
||||
cookie = saved;
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.body.error, "invalid_credentials");
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||
|
||||
/**
|
||||
* Self-service credential management, over Stalwart's JMAP registry:
|
||||
* `x:AccountPassword` (a singleton holding the password and the otpauth URL)
|
||||
* and `x:AppPassword`.
|
||||
*
|
||||
* The registry crate arrived in 0.16, which is the oldest Stalwart ihasmail
|
||||
* supports. Sign-in refuses anything older, so by the time any of this runs
|
||||
* the registry is known to be there.
|
||||
*/
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
/** Stalwart's id for a singleton object; the number it encodes spells this. */
|
||||
const SINGLETON = "singleton";
|
||||
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
|
||||
const MASKED = "[********]";
|
||||
|
||||
export interface AppPasswordRow {
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityState {
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordRow[];
|
||||
}
|
||||
|
||||
/** An error with a message meant for the person using the app. */
|
||||
export class AccountError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status = 400,
|
||||
public readonly code = "account_error",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AccountError";
|
||||
}
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
authorization: string;
|
||||
session: UpstreamSession;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transport */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function accountId(ctx: Ctx): string {
|
||||
return (
|
||||
ctx.session.primaryAccounts?.[STALWART_CAP] ??
|
||||
ctx.session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
Object.keys(ctx.session.accounts ?? {})[0] ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
type Invocation = [string, Record<string, unknown>, string];
|
||||
|
||||
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
|
||||
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502);
|
||||
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the single result out of a /set, turning JMAP's several failure shapes
|
||||
* into one error carrying whatever the server was willing to explain.
|
||||
*/
|
||||
function setResult(res: { methodResponses?: [string, unknown, string][] }, kind: "created" | "updated" | "destroyed"): Record<string, unknown> | null {
|
||||
const [name, args] = res.methodResponses?.[0] ?? [];
|
||||
if (!name) throw new AccountError("The mail server sent no response.", 502, "upstream");
|
||||
if (name === "error") {
|
||||
const err = args as { type?: string; description?: string };
|
||||
if (err.type === "unknownMethod") {
|
||||
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
||||
}
|
||||
throw new AccountError(err.description ?? `The mail server refused the request (${err.type ?? "error"}).`, 502, err.type ?? "upstream");
|
||||
}
|
||||
const body = args as Record<string, Record<string, unknown> | undefined>;
|
||||
const notKind = kind === "created" ? "notCreated" : kind === "updated" ? "notUpdated" : "notDestroyed";
|
||||
const failures = body[notKind];
|
||||
const failure = failures && Object.values(failures)[0];
|
||||
if (failure) {
|
||||
const err = failure as { type?: string; description?: string; properties?: string[] };
|
||||
throw new AccountError(describeSetError(err), err.type === "forbidden" ? 403 : 400, err.type ?? "invalid");
|
||||
}
|
||||
const ok = body[kind];
|
||||
return ok ? ((Object.values(ok)[0] ?? {}) as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function describeSetError(err: { type?: string; description?: string; properties?: string[] }): string {
|
||||
if (err.description) return err.description;
|
||||
if (err.type === "forbidden") return "The mail server refused the change.";
|
||||
if (err.type === "overQuota") return "You have reached the number of app passwords this account allows.";
|
||||
if (err.type === "invalidProperties") {
|
||||
return err.properties?.length ? `The mail server rejected ${err.properties.join(", ")}.` : "The mail server rejected the value.";
|
||||
}
|
||||
return `The mail server refused the change (${err.type ?? "error"}).`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Operations */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function getState(ctx: Ctx): Promise<SecurityState> {
|
||||
const id = accountId(ctx);
|
||||
const res = await jmap(ctx, [
|
||||
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
|
||||
["x:AppPassword/get", { accountId: id, ids: null }, "a"],
|
||||
]);
|
||||
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
|
||||
const apps = listOf(res, "a");
|
||||
return {
|
||||
// The URL itself is masked; its presence is what tells us 2FA is on.
|
||||
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
|
||||
appPasswords: apps.map((a) => ({
|
||||
id: String(a.id ?? ""),
|
||||
description: String(a.description ?? "App password"),
|
||||
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
|
||||
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listOf(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown>[] {
|
||||
const call = res.methodResponses?.find((r) => r[2] === callId);
|
||||
if (!call || call[0] === "error") return [];
|
||||
const list = (call[1] as { list?: unknown }).list;
|
||||
return Array.isArray(list) ? (list as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown> | null {
|
||||
return listOf(res, callId)[0] ?? null;
|
||||
}
|
||||
|
||||
export async function changePassword(ctx: Ctx, opts: { current: string; next: string; otpCode?: string }): Promise<void> {
|
||||
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
||||
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
||||
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
||||
setResult(res, "updated");
|
||||
}
|
||||
|
||||
export async function createAppPassword(ctx: Ctx, opts: { description: string }): Promise<{ id: string; secret: string }> {
|
||||
const description = opts.description.trim() || "App password";
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
|
||||
const created = setResult(res, "created");
|
||||
const secret = created && typeof created.secret === "string" ? created.secret : "";
|
||||
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
|
||||
return { id: String(created?.id ?? description), secret };
|
||||
}
|
||||
|
||||
export async function revokeAppPassword(ctx: Ctx, id: string): Promise<void> {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
||||
setResult(res, "destroyed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start enrolment: mint a secret and hand back the URL to show as a QR code.
|
||||
* Nothing is stored until the user proves they can produce a code from it.
|
||||
*/
|
||||
export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
|
||||
const secret = generateSecret();
|
||||
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the user can produce a code from the secret they just scanned.
|
||||
*
|
||||
* Stalwart validates the credentials already on the account and never looks at
|
||||
* the new secret, so without this an authenticator that was mistyped or out of
|
||||
* step would lock the user out of their mailbox at the next sign-in.
|
||||
*/
|
||||
export function assertEnrolmentCode(url: string, code: string): void {
|
||||
const params = parseOtpauthUrl(url);
|
||||
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
|
||||
if (!verifyTotp(params, code)) {
|
||||
throw new AccountError("That code doesn't match. Check your authenticator app and try the next code.", 400, "bad_code");
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableOtp(ctx: Ctx, opts: { url: string; code: string; current: string }): Promise<void> {
|
||||
assertEnrolmentCode(opts.url, opts.code);
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
}
|
||||
|
||||
export async function disableOtp(ctx: Ctx, opts: { current: string; code: string }): Promise<void> {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{
|
||||
accountId: accountId(ctx),
|
||||
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
|
||||
},
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
}
|
||||
|
||||
export { MASKED };
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getAccountInfo, hasStalwartRegistry, interpretAccountInfo } from "./upstream.js";
|
||||
|
||||
/**
|
||||
* The account locale used to be read only from `x:Account/get`, which needs
|
||||
* the `sysAccountGet` permission — one the built-in `user` role is not given.
|
||||
* Ordinary users therefore silently fell back to the browser locale. Stalwart
|
||||
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
|
||||
* so both are asked for and whichever answers wins. Both are 0.16 methods:
|
||||
* this is a permissions fallback, not a version one.
|
||||
*/
|
||||
|
||||
type Responses = [string, Record<string, unknown>, string][];
|
||||
|
||||
const settingsOk = (locale: string): Responses[number] => ["x:AccountSettings/get", { list: [{ id: "singleton", locale }] }, "s"];
|
||||
const accountOk = (locale: string): Responses[number] => ["x:Account/get", { list: [{ id: "a1", locale }] }, "a"];
|
||||
const failed = (id: string, type: string): Responses[number] => ["error", { type }, id];
|
||||
|
||||
test("prefers the locale a regular user is allowed to read", () => {
|
||||
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
|
||||
assert.equal(info.locale, "de-DE");
|
||||
});
|
||||
|
||||
test("falls back to x:Account when the settings object is forbidden", () => {
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), accountOk("sr_RS@latin")]);
|
||||
assert.equal(info.locale, "sr-Latn-RS");
|
||||
});
|
||||
|
||||
test("an account with no locale set yields none, rather than a guess", () => {
|
||||
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("neither answering leaves the locale unknown", () => {
|
||||
assert.deepEqual(interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]), { locale: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, edition: null });
|
||||
});
|
||||
|
||||
test("locales that carry no language are dropped, not passed through", () => {
|
||||
assert.equal(interpretAccountInfo([settingsOk("C")]).locale, null);
|
||||
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
|
||||
});
|
||||
|
||||
test("a server without the registry is not asked for anything", async () => {
|
||||
// Sign-in refuses these, so getAccountInfo should never reach the wire for
|
||||
// one - and must not, since a server that cannot parse `urn:stalwart:jmap`
|
||||
// fails the whole request rather than the one call.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-unsupported", "Basic x", session as never);
|
||||
assert.deepEqual(info, { locale: null, edition: null });
|
||||
});
|
||||
|
||||
test("no capabilities at all is treated the same way", async () => {
|
||||
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
/**
|
||||
* Where Stalwart actually advertises `urn:stalwart:jmap`.
|
||||
*
|
||||
* Not in the session-level `capabilities`: `Session::new` builds those from a
|
||||
* fixed list that has never carried this capability, in any 0.16.x. It is
|
||||
* handed out per-account instead, so it lands in `primaryAccounts` and in each
|
||||
* account's `accountCapabilities`. Looking only at the session level called
|
||||
* every real 0.16 server too old, which sent self-service credentials to a
|
||||
* REST endpoint 0.16 had removed and made the About page report the wrong
|
||||
* thing.
|
||||
*
|
||||
* This check now decides whether a sign-in is allowed at all, so getting it
|
||||
* wrong would lock every user out of a perfectly good server.
|
||||
*/
|
||||
const STALWART = "urn:stalwart:jmap";
|
||||
const baseCaps = { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} };
|
||||
|
||||
test("a 0.16 server is recognised from primaryAccounts, where it advertises itself", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({ capabilities: baseCaps, accounts: {}, primaryAccounts: { [STALWART]: "a1" } }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a 0.16 server is recognised from an account's capabilities", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({
|
||||
capabilities: baseCaps,
|
||||
accounts: { a1: { accountCapabilities: { "urn:ietf:params:jmap:mail": {}, [STALWART]: {} } } },
|
||||
primaryAccounts: {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("the session level still counts, for a server that ever advertises it there", () => {
|
||||
assert.equal(hasStalwartRegistry({ capabilities: { ...baseCaps, [STALWART]: {} }, accounts: {}, primaryAccounts: {} }), true);
|
||||
});
|
||||
|
||||
test("a server that advertises it nowhere is one we do not support", () => {
|
||||
assert.equal(hasStalwartRegistry({ capabilities: baseCaps, accounts: { a1: { accountCapabilities: baseCaps } }, primaryAccounts: { "urn:ietf:params:jmap:mail": "a1" } }), false);
|
||||
assert.equal(hasStalwartRegistry(undefined), false);
|
||||
});
|
||||
|
||||
test("a shared account carrying the capability is enough to recognise the server", () => {
|
||||
assert.equal(
|
||||
hasStalwartRegistry({
|
||||
capabilities: baseCaps,
|
||||
accounts: { a1: { accountCapabilities: baseCaps }, a2: { accountCapabilities: { [STALWART]: {} } } },
|
||||
primaryAccounts: {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
test("CSRF guard rejects API POSTs without the custom header", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
test("unauthenticated JMAP calls are rejected", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/api/jmap", { method: "POST", headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("cross-site fetches are rejected", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/api/health", { headers: { "sec-fetch-site": "cross-site" } });
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
test("health and security headers", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/api/health");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.equal(res.headers.get("x-frame-options"), "DENY");
|
||||
});
|
||||
|
||||
test("image proxy refuses private targets", async () => {
|
||||
const app = createApp();
|
||||
// no session -> 401 first; so exercise the handler directly via a logged-in-less path is not possible; check the URL validation ordering instead
|
||||
const res = await app.request("/api/image?url=http://127.0.0.1/x");
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
|
||||
const { forwardedContentLength } = await import("./app.js");
|
||||
// gzip: the body we forward has already been decompressed, so the length on
|
||||
// the wire describes different bytes and must not be copied (issue #76).
|
||||
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
|
||||
assert.equal(forwardedContentLength(gz), null);
|
||||
// identity, spelled out or absent: the length describes the body we send.
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
|
||||
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
|
||||
// Nothing to forward is not an error.
|
||||
assert.equal(forwardedContentLength(new Headers()), null);
|
||||
});
|
||||
|
||||
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
|
||||
const http = await import("node:http");
|
||||
const zlib = await import("node:zlib");
|
||||
const { forwardedContentLength } = await import("./app.js");
|
||||
|
||||
const script =
|
||||
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
|
||||
["a", "b", "c"]
|
||||
.map(
|
||||
(k) =>
|
||||
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
|
||||
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
|
||||
)
|
||||
.join("");
|
||||
const gz = zlib.gzipSync(Buffer.from(script));
|
||||
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
|
||||
|
||||
// A hop that compresses regardless of what we asked for.
|
||||
const origin = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
|
||||
res.end(gz);
|
||||
});
|
||||
await new Promise<void>((r) => origin.listen(0, () => r()));
|
||||
const port = (origin.address() as { port: number }).port;
|
||||
|
||||
try {
|
||||
const up = await fetch(`http://127.0.0.1:${port}/`);
|
||||
// What the blob route forwards.
|
||||
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
|
||||
const cl = forwardedContentLength(up.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
|
||||
assert.equal(out.headers.get("content-length"), null);
|
||||
assert.equal(await out.text(), script);
|
||||
} finally {
|
||||
origin.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,711 @@
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
import {
|
||||
type AccountInfo,
|
||||
UpstreamError,
|
||||
absoluteUpstream,
|
||||
expandTemplate,
|
||||
fetchUpstreamSession,
|
||||
hasStalwartRegistry,
|
||||
forgetUpstreamSession,
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
import {
|
||||
AccountError,
|
||||
assertEnrolmentCode,
|
||||
beginOtpEnrolment,
|
||||
changePassword,
|
||||
createAppPassword,
|
||||
disableOtp,
|
||||
enableOtp,
|
||||
getState,
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
export const sessions: SessionBackend = new SessionStore(config.sessionFile);
|
||||
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
|
||||
/**
|
||||
* Credential changes verify the current password upstream, and Stalwart's
|
||||
* fail2ban counts those failures against the *caller's* IP — which for a proxy
|
||||
* is shared by every user. Keep our own lid on it so one person guessing
|
||||
* cannot get the whole deployment banned.
|
||||
*/
|
||||
const accountLimiter = new RateLimiter(10, 15 * 60_000);
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
]);
|
||||
|
||||
export function clientIp(c: Context): string {
|
||||
let peer = "unknown";
|
||||
try {
|
||||
peer = getConnInfo(c).remote.address ?? "unknown";
|
||||
} catch {
|
||||
/* no socket information available */
|
||||
}
|
||||
return resolveClientIp(peer, { forwardedFor: c.req.header("x-forwarded-for"), realIp: c.req.header("x-real-ip") }, config);
|
||||
}
|
||||
|
||||
function isSecureRequest(c: Context): boolean {
|
||||
if (config.secureCookies === "1" || config.secureCookies === "true") return true;
|
||||
if (config.secureCookies === "0" || config.secureCookies === "false") return false;
|
||||
if (config.trustProxy) {
|
||||
const proto = c.req.header("x-forwarded-proto");
|
||||
if (proto) return proto.split(",")[0]!.trim() === "https";
|
||||
}
|
||||
return new URL(c.req.url).protocol === "https:";
|
||||
}
|
||||
|
||||
/** Security headers for every response. */
|
||||
const securityHeaders: MiddlewareHandler = async (c, next) => {
|
||||
await next();
|
||||
const h = c.res.headers;
|
||||
h.set("X-Content-Type-Options", "nosniff");
|
||||
h.set("X-Frame-Options", "DENY");
|
||||
h.set("Referrer-Policy", "no-referrer");
|
||||
h.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
||||
h.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||
if (!h.has("Cache-Control")) h.set("Cache-Control", "no-store");
|
||||
if (isSecureRequest(c)) h.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
};
|
||||
|
||||
/** CSRF: require our custom header on all API calls; reject cross-site fetches. */
|
||||
const csrfGuard: MiddlewareHandler = async (c, next) => {
|
||||
const site = c.req.header("sec-fetch-site");
|
||||
if (site && site !== "same-origin" && site !== "none") {
|
||||
return c.json({ error: "cross_site_request" }, 403);
|
||||
}
|
||||
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
||||
if (c.req.header("x-requested-with") !== "ihasmail") {
|
||||
return c.json({ error: "missing_csrf_header" }, 403);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
||||
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
||||
const cookie = getCookie(c, config.cookieName);
|
||||
const session = sessions.resolve(cookie);
|
||||
if (!session) {
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
c.set("session", session);
|
||||
await next();
|
||||
};
|
||||
|
||||
function setSessionCookie(c: Context, value: string, remember: boolean) {
|
||||
setCookie(c, config.cookieName, value, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure: isSecureRequest(c),
|
||||
path: "/",
|
||||
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function upstreamFailure(c: Context, err: unknown) {
|
||||
if (err instanceof UpstreamError) {
|
||||
return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502);
|
||||
}
|
||||
const name = (err as Error)?.name ?? "";
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time" }, 504);
|
||||
}
|
||||
console.error("[ihasmail] upstream failure:", err);
|
||||
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
|
||||
}
|
||||
|
||||
export function createApp(): Hono<Env> {
|
||||
const app = new Hono<Env>();
|
||||
app.use("*", securityHeaders);
|
||||
|
||||
const api = new Hono<Env>();
|
||||
api.use("*", csrfGuard);
|
||||
|
||||
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: config.version }));
|
||||
|
||||
api.get("/config", (c) =>
|
||||
c.json({
|
||||
appName: config.appName,
|
||||
sourceUrl: config.sourceUrl,
|
||||
imageProxy: config.imageProxy,
|
||||
maxUploadBytes: config.maxUploadBytes,
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------- Auth ----------
|
||||
api.post("/auth/login", async (c) => {
|
||||
const ip = clientIp(c);
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "bad_request" }, 400);
|
||||
}
|
||||
const username = (body.username ?? "").trim();
|
||||
const password = body.password ?? "";
|
||||
const totp = (body.totp ?? "").trim();
|
||||
if (!username || !password) return c.json({ error: "missing_credentials" }, 400);
|
||||
if (username.length > 320 || password.length > 1024) return c.json({ error: "bad_request" }, 400);
|
||||
|
||||
const limitKey = `${ip}|${username.toLowerCase()}`;
|
||||
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
|
||||
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
|
||||
// Stalwart accepts TOTP codes appended to the password as "password$123456".
|
||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
||||
try {
|
||||
const upstream = await fetchUpstreamSession(authorization);
|
||||
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
|
||||
// clearly, rather than signing someone in and letting Files, the account
|
||||
// locale and self-service credentials each fail in their own way with
|
||||
// nothing to connect them. The credentials were good, so say so.
|
||||
if (!hasStalwartRegistry(upstream)) {
|
||||
return c.json(
|
||||
{
|
||||
error: "unsupported_server",
|
||||
message:
|
||||
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
|
||||
},
|
||||
501,
|
||||
);
|
||||
}
|
||||
loginLimiter.reset(limitKey);
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
password: effectivePassword,
|
||||
remember: Boolean(body.remember),
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip,
|
||||
});
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
// A rejected sign-in that carried a two-factor code is worth explaining
|
||||
// rather than calling "invalid credentials", because the credentials are
|
||||
// very likely fine.
|
||||
//
|
||||
// Stalwart accepts a TOTP code only through an OAuth flow -- its own web
|
||||
// interface is an OAuth client, which is why signing in there works. It
|
||||
// offers no password grant, so a client holding a username and password
|
||||
// cannot exchange them plus a code for a token, and the concatenated
|
||||
// `password$code` form ihasmail sent is not a route the server has. Its
|
||||
// documented answer for clients like this one is an app password, which
|
||||
// bypasses TOTP entirely.
|
||||
//
|
||||
// ihasmail already relies on that elsewhere: turning 2FA *on* mints an
|
||||
// app password and moves the session onto it, precisely because a plain
|
||||
// password stops working from that moment. The sign-in page was the one
|
||||
// place still pretending otherwise.
|
||||
if (totp && err instanceof UpstreamError && err.status === 401) {
|
||||
return c.json(
|
||||
{
|
||||
error: "totp_unsupported",
|
||||
message:
|
||||
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.get("/auth/session", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/auth/logout", async (c) => {
|
||||
const cookie = getCookie(c, config.cookieName);
|
||||
const session = sessions.resolve(cookie);
|
||||
if (session) {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
api.get("/auth/sessions", requireSession, (c) => {
|
||||
const session = c.get("session");
|
||||
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
|
||||
});
|
||||
|
||||
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
||||
const session = c.get("session");
|
||||
const n = sessions.destroyAllForUser(session.username, session.id);
|
||||
return c.json({ revoked: n });
|
||||
});
|
||||
|
||||
// ---------- Self-service credentials ----------
|
||||
/**
|
||||
* Password, app passwords and 2FA. These live on the server rather than in
|
||||
* the browser because changing a credential means re-sealing the session
|
||||
* cookie that holds it, and because the browser only ever sees /api/jmap.
|
||||
*/
|
||||
const accountCtx = async (c: Context<Env>) => {
|
||||
const session = c.get("session");
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
return { authorization: session.authorization, session: upstream, username: session.username };
|
||||
};
|
||||
|
||||
const accountFailure = (c: Context, err: unknown) => {
|
||||
if (err instanceof AccountError) {
|
||||
return c.json({ error: err.code, message: err.message }, err.status as 400);
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
};
|
||||
|
||||
/** Guard the endpoints that check a password against brute-forcing. */
|
||||
const guarded = (c: Context<Env>): Response | null => {
|
||||
const key = `account|${c.get("session").username.toLowerCase()}`;
|
||||
if (accountLimiter.check(key)) return null;
|
||||
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
||||
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
||||
};
|
||||
|
||||
api.get("/account/security", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
return c.json(await getState(await accountCtx(c)));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/password", requireSession, async (c) => {
|
||||
const limited = guarded(c);
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ current?: string; next?: string; otpCode?: string }>(c);
|
||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||
const current = body.current ?? "";
|
||||
const next = body.next ?? "";
|
||||
if (!current || !next) return c.json({ error: "missing_fields", message: "Both passwords are required." }, 400);
|
||||
if (next.length > 1024) return c.json({ error: "bad_request" }, 400);
|
||||
if (next === current) {
|
||||
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
|
||||
}
|
||||
try {
|
||||
await changePassword(await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
// The old password is now dead: re-seal this session with the new one and
|
||||
// drop the others, whose sealed copies would fail on their next call.
|
||||
const otpCode = body.otpCode?.trim();
|
||||
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
||||
forgetUpstreamSession(session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
return c.json({ ok: true, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
api.get("/account/app-passwords", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
const state = await getState(await accountCtx(c));
|
||||
return c.json({ appPasswords: state.appPasswords });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/app-passwords", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ description?: string }>(c);
|
||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||
const description = (body.description ?? "").trim().slice(0, 120);
|
||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||
try {
|
||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/app-passwords/revoke", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ id?: string }>(c);
|
||||
if (!body?.id) return c.json({ error: "bad_request" }, 400);
|
||||
try {
|
||||
await revokeAppPassword(await accountCtx(c), body.id);
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/2fa/begin", requireSession, async (c) => {
|
||||
try {
|
||||
// Nothing is stored yet; the client hands the URL back to confirm.
|
||||
return c.json(beginOtpEnrolment(await accountCtx(c)));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/2fa/enable", requireSession, async (c) => {
|
||||
const limited = guarded(c);
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ url?: string; code?: string; current?: string }>(c);
|
||||
if (!body?.url || !body.code || !body.current) return c.json({ error: "bad_request" }, 400);
|
||||
const ctx = await accountCtx(c);
|
||||
const code = body.code.trim();
|
||||
/*
|
||||
* Every proxied call re-authenticates with the stored password, and once
|
||||
* 2FA is on the server wants a fresh TOTP code alongside it — which we
|
||||
* cannot produce between requests. An app password authenticates without
|
||||
* one, so the session moves onto a dedicated app password rather than
|
||||
* being signed out the moment 2FA is switched on.
|
||||
*
|
||||
* Order matters: mint it while the current credential still works, since
|
||||
* the moment 2FA is enabled this session can no longer authenticate at all.
|
||||
*/
|
||||
try {
|
||||
assertEnrolmentCode(body.url, code);
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
let app: { id: string; secret: string } | null = null;
|
||||
try {
|
||||
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
||||
} catch (err) {
|
||||
// Out of app-password quota, say. 2FA is still worth having; the user
|
||||
// just has to sign in again afterwards.
|
||||
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await enableOtp(ctx, { url: body.url, code, current: body.current });
|
||||
} catch (err) {
|
||||
if (app) {
|
||||
// Don't leave a credential behind for a change that never happened.
|
||||
await revokeAppPassword(ctx, app.id).catch(() => {});
|
||||
}
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
let sessionKept = false;
|
||||
if (app) {
|
||||
sessionKept = sessions.reseal(getCookie(c, config.cookieName), app.secret);
|
||||
if (sessionKept) forgetUpstreamSession(session.id);
|
||||
}
|
||||
// Other sessions still hold the bare password and will be refused.
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
api.post("/account/2fa/disable", requireSession, async (c) => {
|
||||
const limited = guarded(c);
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ current?: string; code?: string }>(c);
|
||||
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
|
||||
try {
|
||||
await disableOtp(await accountCtx(c), { current: body.current, code: body.code.trim() });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
// This session may be running on the app password minted when 2FA went on;
|
||||
// the plain password works again now, so put it back.
|
||||
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
||||
forgetUpstreamSession(session.id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---------- JMAP API proxy ----------
|
||||
api.post("/jmap", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
const ct = c.req.header("content-type") ?? "";
|
||||
if (!ct.toLowerCase().startsWith("application/json")) {
|
||||
return c.json({ error: "unsupported_media_type" }, 415);
|
||||
}
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: session.authorization,
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
},
|
||||
body: c.req.raw.body,
|
||||
duplex: "half",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
deleteCookie(c, config.cookieName, { path: "/" });
|
||||
return c.json({ error: "unauthenticated" }, 401);
|
||||
}
|
||||
return passthrough(res);
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Blob upload ----------
|
||||
api.post("/upload/:accountId", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
const accountId = c.req.param("accountId");
|
||||
const len = Number(c.req.header("content-length") ?? "0");
|
||||
if (len > config.maxUploadBytes) return c.json({ error: "too_large" }, 413);
|
||||
// content-length is absent on a chunked request, so the header alone is a
|
||||
// suggestion; count the bytes as they go past.
|
||||
const body = c.req.raw.body ? c.req.raw.body.pipeThrough(byteCap(config.maxUploadBytes)) : null;
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }));
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: session.authorization,
|
||||
"content-type": c.req.header("content-type") ?? "application/octet-stream",
|
||||
accept: "application/json",
|
||||
},
|
||||
body,
|
||||
duplex: "half",
|
||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||
});
|
||||
return passthrough(res);
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Blob download ----------
|
||||
api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
const { accountId, blobId, name } = c.req.param();
|
||||
const accept = c.req.query("accept") ?? "application/octet-stream";
|
||||
const inline = c.req.query("inline") === "1";
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
|
||||
const res = await fetch(url, {
|
||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||
// on our behalf and hand back a decompressed body whose content-length
|
||||
// header still describes the compressed one -- see forwardedContentLength.
|
||||
headers: { authorization: session.authorization, "accept-encoding": "identity" },
|
||||
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
||||
});
|
||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||
const headers = new Headers();
|
||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||
headers.set("Content-Type", type);
|
||||
const cl = forwardedContentLength(res.headers);
|
||||
if (cl) headers.set("Content-Length", cl);
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||
);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
||||
if (!(safeInline && type === "application/pdf")) {
|
||||
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
||||
}
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
return new Response(res.body, { status: 200, headers });
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Push (Server-Sent Events) ----------
|
||||
api.get("/events", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
const types = c.req.query("types") ?? "*";
|
||||
const closeafter = c.req.query("closeafter") ?? "no";
|
||||
const ping = c.req.query("ping") ?? "30";
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }));
|
||||
const controller = new AbortController();
|
||||
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
||||
const res = await fetch(url, {
|
||||
headers: { authorization: session.authorization, accept: "text/event-stream" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) return c.json({ error: "upstream_error" }, 502);
|
||||
const headers = new Headers({
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
return new Response(res.body, { status: 200, headers });
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Remote image privacy proxy ----------
|
||||
api.get("/image", requireSession, imageProxyHandler);
|
||||
|
||||
api.notFound((c) => c.json({ error: "not_found" }, 404));
|
||||
api.onError((err, c) => {
|
||||
console.error("[ihasmail] api error:", err);
|
||||
return c.json({ error: "internal_error" }, 500);
|
||||
});
|
||||
|
||||
app.route("/api", api);
|
||||
|
||||
// ---------- Static SPA ----------
|
||||
app.get("*", staticHandler(config.staticDir));
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Fail a stream that runs past `max` bytes, whatever its headers claimed. */
|
||||
function byteCap(max: number): TransformStream<Uint8Array, Uint8Array> {
|
||||
let total = 0;
|
||||
return new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
total += chunk.byteLength;
|
||||
if (total > max) controller.error(new Error("upload too large"));
|
||||
else controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function readJson<T>(c: Context): Promise<T | null> {
|
||||
try {
|
||||
return (await c.req.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Name the app password after the browser it will live in. */
|
||||
function appPasswordName(c: Context): string {
|
||||
const ua = c.req.header("user-agent") ?? "";
|
||||
const browser = /Firefox\//.test(ua) ? "Firefox" : /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "browser";
|
||||
return `${config.appName} (${browser})`;
|
||||
}
|
||||
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
|
||||
return {
|
||||
ihasmail: {
|
||||
appName: config.appName,
|
||||
sourceUrl: config.sourceUrl,
|
||||
imageProxy: config.imageProxy,
|
||||
maxUploadBytes: config.maxUploadBytes,
|
||||
sessionId: session.id,
|
||||
loginName: session.username,
|
||||
remember: session.remember,
|
||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||
userLocale: info.locale,
|
||||
/** What the upstream server would tell us about itself. */
|
||||
server: { edition: info.edition },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers worth relaying from the mail server. An allowlist rather than a
|
||||
* denylist: everything else it might set — cookies, auth challenges, CORS
|
||||
* grants — would be landing on *our* origin, where it means something else.
|
||||
*/
|
||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||
|
||||
function passthrough(res: Response): Response {
|
||||
const headers = new Headers();
|
||||
res.headers.forEach((v, k) => {
|
||||
if (PASSTHROUGH_HEADERS.has(k.toLowerCase())) headers.set(k, v);
|
||||
});
|
||||
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
headers.set("Cache-Control", "no-store");
|
||||
return new Response(res.body, { status: res.status, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream content-length, but only when it describes the bytes we are
|
||||
* about to forward.
|
||||
*
|
||||
* A compressed response is decompressed for us before we ever see the body --
|
||||
* undici does it transparently -- while the content-length header is left
|
||||
* describing the *compressed* length. Copying it onto the longer body we then
|
||||
* send makes the browser stop reading exactly that many bytes in and call the
|
||||
* download complete, so the file arrives silently truncated.
|
||||
*
|
||||
* That is the second half of issue #76. A hop in front of Stalwart compressed
|
||||
* responses over 1 KiB, so a Sieve script stayed intact until the third rule
|
||||
* pushed it past the threshold and it came back cut off mid-rule. Nothing
|
||||
* reported an error: the script parsed, just with rules missing, and saving
|
||||
* wrote that shortened version back over the real one.
|
||||
*
|
||||
* We ask for `identity` above so the usual case still carries a length the
|
||||
* browser can show progress against; this is the guard for a hop that
|
||||
* compresses anyway.
|
||||
*/
|
||||
export function forwardedContentLength(headers: Headers): string | null {
|
||||
const encoding = headers.get("content-encoding")?.trim().toLowerCase();
|
||||
if (encoding && encoding !== "identity") return null;
|
||||
return headers.get("content-length");
|
||||
}
|
||||
|
||||
function sanitizeContentType(ct: string): string {
|
||||
const lower = ct.split(";")[0]!.trim().toLowerCase();
|
||||
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
|
||||
if (
|
||||
lower === "text/html" ||
|
||||
lower === "application/xhtml+xml" ||
|
||||
lower === "image/svg+xml" ||
|
||||
lower.includes("javascript") ||
|
||||
lower === "text/xml" ||
|
||||
lower === "application/xml"
|
||||
) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
if (lower.startsWith("text/")) return `${lower}; charset=utf-8`;
|
||||
return lower || "application/octet-stream";
|
||||
}
|
||||
|
||||
function isInlineSafe(type: string): boolean {
|
||||
const t = type.split(";")[0]!.trim();
|
||||
return (
|
||||
(t.startsWith("image/") && t !== "image/svg+xml") ||
|
||||
t.startsWith("video/") ||
|
||||
t.startsWith("audio/") ||
|
||||
t === "application/pdf" ||
|
||||
t === "text/plain" ||
|
||||
t === "text/calendar" ||
|
||||
t === "text/vcard"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { inRange, isTrustedProxy, resolveClientIp } from "./clientip.js";
|
||||
|
||||
/**
|
||||
* The rate limiter keys on whatever this returns, so anything a client can
|
||||
* choose is a way to sidestep it. nginx's `$proxy_add_x_forwarded_for`
|
||||
* *appends*, so a client sending `X-Forwarded-For: 1.2.3.4` reaches us as
|
||||
* "1.2.3.4, <their real address>" — reading the leftmost entry hands them a
|
||||
* key they can change per request.
|
||||
*/
|
||||
|
||||
const cfg = { trustProxy: true, trustedProxies: [] as string[] };
|
||||
const direct = { trustProxy: false, trustedProxies: [] as string[] };
|
||||
|
||||
test("CIDR matching covers both families and single addresses", () => {
|
||||
assert.equal(inRange("10.1.2.3", "10.0.0.0/8"), true);
|
||||
assert.equal(inRange("11.1.2.3", "10.0.0.0/8"), false);
|
||||
assert.equal(inRange("172.16.5.4", "172.16.0.0/12"), true);
|
||||
assert.equal(inRange("172.32.5.4", "172.16.0.0/12"), false);
|
||||
assert.equal(inRange("127.0.0.1", "127.0.0.1"), true, "a bare address is a /32");
|
||||
assert.equal(inRange("::1", "::1/128"), true);
|
||||
assert.equal(inRange("fd00::5", "fc00::/7"), true);
|
||||
assert.equal(inRange("2001:db8::1", "fc00::/7"), false);
|
||||
assert.equal(inRange("10.1.2.3", "not-a-range"), false);
|
||||
assert.equal(inRange("10.1.2.3", "::1/128"), false, "families do not cross");
|
||||
});
|
||||
|
||||
test("loopback and private peers are trusted by default", () => {
|
||||
for (const p of ["127.0.0.1", "::1", "10.0.0.5", "172.17.0.1", "192.168.1.9", "fd00::2"]) {
|
||||
assert.equal(isTrustedProxy(p, cfg), true, p);
|
||||
}
|
||||
for (const p of ["8.8.8.8", "2001:db8::1"]) {
|
||||
assert.equal(isTrustedProxy(p, cfg), false, p);
|
||||
}
|
||||
});
|
||||
|
||||
test("the real client is taken from the right, not the left", () => {
|
||||
// What nginx produces when the client sent a forged header of their own.
|
||||
const ip = resolveClientIp("172.17.0.1", { forwardedFor: "1.2.3.4, 203.0.113.9" }, cfg);
|
||||
assert.equal(ip, "203.0.113.9", "the entry our own proxy observed");
|
||||
});
|
||||
|
||||
test("a forged chain cannot move the rate-limit key", () => {
|
||||
const forged = ["9.9.9.9", "8.8.8.8, 7.7.7.7", "203.0.113.1, 203.0.113.2, 203.0.113.3"];
|
||||
const seen = forged.map((f) => resolveClientIp("127.0.0.1", { forwardedFor: `${f}, 198.51.100.7` }, cfg));
|
||||
assert.deepEqual(seen, ["198.51.100.7", "198.51.100.7", "198.51.100.7"], "always the same real client");
|
||||
});
|
||||
|
||||
test("hops we run ourselves are skipped over", () => {
|
||||
// client → our edge proxy → our app proxy → us
|
||||
const ip = resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7, 10.0.0.2, 10.0.0.3" }, cfg);
|
||||
assert.equal(ip, "198.51.100.7");
|
||||
});
|
||||
|
||||
test("a peer we do not run is believed only about itself", () => {
|
||||
const ip = resolveClientIp("8.8.8.8", { forwardedFor: "1.2.3.4" }, cfg);
|
||||
assert.equal(ip, "8.8.8.8", "an untrusted peer cannot name its own client");
|
||||
});
|
||||
|
||||
test("forwarding headers are ignored entirely when the proxy is not trusted", () => {
|
||||
assert.equal(resolveClientIp("203.0.113.5", { forwardedFor: "1.2.3.4", realIp: "5.6.7.8" }, direct), "203.0.113.5");
|
||||
});
|
||||
|
||||
test("X-Real-IP is a fallback, never an override", () => {
|
||||
assert.equal(resolveClientIp("127.0.0.1", { realIp: "198.51.100.7" }, cfg), "198.51.100.7");
|
||||
assert.equal(
|
||||
resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7", realIp: "1.2.3.4" }, cfg),
|
||||
"198.51.100.7",
|
||||
"the chain wins where there is one",
|
||||
);
|
||||
});
|
||||
|
||||
test("junk in the chain is discarded rather than used as a key", () => {
|
||||
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "not-an-ip, 198.51.100.7" }, cfg), "198.51.100.7");
|
||||
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "not-an-ip" }, cfg), "127.0.0.1", "falls back to the peer");
|
||||
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "" }, cfg), "127.0.0.1");
|
||||
});
|
||||
|
||||
test("bracketed and IPv4-mapped forms are normalised", () => {
|
||||
assert.equal(resolveClientIp("::1", { forwardedFor: "[2001:db8::5]" }, cfg), "2001:db8::5");
|
||||
assert.equal(resolveClientIp("::1", { forwardedFor: "::ffff:198.51.100.7" }, cfg), "198.51.100.7");
|
||||
});
|
||||
|
||||
test("an explicit trusted list replaces the defaults", () => {
|
||||
const only = { trustProxy: true, trustedProxies: ["203.0.113.0/24"] };
|
||||
assert.equal(resolveClientIp("203.0.113.9", { forwardedFor: "198.51.100.7" }, only), "198.51.100.7");
|
||||
// Loopback is no longer trusted once a list is given.
|
||||
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "198.51.100.7" }, only), "127.0.0.1");
|
||||
});
|
||||
|
||||
test("a chain of nothing but our own proxies still yields an address", () => {
|
||||
assert.equal(resolveClientIp("127.0.0.1", { forwardedFor: "10.0.0.2, 10.0.0.3" }, cfg), "10.0.0.2");
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
/**
|
||||
* Work out who is really talking to us, for rate limiting and session records.
|
||||
*
|
||||
* `X-Forwarded-For` is a list that each hop appends to, so the entry nearest
|
||||
* the right is the one our own proxy observed and the entries to its left were
|
||||
* supplied by whoever came before — including the client. nginx's
|
||||
* `$proxy_add_x_forwarded_for` appends, so a client sending
|
||||
* `X-Forwarded-For: 1.2.3.4` arrives as `1.2.3.4, <their real address>`:
|
||||
* reading the leftmost entry hands an attacker a rate-limit key they can
|
||||
* change at will. Read from the right instead, skipping hops we run ourselves,
|
||||
* and only believe the header at all when the peer is a proxy we trust.
|
||||
*/
|
||||
|
||||
/** Peers whose forwarding headers are believed when none are configured. */
|
||||
const DEFAULT_TRUSTED = ["127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"];
|
||||
|
||||
export interface TrustConfig {
|
||||
trustProxy: boolean;
|
||||
/** CIDRs or bare addresses; empty means DEFAULT_TRUSTED. */
|
||||
trustedProxies: string[];
|
||||
}
|
||||
|
||||
function toBits(addr: string): { value: bigint; width: number } | null {
|
||||
const v = isIP(addr);
|
||||
if (v === 4) {
|
||||
const parts = addr.split(".").map(Number);
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
||||
return { value: parts.reduce((acc, n) => (acc << 8n) | BigInt(n), 0n), width: 32 };
|
||||
}
|
||||
if (v === 6) {
|
||||
// Expand "::" and any embedded IPv4 tail into eight 16-bit groups.
|
||||
let text = addr;
|
||||
const tail = /:(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
||||
if (tail) {
|
||||
const b = tail[1]!.split(".").map(Number);
|
||||
text = `${text.slice(0, tail.index)}:${((b[0]! << 8) | b[1]!).toString(16)}:${((b[2]! << 8) | b[3]!).toString(16)}`;
|
||||
}
|
||||
const [head, rest] = text.split("::");
|
||||
const left = head ? head.split(":").filter(Boolean) : [];
|
||||
const right = rest !== undefined ? (rest ? rest.split(":").filter(Boolean) : []) : null;
|
||||
const groups = right === null ? left : [...left, ...Array<string>(8 - left.length - right.length).fill("0"), ...right];
|
||||
if (groups.length !== 8) return null;
|
||||
let value = 0n;
|
||||
for (const g of groups) {
|
||||
const n = parseInt(g, 16);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 0xffff) return null;
|
||||
value = (value << 16n) | BigInt(n);
|
||||
}
|
||||
return { value, width: 128 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Is `addr` inside `range`, which may be a CIDR or a single address? */
|
||||
export function inRange(addr: string, range: string): boolean {
|
||||
const [net, bitsText] = range.trim().split("/");
|
||||
const a = toBits(addr);
|
||||
const n = toBits(net ?? "");
|
||||
if (!a || !n || a.width !== n.width) return false;
|
||||
const bits = bitsText === undefined ? n.width : Number(bitsText);
|
||||
if (!Number.isInteger(bits) || bits < 0 || bits > n.width) return false;
|
||||
if (bits === 0) return true;
|
||||
const shift = BigInt(n.width - bits);
|
||||
return a.value >> shift === n.value >> shift;
|
||||
}
|
||||
|
||||
export function isTrustedProxy(addr: string, cfg: TrustConfig): boolean {
|
||||
const ranges = cfg.trustedProxies.length ? cfg.trustedProxies : DEFAULT_TRUSTED;
|
||||
return ranges.some((r) => inRange(addr, r));
|
||||
}
|
||||
|
||||
export interface ForwardHeaders {
|
||||
forwardedFor?: string;
|
||||
realIp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client address to attribute a request to. `peer` is the socket address,
|
||||
* which is the only part nobody downstream can forge.
|
||||
*/
|
||||
export function resolveClientIp(peer: string, headers: ForwardHeaders, cfg: TrustConfig): string {
|
||||
if (!cfg.trustProxy || !peer || peer === "unknown") return peer || "unknown";
|
||||
// A peer we do not run is not allowed to tell us who its client is.
|
||||
if (!isTrustedProxy(peer, cfg)) return peer;
|
||||
const chain = (headers.forwardedFor ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^\[|\]$/g, "").replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/i, ""))
|
||||
.filter((s) => isIP(s) !== 0);
|
||||
// Rightmost first: the last hop we trust is ours, anything left of the first
|
||||
// untrusted entry was written by someone we have no reason to believe.
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
if (!isTrustedProxy(chain[i]!, cfg)) return chain[i]!;
|
||||
}
|
||||
if (chain.length) return chain[0]!;
|
||||
const real = headers.realIp?.trim();
|
||||
return real && isIP(real) !== 0 ? real : peer;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { assertImmutable } from "./config.js";
|
||||
|
||||
function tempRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), "ihasmail-immutable-"));
|
||||
}
|
||||
|
||||
test("IMMUTABLE refuses a configured SESSION_FILE", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
assert.throws(() => assertImmutable("/data/sessions.json", root), /SESSION_FILE is \/data\/sessions\.json/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("IMMUTABLE refuses a writable root, and leaves no probe behind", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
assert.throws(() => assertImmutable("", root), /is writable/);
|
||||
assert.equal(existsSync(join(root, ".immutable-probe")), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("IMMUTABLE accepts a root it cannot write to", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
chmodSync(root, 0o555);
|
||||
assert.doesNotThrow(() => assertImmutable("", root));
|
||||
} finally {
|
||||
chmodSync(root, 0o755);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { resolveVersion } from "../../scripts/version.mjs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/** Minimal .env loader (no dependency): first match wins, never overrides real env. */
|
||||
function loadDotEnv() {
|
||||
const candidates = [resolve(process.cwd(), ".env"), fileURLToPath(new URL("../../.env", import.meta.url)), fileURLToPath(new URL("../.env", import.meta.url))];
|
||||
for (const file of candidates) {
|
||||
if (!existsSync(file)) continue;
|
||||
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
||||
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
||||
if (!m || line.trim().startsWith("#")) continue;
|
||||
let v = m[2]!;
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
if (process.env[m[1]!] === undefined) process.env[m[1]!] = v;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
loadDotEnv();
|
||||
|
||||
function env(name: string, fallback?: string): string {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") {
|
||||
if (fallback === undefined) throw new Error(`Missing required environment variable ${name}`);
|
||||
return fallback;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function bool(name: string, fallback: boolean): boolean {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(v.toLowerCase());
|
||||
}
|
||||
|
||||
function int(name: string, fallback: number): number {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") return fallback;
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n)) throw new Error(`Invalid integer for ${name}: ${v}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
let appSecret = process.env.APP_SECRET ?? "";
|
||||
if (!appSecret || appSecret === "change-me") {
|
||||
if (isProd) {
|
||||
throw new Error("APP_SECRET must be set to a strong random value in production");
|
||||
}
|
||||
appSecret = randomBytes(32).toString("base64");
|
||||
console.warn(
|
||||
"[ihasmail] APP_SECRET not set - using an ephemeral secret (persisted sessions will not survive restarts)",
|
||||
);
|
||||
}
|
||||
|
||||
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* Declares that this instance is running as an immutable container: read-only
|
||||
* root filesystem, nothing durable of its own, replaceable by its image.
|
||||
*
|
||||
* It is a claim the process checks rather than one it takes on trust, because
|
||||
* the failure it guards against is silent. Left to itself the server survives
|
||||
* a read-only filesystem perfectly well -- sessions are held in memory and the
|
||||
* write is best-effort, so the only sign that `SESSION_FILE` is going nowhere
|
||||
* is one warning at the first login, long after anyone was watching. The
|
||||
* instance looks healthy right up until it is replaced and everyone is signed
|
||||
* out. Setting IMMUTABLE turns both halves of that into a refusal to start.
|
||||
*/
|
||||
const immutable = bool("IMMUTABLE", false);
|
||||
const sessionFile = process.env.SESSION_FILE ?? "";
|
||||
|
||||
/**
|
||||
* Refuse to run when the promise IMMUTABLE makes is not one this instance can
|
||||
* keep. Exported so it can be tested without a read-only filesystem to hand.
|
||||
*/
|
||||
export function assertImmutable(sessionFile: string, root: string): void {
|
||||
// The image sets SESSION_FILE=/data/sessions.json, so this is a deliberate
|
||||
// refusal rather than a formality: running immutably means clearing it. It
|
||||
// is not quietly ignored, because a configured path that silently persists
|
||||
// nothing is exactly the failure this flag exists to surface.
|
||||
if (sessionFile) {
|
||||
throw new Error(
|
||||
`IMMUTABLE is set, but SESSION_FILE is ${sessionFile}. An immutable instance keeps no durable state of its own: ` +
|
||||
"pass SESSION_FILE= (empty) to hold sessions in memory, or unset IMMUTABLE.",
|
||||
);
|
||||
}
|
||||
// And check the property itself, not just the intention to have it. Setting
|
||||
// the variable while forgetting `--read-only` is the easy mistake, and it
|
||||
// leaves an instance claiming a guarantee it does not have.
|
||||
const probe = resolve(root, ".immutable-probe");
|
||||
let writable = false;
|
||||
try {
|
||||
writeFileSync(probe, "");
|
||||
writable = true;
|
||||
unlinkSync(probe);
|
||||
} catch {
|
||||
/* EROFS, or EACCES on a root we do not own: either way, not writable by us */
|
||||
}
|
||||
if (writable) {
|
||||
throw new Error(
|
||||
`IMMUTABLE is set, but ${root} is writable. Run the container with --read-only (and --tmpfs /tmp), or unset IMMUTABLE.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
|
||||
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
/**
|
||||
* What this build calls itself: `2.16.57`. Set by the image build from
|
||||
* `--build-arg IHASMAIL_VERSION`, since `.dockerignore` keeps `.git` out of
|
||||
* the build context and nothing in there could work it out. A dev checkout
|
||||
* has git, so it falls back to asking; see `scripts/version.mjs`.
|
||||
*/
|
||||
version: resolveVersion(),
|
||||
/**
|
||||
* Where this instance's source can be had, shown to everyone who reaches it.
|
||||
*
|
||||
* The AGPL asks whoever *runs* a modified version to offer that version's
|
||||
* source, not the one it was forked from -- so anyone deploying a patched
|
||||
* ihasmail should point this at their own tree.
|
||||
*/
|
||||
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"),
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: int("PORT", 8080),
|
||||
stalwartUrl,
|
||||
appSecret,
|
||||
trustProxy: bool("TRUST_PROXY", true),
|
||||
/**
|
||||
* Peers whose X-Forwarded-* headers are believed. Empty falls back to
|
||||
* loopback and the private ranges, which covers the usual reverse proxy on
|
||||
* the same host or Docker network. A peer outside this is attributed by its
|
||||
* socket address whatever it claims.
|
||||
*/
|
||||
trustedProxies: (process.env.TRUSTED_PROXIES ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
||||
/** "auto" = Secure when the request arrived over https; "1"/"0" to force. */
|
||||
secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(),
|
||||
sessionTtl: int("SESSION_TTL", 12 * 60 * 60),
|
||||
sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60),
|
||||
sessionFile,
|
||||
/** True when this instance has asserted, and verified, that it is immutable. */
|
||||
immutable,
|
||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||
imageProxy: bool("IMAGE_PROXY", true),
|
||||
cookieName: env("COOKIE_NAME", "ihm_session"),
|
||||
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
||||
};
|
||||
|
||||
export type Config = typeof config;
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, request as httpRequest, type IncomingMessage, type Server } from "node:http";
|
||||
import { AddressInfo } from "node:net";
|
||||
|
||||
process.env.STALWART_URL = "http://127.0.0.1:1";
|
||||
process.env.APP_SECRET = "test-secret-for-image-proxy";
|
||||
|
||||
const { fetchPinned, isPrivateAddress } = await import("./imageproxy.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
/**
|
||||
* The proxy hides the reader from tracking pixels, so it fetches URLs a sender
|
||||
* chose — which makes it the one place in the app that will knock on any door
|
||||
* it is pointed at.
|
||||
*/
|
||||
|
||||
test("addresses we must never reach are recognised", () => {
|
||||
for (const a of [
|
||||
"127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1",
|
||||
"169.254.169.254", // cloud metadata, the classic SSRF target
|
||||
"100.64.0.1", "0.0.0.0", "224.0.0.1",
|
||||
"::1", "::", "fe80::1", "fd00::1", "fc00::1",
|
||||
"ff02::1", // multicast
|
||||
"::ffff:127.0.0.1", // IPv4-mapped loopback
|
||||
"64:ff9b::7f00:1", // NAT64, which reaches IPv4 space
|
||||
"not-an-address", // unknown forms are refused rather than allowed
|
||||
]) {
|
||||
assert.equal(isPrivateAddress(a), true, a);
|
||||
}
|
||||
for (const a of ["8.8.8.8", "1.1.1.1", "93.184.216.34", "172.32.0.1", "2001:db8::1"]) {
|
||||
assert.equal(isPrivateAddress(a), false, a);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The interesting half. Checking a name and then handing the *name* to a
|
||||
* fetching library leaves a gap: it resolves again when the socket opens, and
|
||||
* whoever controls the zone can answer differently the second time — the first
|
||||
* answer passes the check, the second points at localhost.
|
||||
*
|
||||
* Two servers on the same port at different addresses settle it without
|
||||
* depending on how this machine resolves anything: `localhost` reaches one of
|
||||
* them, and the pin has to reach the other.
|
||||
*/
|
||||
const PORT = 18811;
|
||||
const RESOLVED = "::1"; // what "localhost" gets you
|
||||
const PINNED = "127.0.0.2"; // somewhere only an explicit address reaches
|
||||
let viaName: Server;
|
||||
let viaPin: Server;
|
||||
|
||||
const identify = (name: string) =>
|
||||
createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "image/png" });
|
||||
res.end(name);
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
viaName = identify("reached-by-name");
|
||||
viaPin = identify("reached-by-pin");
|
||||
await new Promise<void>((r, j) => viaName.listen(PORT, RESOLVED, r).on("error", j));
|
||||
await new Promise<void>((r, j) => viaPin.listen(PORT, PINNED, r).on("error", j));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
viaName?.close();
|
||||
viaPin?.close();
|
||||
});
|
||||
|
||||
const read = async (res: IncomingMessage) => {
|
||||
res.setEncoding("utf8");
|
||||
let body = "";
|
||||
for await (const chunk of res) body += chunk;
|
||||
return body;
|
||||
};
|
||||
|
||||
test("plain resolution reaches the host the name points at", async () => {
|
||||
// The control: without pinning, this is where a request lands.
|
||||
const res = await new Promise<IncomingMessage>((resolve, reject) => {
|
||||
const req = httpRequest(`http://localhost:${PORT}/who`, resolve);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
assert.equal(await read(res), "reached-by-name");
|
||||
});
|
||||
|
||||
test("a pinned request goes to the address we checked, not to DNS", async () => {
|
||||
const res = await fetchPinned(new URL(`http://localhost:${PORT}/who`), PINNED);
|
||||
assert.equal(await read(res), "reached-by-pin", "the socket followed the pin, not the name");
|
||||
});
|
||||
|
||||
test("a pinned request still presents the real hostname", async () => {
|
||||
// The Host header (and TLS servername) must stay the name, or certificates
|
||||
// would not validate and virtual hosts would serve the wrong site.
|
||||
const seen = identify("");
|
||||
let host = "";
|
||||
seen.on("request", (req) => (host = String(req.headers.host)));
|
||||
await new Promise<void>((r) => seen.listen(0, "127.0.0.3", r));
|
||||
const p = (seen.address() as AddressInfo).port;
|
||||
const res = await fetchPinned(new URL(`http://example.test:${p}/who`), "127.0.0.3");
|
||||
await read(res);
|
||||
seen.close();
|
||||
assert.equal(host, `example.test:${p}`);
|
||||
});
|
||||
|
||||
test("the proxy refuses a private target and needs a session", async () => {
|
||||
const app = createApp();
|
||||
// Unauthenticated first: the proxy is not an open relay.
|
||||
const anon = await app.request("/api/image?url=http://127.0.0.1/x.png");
|
||||
assert.equal(anon.status, 401);
|
||||
});
|
||||
|
||||
test("the proxy rejects unusable URLs before resolving anything", async () => {
|
||||
const app = createApp();
|
||||
for (const u of ["file:///etc/passwd", "gopher://x/1", "http://user:[email protected]/x.png"]) {
|
||||
const res = await app.request(`/api/image?url=${encodeURIComponent(u)}`);
|
||||
// Still behind the session check, but the point is it never reaches the network.
|
||||
assert.equal(res.status, 401);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { request as httpRequest, type IncomingMessage } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { Readable } from "node:stream";
|
||||
import type { Context } from "hono";
|
||||
import { config } from "./config.js";
|
||||
|
||||
const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
|
||||
const UA = "Mozilla/5.0 (compatible; ihasmail-image-proxy)";
|
||||
|
||||
export function isPrivateAddress(addr: string): boolean {
|
||||
const v = isIP(addr);
|
||||
if (v === 4) {
|
||||
const [a, b] = addr.split(".").map(Number) as [number, number];
|
||||
if (a === 10 || a === 127 || a === 0) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
if (a >= 224) return true;
|
||||
return false;
|
||||
}
|
||||
if (v === 6) {
|
||||
const lower = addr.toLowerCase();
|
||||
if (lower === "::1" || lower === "::") return true;
|
||||
if (lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
||||
if (lower.startsWith("ff")) return true; // multicast
|
||||
if (lower.startsWith("::ffff:")) return isPrivateAddress(lower.slice(7));
|
||||
if (lower.startsWith("64:ff9b:")) return true; // NAT64, reaches IPv4 space
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class BlockedTarget extends Error {}
|
||||
|
||||
/**
|
||||
* Settle on one address for `hostname` and refuse it if it is somewhere we
|
||||
* should not be reaching.
|
||||
*/
|
||||
async function resolveAllowed(hostname: string): Promise<string> {
|
||||
const host = hostname.replace(/^\[|\]$/g, "");
|
||||
if (isIP(host)) {
|
||||
if (isPrivateAddress(host)) throw new BlockedTarget(host);
|
||||
return host;
|
||||
}
|
||||
const addrs = await lookup(host, { all: true });
|
||||
if (!addrs.length) throw new BlockedTarget(host);
|
||||
// Every answer has to be acceptable: one bad record is enough to mean the
|
||||
// name is not something we should be fetching at all.
|
||||
for (const a of addrs) if (isPrivateAddress(a.address)) throw new BlockedTarget(a.address);
|
||||
return addrs[0]!.address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch, connecting to `addr` rather than whatever DNS says at the moment the
|
||||
* socket opens.
|
||||
*
|
||||
* Checking a name and then handing the name to a fetching library leaves a gap:
|
||||
* the library resolves again, and an attacker who controls the zone can answer
|
||||
* differently the second time — the first answer passes the check, the second
|
||||
* points at localhost. Pinning the address closes the gap. TLS is unaffected:
|
||||
* the certificate is still validated against the hostname, which is what
|
||||
* `servername` and the Host header carry.
|
||||
*/
|
||||
export function fetchPinned(url: URL, addr: string, signal?: AbortSignal): Promise<IncomingMessage> {
|
||||
const family = isIP(addr) === 6 ? 6 : 4;
|
||||
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = send(
|
||||
url,
|
||||
{
|
||||
/*
|
||||
* Called instead of a real resolution, so the socket goes exactly where
|
||||
* we decided it should. Node asks for every address at once when it is
|
||||
* picking a family itself (autoSelectFamily), and for a single one
|
||||
* otherwise; answer in whichever shape was asked for.
|
||||
*/
|
||||
lookup: (_hostname: string, opts: { all?: boolean }, cb: (err: Error | null, address: string | { address: string; family: number }[], family?: number) => void) =>
|
||||
opts?.all ? cb(null, [{ address: addr, family }]) : cb(null, addr, family),
|
||||
servername: isIP(url.hostname) ? undefined : url.hostname,
|
||||
// A pooled socket is keyed by host and port, not by the address we
|
||||
// pinned, so a connection opened earlier would be reused and the pin
|
||||
// never consulted. Take a fresh socket every time.
|
||||
agent: false,
|
||||
headers: { accept: "image/avif,image/webp,image/*,*/*;q=0.8", "user-agent": UA, host: url.host },
|
||||
signal,
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gmail-style remote content proxy: hides the reader's IP address and
|
||||
* user-agent from tracking pixels, and blocks SSRF to internal networks.
|
||||
*/
|
||||
export async function imageProxyHandler(c: Context) {
|
||||
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
|
||||
const raw = c.req.query("url") ?? "";
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return c.json({ error: "bad_url" }, 400);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400);
|
||||
if (url.username || url.password) return c.json({ error: "bad_url" }, 400);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
let res: IncomingMessage;
|
||||
try {
|
||||
let addr: string;
|
||||
try {
|
||||
addr = await resolveAllowed(url.hostname);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
}
|
||||
res = await fetchPinned(url, addr, controller.signal);
|
||||
|
||||
// Follow a limited number of redirects, re-checking and re-pinning each hop.
|
||||
let hops = 0;
|
||||
while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) {
|
||||
const loc = res.headers.location;
|
||||
if (!loc) break;
|
||||
res.resume(); // discard the redirect body
|
||||
const next = new URL(loc, url);
|
||||
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "bad_redirect" }, 400);
|
||||
}
|
||||
try {
|
||||
addr = await resolveAllowed(next.hostname);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
}
|
||||
url = next;
|
||||
res = await fetchPinned(url, addr, controller.signal);
|
||||
hops++;
|
||||
}
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
}
|
||||
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
clearTimeout(timer);
|
||||
res.resume();
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
}
|
||||
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (!type.startsWith("image/") || type === "image/svg+xml") {
|
||||
clearTimeout(timer);
|
||||
res.resume();
|
||||
return c.json({ error: "not_image" }, 415);
|
||||
}
|
||||
const len = Number(res.headers["content-length"] ?? "0");
|
||||
if (len > MAX_IMAGE_BYTES) {
|
||||
clearTimeout(timer);
|
||||
res.resume();
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
|
||||
// Enforce the size limit while streaming.
|
||||
let total = 0;
|
||||
const limiter = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller2) {
|
||||
total += chunk.byteLength;
|
||||
if (total > MAX_IMAGE_BYTES) controller2.error(new Error("too large"));
|
||||
else controller2.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
res.on("close", () => clearTimeout(timer));
|
||||
const headers = new Headers({
|
||||
"Content-Type": type,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
});
|
||||
if (len) headers.set("Content-Length", String(len));
|
||||
const body = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>;
|
||||
return new Response(body.pipeThrough(limiter), { status: 200, headers });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { config } from "./config.js";
|
||||
import { createApp, sessions } from "./app.js";
|
||||
|
||||
async function main() {
|
||||
await sessions.init();
|
||||
const app = createApp();
|
||||
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
||||
console.log(`[ihasmail] ${config.appName} listening on http://${info.address}:${info.port}`);
|
||||
console.log(`[ihasmail] upstream Stalwart: ${config.stalwartUrl}`);
|
||||
console.log(`[ihasmail] static dir: ${config.staticDir}`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`[ihasmail] ${signal} received, shutting down`);
|
||||
server.close();
|
||||
await sessions.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[ihasmail] fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* ihasmail requires Stalwart 0.16 or newer. Sign-in is where that is enforced,
|
||||
* and it matters that it is enforced *there*: the alternative is signing
|
||||
* someone in and letting Files, the account locale and self-service
|
||||
* credentials each fail in their own way, with nothing to connect the three or
|
||||
* to say what the real problem is.
|
||||
*
|
||||
* The refusal also has to keep two things apart that look the same from the
|
||||
* outside. Bad credentials are a 401 the user can fix by typing again; an
|
||||
* unsupported server is not, and telling someone their password is wrong when
|
||||
* it is not would send them round in circles.
|
||||
*/
|
||||
|
||||
const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "demo-password";
|
||||
process.env.MOCK_NO_REGISTRY = "1"; // a server without urn:stalwart:jmap
|
||||
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-login-guard";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
|
||||
const app = createApp();
|
||||
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
async function login(body: unknown): Promise<{ status: number; body: any; setCookie: string | null }> {
|
||||
const res = await app.request("/api/auth/login", { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get("set-cookie") };
|
||||
}
|
||||
|
||||
before(() => {
|
||||
assert.equal(process.env.MOCK_NO_REGISTRY, "1");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("a server without the registry is refused, with good credentials", async () => {
|
||||
const res = await login({ username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.status, 501);
|
||||
assert.equal(res.body.error, "unsupported_server");
|
||||
});
|
||||
|
||||
test("the message says the credentials were fine, and names the way out", async () => {
|
||||
const { body } = await login({ username: "[email protected]", password: "demo-password" });
|
||||
// Someone hitting this has typed a correct password. Saying so is the
|
||||
// difference between "upgrade your server" and "try your password again".
|
||||
assert.match(body.message, /credentials are fine/i);
|
||||
assert.match(body.message, /0\.16/);
|
||||
assert.match(body.message, /stalwart-0\.15-support/, "the tag to build from if they cannot upgrade");
|
||||
});
|
||||
|
||||
test("no session is minted for a server we cannot talk to", async () => {
|
||||
// A cookie here would leave a signed-in session against a server every
|
||||
// other request is going to fail on.
|
||||
const res = await login({ username: "[email protected]", password: "demo-password" });
|
||||
assert.equal(res.setCookie, null);
|
||||
});
|
||||
|
||||
test("bad credentials on such a server are still a 401, not the server error", async () => {
|
||||
// The upstream session request fails first, and that answer is the honest
|
||||
// one: we never got far enough to learn what the server supports.
|
||||
const res = await login({ username: "[email protected]", password: "wrong-password" });
|
||||
assert.equal(res.status, 401);
|
||||
assert.notEqual(res.body.error, "unsupported_server");
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||
|
||||
const NOW = Date.parse("2026-08-24T12:00:00Z");
|
||||
const envelope = (parameters: Record<string, string> | null) => ({
|
||||
mailFrom: { email: "[email protected]", ...(parameters ? { parameters } : {}) },
|
||||
rcptTo: [{ email: "[email protected]" }],
|
||||
});
|
||||
|
||||
describe("FUTURERELEASE parameters", () => {
|
||||
it("reads HOLDUNTIL as an RFC 3339 date-time", () => {
|
||||
const at = holdUntilOf(envelope({ HOLDUNTIL: "2026-11-20T05:00:00Z" }), NOW);
|
||||
assert.equal(at, Date.parse("2026-11-20T05:00:00Z"));
|
||||
});
|
||||
|
||||
it("reads HOLDFOR as a count of seconds from now", () => {
|
||||
assert.equal(holdUntilOf(envelope({ HOLDFOR: "3600" }), NOW), NOW + 3_600_000);
|
||||
});
|
||||
|
||||
it("matches the parameter name whatever its case, as an SMTP parser does", () => {
|
||||
assert.equal(holdUntilOf(envelope({ holduntil: "2026-11-20T05:00:00Z" }), NOW), Date.parse("2026-11-20T05:00:00Z"));
|
||||
});
|
||||
|
||||
it("means send now when neither parameter is present", () => {
|
||||
assert.equal(holdUntilOf(envelope(null), NOW), null);
|
||||
assert.equal(holdUntilOf(envelope({}), NOW), null);
|
||||
assert.equal(holdUntilOf(undefined, NOW), null);
|
||||
});
|
||||
|
||||
it("refuses both parameters at once, as Stalwart does with a 501", () => {
|
||||
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "2026-11-20T05:00:00Z", HOLDFOR: "600" }), NOW)));
|
||||
});
|
||||
|
||||
it("refuses values that will not parse", () => {
|
||||
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "next tuesday" }), NOW)));
|
||||
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "soon" }), NOW)));
|
||||
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "0" }), NOW)));
|
||||
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDFOR: "-60" }), NOW)));
|
||||
});
|
||||
|
||||
it("accepts a Unix timestamp only as the date it is not", () => {
|
||||
// 0.16.16 briefly wanted seconds-since-epoch here; 0.16.17 restored RFC
|
||||
// 3339. A bare number must not be mistaken for a valid hold.
|
||||
assert.ok(Number.isNaN(holdUntilOf(envelope({ HOLDUNTIL: "1795000000" }), NOW)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("undoStatus", () => {
|
||||
const sub = (sendAt: string, undoStatus: string | null = null) => ({ sendAt, undoStatus });
|
||||
|
||||
it("is pending while the release time is still ahead", () => {
|
||||
assert.equal(undoStatusOf(sub("2026-11-20T05:00:00Z"), NOW), "pending");
|
||||
});
|
||||
|
||||
it("is final once the release time has passed", () => {
|
||||
assert.equal(undoStatusOf(sub("2026-08-24T11:59:59Z"), NOW), "final");
|
||||
assert.equal(undoStatusOf(sub("2026-08-24T12:00:00Z"), NOW), "final");
|
||||
});
|
||||
|
||||
it("stays canceled regardless of the clock", () => {
|
||||
assert.equal(undoStatusOf(sub("2026-11-20T05:00:00Z", "canceled"), NOW), "canceled");
|
||||
assert.equal(undoStatusOf(sub("2026-01-01T00:00:00Z", "canceled"), NOW), "canceled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* FUTURERELEASE (RFC 4865) as Stalwart applies it to a JMAP envelope.
|
||||
*
|
||||
* A client asks for a delayed send by putting `HOLDUNTIL` (a date-time) or
|
||||
* `HOLDFOR` (seconds) in the `mailFrom` parameters; Stalwart hands those to its
|
||||
* RFC 5321 parameter parser and derives `sendAt` from the result. `sendAt` is
|
||||
* never something the client sets. Kept apart from the mock server itself so
|
||||
* the rules can be tested without binding a port.
|
||||
*/
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
|
||||
/** Neither parameter given. */
|
||||
export const NO_HOLD = null;
|
||||
/** The parameters are contradictory or unparseable; the create must fail. */
|
||||
export const BAD_HOLD = NaN;
|
||||
|
||||
function lookup(params: Obj, name: string): string | undefined {
|
||||
const key = Object.keys(params).find((k) => k.toUpperCase() === name);
|
||||
return key === undefined ? undefined : String(params[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant an envelope asks to be released: null for "send it now", NaN for
|
||||
* parameters the server would refuse.
|
||||
*/
|
||||
export function holdUntilOf(envelope: Obj | undefined, now: number): number | null {
|
||||
const params = ((envelope?.mailFrom as Obj | undefined)?.parameters ?? {}) as Obj;
|
||||
const until = lookup(params, "HOLDUNTIL");
|
||||
const forSecs = lookup(params, "HOLDFOR");
|
||||
// "501 5.5.4 Only one of HOLDFOR or HOLDUNTIL may be specified."
|
||||
if (until !== undefined && forSecs !== undefined) return BAD_HOLD;
|
||||
if (until !== undefined) {
|
||||
const t = Date.parse(until);
|
||||
return Number.isNaN(t) ? BAD_HOLD : t;
|
||||
}
|
||||
if (forSecs !== undefined) {
|
||||
const secs = Number(forSecs);
|
||||
return Number.isFinite(secs) && secs > 0 ? now + secs * 1000 : BAD_HOLD;
|
||||
}
|
||||
return NO_HOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending while the message is still in the queue, which is what Stalwart
|
||||
* reports: `undoStatus` is read off the spool, not stored on the submission.
|
||||
*/
|
||||
export function undoStatusOf(sub: Obj, now: number): "pending" | "final" | "canceled" {
|
||||
if (sub.undoStatus === "canceled") return "canceled";
|
||||
return Date.parse(String(sub.sendAt)) > now ? "pending" : "final";
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js";
|
||||
|
||||
/**
|
||||
* The mock expands recurrences so that per-occurrence editing can be developed
|
||||
* against something. What it has to get right is not the expansion — that is
|
||||
* the easy half — but the three things a live server does that a client will
|
||||
* otherwise be written against wrongly:
|
||||
*
|
||||
* - every expanded id is synthetic, one-offs included;
|
||||
* - an occurrence carries a `recurrenceId` and no rule;
|
||||
* - a per-occurrence patch loses some properties in silence.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] };
|
||||
|
||||
/** A standup at 09:00 every weekday, starting Monday 2026-09-07. */
|
||||
const series = () => ({ id: "ev1", "@type": "Event", uid: "u1", title: "Standup", start: "2026-09-07T09:00:00", duration: "PT30M", recurrenceRule: WEEKDAYS } as Record<string, unknown>);
|
||||
const oneOff = () => ({ id: "ev2", "@type": "Event", uid: "u2", title: "Lunch", start: "2026-09-08T12:00:00", duration: "PT1H" } as Record<string, unknown>);
|
||||
|
||||
const week = (from: string, to: string) => [new Date(from), new Date(to)] as const;
|
||||
|
||||
describe("expandOccurrences", () => {
|
||||
it("gives a weekday rule five dates in a week and skips the weekend", () => {
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(series(), a, b);
|
||||
assert.deepEqual(out.map((o) => o.start), [
|
||||
"2026-09-07T09:00:00", "2026-09-08T09:00:00", "2026-09-09T09:00:00",
|
||||
"2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a one-off exactly one occurrence, at index 0", () => {
|
||||
const [a, b] = week("2026-09-01T00:00:00", "2026-10-01T00:00:00");
|
||||
const out = expandOccurrences(oneOff(), a, b);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0]!.index, 0);
|
||||
});
|
||||
|
||||
it("honours count", () => {
|
||||
const ev = { ...series(), recurrenceRule: { ...WEEKDAYS, count: 3 } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-10-01T00:00:00");
|
||||
assert.equal(expandOccurrences(ev, a, b).length, 3);
|
||||
});
|
||||
|
||||
it("drops an excluded date from the expansion, keeping the series positions", () => {
|
||||
const ev = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { excluded: true } } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(ev, a, b);
|
||||
assert.deepEqual(out.map((o) => o.start), [
|
||||
"2026-09-07T09:00:00", "2026-09-09T09:00:00", "2026-09-10T09:00:00", "2026-09-11T09:00:00",
|
||||
]);
|
||||
// The position within the series is unchanged — Wednesday is still the
|
||||
// third date the rule produces, whatever happened to Tuesday. It is the
|
||||
// *id* built on top of that which moves, and only after a write.
|
||||
assert.equal(out[1]!.index, 2);
|
||||
});
|
||||
|
||||
it("carries an override onto the occurrence it keys", () => {
|
||||
const ev = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } };
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const out = expandOccurrences(ev, a, b);
|
||||
assert.deepEqual(out.find((o) => o.start === "2026-09-09T09:00:00")!.override, { title: "Standup (long)" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrenceView", () => {
|
||||
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
|
||||
const base = series();
|
||||
const occ = occurrenceAt(base, 1)!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.id, syntheticId("ev1", 1));
|
||||
assert.equal(view.baseEventId, "ev1");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
assert.equal(view.recurrenceRule, undefined);
|
||||
assert.equal(view.recurrenceOverrides, undefined);
|
||||
});
|
||||
|
||||
it("gives a one-off a synthetic id over a different base, and no recurrenceId", () => {
|
||||
// Both halves matter. The id is why `baseEventId` proves nothing about a
|
||||
// series; the absent `recurrenceId` is why a one-off does not read as one.
|
||||
const base = oneOff();
|
||||
const view = occurrenceView(base, occurrenceAt(base, 0)!);
|
||||
assert.equal(view.id, "ev2-o0");
|
||||
assert.equal(view.baseEventId, "ev2");
|
||||
assert.notEqual(view.id, view.baseEventId);
|
||||
assert.equal(view.recurrenceId, undefined);
|
||||
});
|
||||
|
||||
it("lets an override win over the series", () => {
|
||||
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
|
||||
// Slot 2, not 1: one override has already shifted the numbering. Reaching
|
||||
// for the id this occurrence had *before* the write is the bug below.
|
||||
const view = occurrenceView(base, occurrenceAt(base, 2)!);
|
||||
assert.equal(view.start, "2026-09-08T09:00:00");
|
||||
assert.equal(view.title, "Moved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSyntheticId", () => {
|
||||
it("round-trips", () => {
|
||||
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 });
|
||||
});
|
||||
it("does not claim a stored id", () => {
|
||||
assert.equal(parseSyntheticId("ev1"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitOccurrencePatch", () => {
|
||||
it("applies what an occurrence takes", () => {
|
||||
const { rejected, applied } = splitOccurrencePatch({ title: "Just today", color: "#f00" });
|
||||
assert.equal(rejected, undefined);
|
||||
assert.deepEqual(applied, { title: "Just today", color: "#f00" });
|
||||
});
|
||||
|
||||
it("refuses an event-level property by name", () => {
|
||||
assert.equal(splitOccurrencePatch({ calendarIds: { c2: true } }).rejected, "calendarIds");
|
||||
assert.equal(splitOccurrencePatch({ hideAttendees: true }).rejected, "hideAttendees");
|
||||
});
|
||||
|
||||
it("drops an inherited property in silence, which is the dangerous half", () => {
|
||||
// No `rejected`, nothing applied, and a real server would still answer
|
||||
// "updated". Anything that trusts the response believes this landed.
|
||||
const { rejected, applied } = splitOccurrencePatch({ privacy: "private", recurrenceRule: null });
|
||||
assert.equal(rejected, undefined);
|
||||
assert.deepEqual(applied, {});
|
||||
});
|
||||
|
||||
it("judges a pointer patch on its first token", () => {
|
||||
assert.deepEqual(splitOccurrencePatch({ "participants/me/participationStatus": "accepted" }).applied,
|
||||
{ "participants/me/participationStatus": "accepted" });
|
||||
assert.deepEqual(splitOccurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).applied, {});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("synthetic ids are only true until the next write", () => {
|
||||
/*
|
||||
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides`
|
||||
* entry renumbered a five-week series so that the *same* ids addressed
|
||||
* different dates. Nothing was rejected. The mock reproduces the shape of
|
||||
* that rather than the exact permutation, because the property that bites is
|
||||
* not which date an id moves to but that it moves at all, silently.
|
||||
*/
|
||||
it("makes a cached id address a different date after an override is written", () => {
|
||||
const before = series();
|
||||
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!));
|
||||
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start;
|
||||
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start;
|
||||
|
||||
assert.notEqual(dateAfter, dateBefore);
|
||||
// And crucially it still resolves — a stale id is wrong, not invalid, so a
|
||||
// client that trusts it gets a confident answer about the wrong day.
|
||||
assert.ok(dateAfter);
|
||||
});
|
||||
|
||||
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => {
|
||||
const before = series();
|
||||
const occ = occurrenceAt(before, 3)!;
|
||||
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
|
||||
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"))
|
||||
.find((o) => o.recurrenceId === occ.recurrenceId);
|
||||
assert.equal(same!.start, occ.start);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("an override that moves an occurrence", () => {
|
||||
/*
|
||||
* Confirmed live on 0.16.20 (2026-08-31): one occurrence of a weekly 09:00
|
||||
* series moved to 14:00 comes back with `start` at 14:00 and `recurrenceId`
|
||||
* still at 09:00 — the slot the rule made, which the move does not touch.
|
||||
*
|
||||
* The mock used to clobber the override's `start` with the slot time, so a
|
||||
* moved occurrence did not move. That made per-occurrence *time* editing —
|
||||
* one of the main things the feature is for — look broken against the mock
|
||||
* and fine against the server.
|
||||
*/
|
||||
const moved = () => ({
|
||||
...series(),
|
||||
recurrenceOverrides: { "2026-09-08T09:00:00": { start: "2026-09-08T14:00:00" } },
|
||||
});
|
||||
|
||||
it("moves the occurrence and leaves its recurrenceId on the original slot", () => {
|
||||
const [a, b] = week("2026-09-07T00:00:00", "2026-09-14T00:00:00");
|
||||
const occ = expandOccurrences(moved(), a, b).find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
||||
assert.equal(occ.start, "2026-09-08T14:00:00");
|
||||
assert.equal(occ.recurrenceId, "2026-09-08T09:00:00");
|
||||
});
|
||||
|
||||
it("shows the moved time on the occurrence a get returns", () => {
|
||||
const base = moved();
|
||||
const occ = expandOccurrences(base, new Date("2026-09-07T00:00:00"), new Date("2026-09-14T00:00:00"))
|
||||
.find((o) => o.recurrenceId === "2026-09-08T09:00:00")!;
|
||||
const view = occurrenceView(base, occ);
|
||||
assert.equal(view.start, "2026-09-08T14:00:00");
|
||||
assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
|
||||
});
|
||||
|
||||
it("keeps the occurrence findable by recurrenceId after the move", () => {
|
||||
// This is the property the store depends on: `recurrenceId` survives both
|
||||
// a renumbering and a move, so it is the handle a mutation resolves from.
|
||||
const base = moved();
|
||||
const all = expandOccurrences(base, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00"));
|
||||
assert.equal(all.filter((o) => o.recurrenceId === "2026-09-08T09:00:00").length, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20.
|
||||
*
|
||||
* The mock used to hand a recurring event back once, as its stored self. Three
|
||||
* things that only a live server showed were therefore impossible to develop
|
||||
* against, and all three had already cost a debugging session:
|
||||
*
|
||||
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
|
||||
* a one-off included, so `baseEventId` is no evidence of a series;
|
||||
* - an occurrence carries a `recurrenceId` and no rule of its own;
|
||||
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a
|
||||
* `recurrenceOverrides` entry rather than touching the series.
|
||||
*
|
||||
* A mock that agrees with the client rather than with the server is how #26 and
|
||||
* #30 reached a live instance, so the refusals matter as much as the successes:
|
||||
* what Stalwart rejects is rejected here, and what it drops in silence is
|
||||
* dropped here, in silence, on purpose.
|
||||
*/
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
|
||||
/** How far the expander will walk before giving up on a rule. */
|
||||
const MAX_ITERATIONS = 750;
|
||||
|
||||
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||
|
||||
/**
|
||||
* The id an occurrence is addressed by, which is only true until the next write.
|
||||
*
|
||||
* Stalwart's are opaque; the mock's are parseable because it has to resolve
|
||||
* them, and nothing in ihasmail may read either.
|
||||
*
|
||||
* They are also deliberately **unstable**, because the real ones are.
|
||||
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position
|
||||
* in the expanded series, and writing a `recurrenceOverrides` entry adds a
|
||||
* component that renumbers it. A five-week series held `e i m q u` over
|
||||
* 03-01…03-29; after one override was written to 03-08 the same ids addressed
|
||||
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
|
||||
* different dates.
|
||||
*
|
||||
* That is the hazard worth reproducing, and note which way round it goes: a
|
||||
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead
|
||||
* would hand back a loud `notFound` and let a client that caches ids look
|
||||
* careful. So the numbering is shifted by the number of overrides — an
|
||||
* arbitrary stand-in for Stalwart's renumbering, with the one property that
|
||||
* matters: hold an id across a write and it silently addresses another date.
|
||||
*/
|
||||
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`;
|
||||
|
||||
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null {
|
||||
const m = /^(.+)-o(\d+)$/.exec(id);
|
||||
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
|
||||
}
|
||||
|
||||
/** How far the id numbering has been rotated away from the series order. */
|
||||
function rotation(base: Obj): number {
|
||||
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length;
|
||||
}
|
||||
|
||||
/** The id slot this occurrence currently answers to. */
|
||||
export function slotOfOccurrence(base: Obj, occ: Occurrence): number {
|
||||
return occ.index + rotation(base);
|
||||
}
|
||||
|
||||
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
|
||||
export function localDateTime(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
const parseLocal = (s: string): Date => new Date(s);
|
||||
|
||||
export interface Occurrence {
|
||||
index: number;
|
||||
/** The slot in the series this instance fills, which keys any override. */
|
||||
recurrenceId: string;
|
||||
start: string;
|
||||
/** Set when a `recurrenceOverrides` entry applies to this date. */
|
||||
override?: Obj;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
frequency?: string;
|
||||
interval?: number;
|
||||
count?: number;
|
||||
until?: string;
|
||||
byDay?: { day: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of `base` between `from` and `to`, in series order.
|
||||
*
|
||||
* An event with no rule has exactly one, at index 0 — which is what gives a
|
||||
* one-off the synthetic id a real server would give it.
|
||||
*/
|
||||
export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const startStr = base.start as string;
|
||||
if (!startStr) return [];
|
||||
const first = parseLocal(startStr);
|
||||
const rule = base.recurrenceRule as Rule | undefined;
|
||||
|
||||
const out: Occurrence[] = [];
|
||||
const emit = (index: number, at: Date): boolean => {
|
||||
const recurrenceId = localDateTime(at);
|
||||
const override = overrides[recurrenceId];
|
||||
// An excluded date is simply gone from the expansion. Its slot is not
|
||||
// reserved -- see `syntheticId` for why nothing here pretends otherwise.
|
||||
if (override?.excluded === true) return true;
|
||||
/*
|
||||
* An override may move the occurrence, and then `start` and `recurrenceId`
|
||||
* are two different times: the slot it fills stays where the rule put it,
|
||||
* and only the clock time moves. **Confirmed live on 0.16.20
|
||||
* (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00
|
||||
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
|
||||
* `2027-06-14T09:00:00`.
|
||||
*
|
||||
* Which is exactly why `recurrenceId` is what a client holds on to. It is
|
||||
* the one name for this instance that neither a renumbering nor a move
|
||||
* changes.
|
||||
*/
|
||||
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
|
||||
const shown = parseLocal(start);
|
||||
if (shown >= from && shown < to) {
|
||||
out.push({ index, recurrenceId, start, ...(override ? { override } : {}) });
|
||||
}
|
||||
return at < to;
|
||||
};
|
||||
|
||||
if (!rule?.frequency) {
|
||||
emit(0, first);
|
||||
return out;
|
||||
}
|
||||
|
||||
const interval = Math.max(1, rule.interval ?? 1);
|
||||
const until = rule.until ? parseLocal(rule.until) : null;
|
||||
const byDay = rule.byDay?.length ? new Set(rule.byDay.map((d) => d.day.toLowerCase())) : null;
|
||||
|
||||
let index = 0;
|
||||
let emitted = 0;
|
||||
const cursor = new Date(first);
|
||||
|
||||
for (let step = 0; step < MAX_ITERATIONS; step++) {
|
||||
if (until && cursor > until) break;
|
||||
if (rule.count != null && emitted >= rule.count) break;
|
||||
|
||||
const matches = !byDay || byDay.has(DAYS[cursor.getDay()]!);
|
||||
if (matches) {
|
||||
emitted++;
|
||||
const keepGoing = emit(index, new Date(cursor));
|
||||
index++;
|
||||
if (!keepGoing) break;
|
||||
}
|
||||
|
||||
// A rule with byDay walks day by day and keeps the days it names; without
|
||||
// one it steps by its own frequency.
|
||||
if (byDay) cursor.setDate(cursor.getDate() + 1);
|
||||
else if (rule.frequency === "daily") cursor.setDate(cursor.getDate() + interval);
|
||||
else if (rule.frequency === "weekly") cursor.setDate(cursor.getDate() + 7 * interval);
|
||||
else if (rule.frequency === "monthly") cursor.setMonth(cursor.getMonth() + interval);
|
||||
else if (rule.frequency === "yearly") cursor.setFullYear(cursor.getFullYear() + interval);
|
||||
else break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Fields that describe the series and never travel down to one instance. */
|
||||
const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides"];
|
||||
|
||||
/**
|
||||
* The object a `CalendarEvent/get` returns for one occurrence.
|
||||
*
|
||||
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
|
||||
* master — so an occurrence is recognisable by its `recurrenceId` and by
|
||||
* nothing else, which is the shape `isRecurring` was written against.
|
||||
*/
|
||||
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
|
||||
const view: Obj = { ...base };
|
||||
for (const k of SERIES_ONLY) delete view[k];
|
||||
Object.assign(view, occ.override ?? {});
|
||||
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ));
|
||||
view.baseEventId = base.id;
|
||||
view.start = occ.start;
|
||||
// Only a genuine instance of a series carries one. A one-off expanded into
|
||||
// its single occurrence does not, or every one-off would look recurring.
|
||||
if (base.recurrenceRule) view.recurrenceId = occ.recurrenceId;
|
||||
delete view.excluded;
|
||||
return view;
|
||||
}
|
||||
|
||||
/* ---------- what a single occurrence will not take ---------- */
|
||||
|
||||
/** Refused outright, with `invalidProperties`. */
|
||||
export const OCCURRENCE_REJECTED = new Set([
|
||||
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
|
||||
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dropped from the patch, with the response still reporting success.
|
||||
*
|
||||
* This is the half that has to be reproduced most carefully. A mock that
|
||||
* *applied* these would agree with a client that sends them, and the belief
|
||||
* would ship — which is exactly the road #26 took to a live server.
|
||||
*/
|
||||
export const OCCURRENCE_INHERITED = new Set([
|
||||
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
|
||||
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
|
||||
"recurrenceOverrides", "recurrenceRule", "relatedTo",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Split a per-occurrence patch the way the server's validator does.
|
||||
*
|
||||
* `rejected` is the first property that would be refused, if any; `applied` is
|
||||
* what actually lands on the override. Everything else vanishes without a word.
|
||||
*/
|
||||
export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: Obj } {
|
||||
const applied: Obj = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
const [head, , third] = key.split("/");
|
||||
const root = head ?? key;
|
||||
if (OCCURRENCE_REJECTED.has(root)) return { rejected: root, applied };
|
||||
if (OCCURRENCE_INHERITED.has(root)) continue;
|
||||
if (root === "participants" && third === "calendarAddress") continue;
|
||||
if (root === "id") continue;
|
||||
applied[key] = value;
|
||||
}
|
||||
return { applied };
|
||||
}
|
||||
|
||||
/** The occurrence a slot currently addresses — which is not a fixed thing. */
|
||||
export function occurrenceAt(base: Obj, slot: number): Occurrence | null {
|
||||
const index = slot - rotation(base);
|
||||
if (index < 0) return null;
|
||||
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
|
||||
return all.find((o) => o.index === index) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Simple sliding-window rate limiter keyed by arbitrary string (ip, ip+user). */
|
||||
export class RateLimiter {
|
||||
private hits = new Map<string, number[]>();
|
||||
|
||||
constructor(
|
||||
private readonly max: number,
|
||||
private readonly windowMs: number,
|
||||
) {
|
||||
const t = setInterval(() => this.prune(), windowMs);
|
||||
t.unref();
|
||||
}
|
||||
|
||||
/** Returns true if the action is allowed, false if the caller should back off. */
|
||||
check(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const arr = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
|
||||
if (arr.length >= this.max) {
|
||||
this.hits.set(key, arr);
|
||||
return false;
|
||||
}
|
||||
arr.push(now);
|
||||
this.hits.set(key, arr);
|
||||
return true;
|
||||
}
|
||||
|
||||
reset(key: string): void {
|
||||
this.hits.delete(key);
|
||||
}
|
||||
|
||||
retryAfterSeconds(key: string): number {
|
||||
const arr = this.hits.get(key);
|
||||
if (!arr || !arr.length) return 0;
|
||||
const oldest = arr[0]!;
|
||||
return Math.max(1, Math.ceil((this.windowMs - (Date.now() - oldest)) / 1000));
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, arr] of this.hits) {
|
||||
const kept = arr.filter((t) => now - t < this.windowMs);
|
||||
if (kept.length) this.hits.set(k, kept);
|
||||
else this.hits.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SessionStore } from "./sessions.js";
|
||||
import { normalizeLocale } from "./upstream.js";
|
||||
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
test("seal/open round-trips and rejects wrong key", () => {
|
||||
const salt = randomBytes(16);
|
||||
const k1 = deriveKey("cookie-secret", "app-secret", salt);
|
||||
const k2 = deriveKey("other", "app-secret", salt);
|
||||
const ct = seal("hello", k1);
|
||||
assert.equal(open(ct, k1), "hello");
|
||||
assert.equal(open(ct, k2), null);
|
||||
assert.equal(sha256("a"), sha256("a"));
|
||||
});
|
||||
|
||||
test("session store creates, resolves, and refuses tampered cookies", () => {
|
||||
const store = new SessionStore("");
|
||||
const { cookie, session } = store.create({ username: "[email protected]", password: "p4ss", remember: false, userAgent: "ua", ip: "127.0.0.1" });
|
||||
assert.equal(session.username, "[email protected]");
|
||||
const live = store.resolve(cookie);
|
||||
assert.ok(live);
|
||||
assert.equal(live!.authorization, `Basic ${Buffer.from("[email protected]:p4ss").toString("base64")}`);
|
||||
assert.equal(store.resolve(cookie + "x"), null);
|
||||
assert.equal(store.resolve("nope"), null);
|
||||
assert.equal(store.listForUser("[email protected]").length, 1);
|
||||
store.destroy(live!.id);
|
||||
assert.equal(store.resolve(cookie), null);
|
||||
});
|
||||
|
||||
test("persisted session data does not contain the password", () => {
|
||||
const store = new SessionStore("");
|
||||
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
||||
const json = JSON.stringify(store.listForUser("u"));
|
||||
assert.ok(!json.includes("super-secret-pw"));
|
||||
});
|
||||
|
||||
test("rate limiter blocks after max hits in window", () => {
|
||||
const rl = new RateLimiter(3, 60_000);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), false);
|
||||
assert.ok(rl.retryAfterSeconds("k") > 0);
|
||||
rl.reset("k");
|
||||
assert.equal(rl.check("k"), true);
|
||||
});
|
||||
|
||||
test("normalizes Stalwart account locales to BCP-47 tags", () => {
|
||||
assert.equal(normalizeLocale("de_DE"), "de-DE");
|
||||
assert.equal(normalizeLocale("de_DE.UTF-8"), "de-DE");
|
||||
assert.equal(normalizeLocale("ca_ES@valencia"), "ca-ES");
|
||||
assert.equal(normalizeLocale("sr_RS@latin"), "sr-Latn-RS");
|
||||
assert.equal(normalizeLocale("uz_UZ@cyrillic"), "uz-Cyrl-UZ");
|
||||
assert.equal(normalizeLocale("ru_RU@cyrillic"), "ru-RU");
|
||||
assert.equal(normalizeLocale("en"), "en");
|
||||
assert.equal(normalizeLocale("POSIX"), null);
|
||||
assert.equal(normalizeLocale("C"), null);
|
||||
assert.equal(normalizeLocale(""), null);
|
||||
assert.equal(normalizeLocale(undefined), null);
|
||||
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
||||
assert.equal(normalizeLocale("../etc/passwd"), null);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { config } from "./config.js";
|
||||
import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js";
|
||||
|
||||
export interface StoredSession {
|
||||
id: string;
|
||||
/** sha256 of the cookie secret; used to validate presented cookies. */
|
||||
secretHash: string;
|
||||
/** base64 random salt for key derivation */
|
||||
salt: string;
|
||||
/** sealed JSON {username, password} */
|
||||
sealedCredentials: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface LiveSession {
|
||||
id: string;
|
||||
username: string;
|
||||
/** Basic Authorization header value for upstream calls. */
|
||||
authorization: string;
|
||||
remember: boolean;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/** What `/api/auth/sessions` reports about a session, with nothing secret in it. */
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the rest of the server asks of a session store.
|
||||
*
|
||||
* There is one implementation today -- `SessionStore` below, which keeps the
|
||||
* records in memory and optionally mirrors them to `SESSION_FILE`. The reason
|
||||
* it is named as an interface anyway is that a second one is planned: a
|
||||
* stateless backend that carries the whole record in the cookie, so that a
|
||||
* replica can serve a session it never issued and `/data` can go away. Callers
|
||||
* written against the concrete class would all have to be revisited then.
|
||||
*
|
||||
* Five of these are already stateless in shape -- `create`, `resolve`,
|
||||
* `reseal` and `destroy` each touch exactly one session, and the sealing key is
|
||||
* derived from the cookie secret (see `crypto.ts`), so the record can move into
|
||||
* the cookie without the server keeping a map.
|
||||
*
|
||||
* The other two cannot be. `listForUser` and `destroyAllForUser` have to reach
|
||||
* sessions other than the one presenting itself, which means something has to
|
||||
* be enumerable somewhere. `destroyAllForUser` is not only the "sign out my
|
||||
* other sessions" button: `app.ts` also calls it when the password or the app
|
||||
* password changes, so it carries the guarantee that changing a credential
|
||||
* invalidates the sessions still holding the old one. A stateless backend
|
||||
* cannot honour that alone; the plan is for OAuth to hand the job to
|
||||
* Stalwart's own token registry, which can already answer both questions.
|
||||
*/
|
||||
export interface SessionBackend {
|
||||
init(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
destroy(id: string): void;
|
||||
destroyAllForUser(username: string, exceptId?: string): number;
|
||||
listForUser(username: string): SessionSummary[];
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
|
||||
export class SessionStore implements SessionBackend {
|
||||
private sessions = new Map<string, StoredSession>();
|
||||
private dirty = false;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
private sweepTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly file: string) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.file) {
|
||||
try {
|
||||
const raw = await readFile(this.file, "utf8");
|
||||
const arr = JSON.parse(raw) as StoredSession[];
|
||||
const now = Date.now();
|
||||
for (const s of arr) if (s.expiresAt > now) this.sessions.set(s.id, s);
|
||||
console.log(`[ihasmail] restored ${this.sessions.size} session(s)`);
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.warn("[ihasmail] could not read session file:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
|
||||
this.sweepTimer.unref();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.expiresAt <= now) {
|
||||
this.sessions.delete(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed) this.scheduleSave();
|
||||
}
|
||||
|
||||
private scheduleSave(): void {
|
||||
this.dirty = true;
|
||||
if (!this.file || this.saveTimer) return;
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveTimer = null;
|
||||
void this.flush();
|
||||
}, 1000);
|
||||
this.saveTimer.unref();
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (!this.file || !this.dirty) return;
|
||||
this.dirty = false;
|
||||
try {
|
||||
await mkdir(dirname(this.file), { recursive: true });
|
||||
const tmp = `${this.file}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify([...this.sessions.values()]), { mode: 0o600 });
|
||||
await rename(tmp, this.file);
|
||||
} catch (err) {
|
||||
console.warn("[ihasmail] could not persist sessions:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a session; returns the cookie value to hand to the client. */
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession } {
|
||||
const id = randomToken(18);
|
||||
const secret = randomToken(32);
|
||||
const salt = randomBytes(16);
|
||||
const key = deriveKey(secret, config.appSecret, salt);
|
||||
const now = Date.now();
|
||||
const ttl = (params.remember ? config.sessionRememberTtl : config.sessionTtl) * 1000;
|
||||
const stored: StoredSession = {
|
||||
id,
|
||||
secretHash: sha256(secret),
|
||||
salt: salt.toString("base64"),
|
||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||
username: params.username,
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
expiresAt: now + ttl,
|
||||
remember: params.remember,
|
||||
userAgent: params.userAgent.slice(0, 200),
|
||||
ip: params.ip,
|
||||
};
|
||||
this.sessions.set(id, stored);
|
||||
this.scheduleSave();
|
||||
const cookie = `${id}${COOKIE_SEP}${secret}`;
|
||||
return { cookie, session: this.toLive(stored, params.username, params.password) };
|
||||
}
|
||||
|
||||
/** Resolve a cookie to a live session (with decrypted upstream credentials). */
|
||||
resolve(cookie: string | undefined): LiveSession | null {
|
||||
if (!cookie) return null;
|
||||
const idx = cookie.indexOf(COOKIE_SEP);
|
||||
if (idx <= 0) return null;
|
||||
const id = cookie.slice(0, idx);
|
||||
const secret = cookie.slice(idx + 1);
|
||||
const stored = this.sessions.get(id);
|
||||
if (!stored) return null;
|
||||
const now = Date.now();
|
||||
if (stored.expiresAt <= now) {
|
||||
this.sessions.delete(id);
|
||||
this.scheduleSave();
|
||||
return null;
|
||||
}
|
||||
if (!safeEqual(stored.secretHash, sha256(secret))) return null;
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
const json = open(stored.sealedCredentials, key);
|
||||
if (!json) return null;
|
||||
let creds: { u: string; p: string };
|
||||
try {
|
||||
creds = JSON.parse(json) as { u: string; p: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Sliding expiry: bump every few minutes, not on every request.
|
||||
if (now - stored.lastSeenAt > 60_000) {
|
||||
stored.lastSeenAt = now;
|
||||
const ttl = (stored.remember ? config.sessionRememberTtl : config.sessionTtl) * 1000;
|
||||
stored.expiresAt = now + ttl;
|
||||
this.scheduleSave();
|
||||
}
|
||||
return this.toLive(stored, creds.u, creds.p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seal this session's stored credentials.
|
||||
*
|
||||
* The upstream password is what every proxied call authenticates with, so a
|
||||
* password change (or swapping in an app password when 2FA is switched on)
|
||||
* would otherwise leave the session holding a credential the server no
|
||||
* longer accepts. Needs the cookie: the sealing key is derived from the
|
||||
* secret half of it, which the server never keeps.
|
||||
*/
|
||||
reseal(cookie: string | undefined, password: string): boolean {
|
||||
if (!cookie) return false;
|
||||
const idx = cookie.indexOf(COOKIE_SEP);
|
||||
if (idx <= 0) return false;
|
||||
const id = cookie.slice(0, idx);
|
||||
const secret = cookie.slice(idx + 1);
|
||||
const stored = this.sessions.get(id);
|
||||
if (!stored) return false;
|
||||
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
|
||||
this.scheduleSave();
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(id: string): void {
|
||||
if (this.sessions.delete(id)) this.scheduleSave();
|
||||
}
|
||||
|
||||
destroyAllForUser(username: string, exceptId?: string): number {
|
||||
let n = 0;
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.username === username && id !== exceptId) {
|
||||
this.sessions.delete(id);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n) this.scheduleSave();
|
||||
return n;
|
||||
}
|
||||
|
||||
listForUser(username: string): SessionSummary[] {
|
||||
const out = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.username !== username) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
|
||||
out.push(rest);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private toLive(s: StoredSession, username: string, password: string): LiveSession {
|
||||
return {
|
||||
id: s.id,
|
||||
username,
|
||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||
remember: s.remember,
|
||||
createdAt: s.createdAt,
|
||||
lastSeenAt: s.lastSeenAt,
|
||||
expiresAt: s.expiresAt,
|
||||
userAgent: s.userAgent,
|
||||
ip: s.ip,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat, readFile } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import type { Context, Handler } from "hono";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".wasm": "application/wasm",
|
||||
};
|
||||
|
||||
/**
|
||||
* Content Security Policy for the app shell. Inline styles are required because
|
||||
* sanitized HTML email carries style attributes; everything else is strict.
|
||||
*/
|
||||
export const APP_CSP = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"media-src 'self' blob:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"worker-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
export function staticHandler(root: string): Handler {
|
||||
const absRoot = resolve(root);
|
||||
let indexCache: { body: string; mtime: number } | null = null;
|
||||
|
||||
async function serveIndex(c: Context) {
|
||||
try {
|
||||
const p = join(absRoot, "index.html");
|
||||
const st = await stat(p);
|
||||
if (!indexCache || indexCache.mtime !== st.mtimeMs) {
|
||||
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
|
||||
}
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
c.header("Content-Security-Policy", APP_CSP);
|
||||
return c.body(indexCache.body);
|
||||
} catch {
|
||||
c.header("Content-Type", "text/plain; charset=utf-8");
|
||||
return c.body("ihasmail: web build not found. Run `npm run build` first.", 503);
|
||||
}
|
||||
}
|
||||
|
||||
return async (c) => {
|
||||
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405);
|
||||
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
|
||||
if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c);
|
||||
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
||||
const filePath = join(absRoot, rel);
|
||||
if (!filePath.startsWith(absRoot + sep)) return serveIndex(c);
|
||||
try {
|
||||
const st = await stat(filePath);
|
||||
if (!st.isFile()) return serveIndex(c);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
||||
c.header("Content-Length", String(st.size));
|
||||
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else if (ext === ".html") {
|
||||
c.header("Cache-Control", "no-cache");
|
||||
c.header("Content-Security-Policy", APP_CSP);
|
||||
} else {
|
||||
c.header("Cache-Control", "public, max-age=3600");
|
||||
}
|
||||
if (c.req.method === "HEAD") return c.body(null);
|
||||
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
|
||||
return c.body(stream);
|
||||
} catch {
|
||||
// SPA fallback for client-side routes (no file extension) only.
|
||||
if (!extname(rel)) return serveIndex(c);
|
||||
return c.text("Not Found", 404);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { base32Decode, base32Encode, generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||
|
||||
/** RFC 6238 Appendix B seeds. */
|
||||
const SHA1_SECRET = base32Encode(Buffer.from("12345678901234567890", "ascii"));
|
||||
const SHA256_SECRET = base32Encode(Buffer.from("12345678901234567890123456789012", "ascii"));
|
||||
|
||||
test("base32 matches the RFC 4648 alphabet and round-trips", () => {
|
||||
assert.equal(SHA1_SECRET, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
|
||||
assert.equal(base32Encode(Buffer.from("f", "ascii")), "MY");
|
||||
assert.equal(base32Encode(Buffer.from("foobar", "ascii")), "MZXW6YTBOI");
|
||||
assert.deepEqual(base32Decode("MZXW6YTBOI"), Buffer.from("foobar", "ascii"));
|
||||
// Users paste secrets with spaces, lowercase and padding.
|
||||
assert.deepEqual(base32Decode("mzxw 6ytb-oi==="), Buffer.from("foobar", "ascii"));
|
||||
assert.equal(base32Decode("not base32!"), null);
|
||||
});
|
||||
|
||||
test("verifyTotp accepts the RFC 6238 SHA-1 test vectors", () => {
|
||||
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
|
||||
for (const [time, code] of [
|
||||
[59, "94287082"],
|
||||
[1111111109, "07081804"],
|
||||
[1111111111, "14050471"],
|
||||
[1234567890, "89005924"],
|
||||
[2000000000, "69279037"],
|
||||
[20000000000, "65353130"],
|
||||
] as const) {
|
||||
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyTotp accepts the RFC 6238 SHA-256 test vectors", () => {
|
||||
const params = { secret: SHA256_SECRET, algorithm: "SHA256" as const, digits: 8, period: 30 };
|
||||
for (const [time, code] of [
|
||||
[59, "46119246"],
|
||||
[1111111109, "68084774"],
|
||||
[1234567890, "91819424"],
|
||||
] as const) {
|
||||
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyTotp rejects wrong, malformed and mis-sized codes", () => {
|
||||
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
|
||||
const at = { window: 0, now: 59_000 };
|
||||
assert.equal(verifyTotp(params, "94287083", at), false);
|
||||
assert.equal(verifyTotp(params, "9428708", at), false, "too short");
|
||||
assert.equal(verifyTotp(params, "942870822", at), false, "too long");
|
||||
assert.equal(verifyTotp(params, "abcdefgh", at), false);
|
||||
assert.equal(verifyTotp(params, "", at), false);
|
||||
assert.equal(verifyTotp({ ...params, secret: "!!!" }, "94287082", at), false, "bad secret");
|
||||
});
|
||||
|
||||
test("the skew window covers a step either side and no further", () => {
|
||||
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
|
||||
// 94287082 is the code for the step containing t=59.
|
||||
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 89_000 }), true, "one step late");
|
||||
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 29_000 }), true, "one step early");
|
||||
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 119_000 }), false, "two steps late");
|
||||
});
|
||||
|
||||
test("otpauth URLs round-trip through the parser", () => {
|
||||
const secret = generateSecret();
|
||||
const url = otpauthUrl({ secret, account: "[email protected]", issuer: "ihasmail" });
|
||||
assert.match(url, /^otpauth:\/\/totp\/ihasmail:ann%40example\.org\?/);
|
||||
const parsed = parseOtpauthUrl(url);
|
||||
assert.deepEqual(parsed, { secret, algorithm: "SHA1", digits: 6, period: 30 });
|
||||
});
|
||||
|
||||
test("generated secrets are 160-bit and distinct", () => {
|
||||
const a = generateSecret();
|
||||
const b = generateSecret();
|
||||
assert.equal(base32Decode(a)?.length, 20);
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test("parseOtpauthUrl rejects anything that is not a usable TOTP URL", () => {
|
||||
assert.equal(parseOtpauthUrl("https://example.org"), null);
|
||||
assert.equal(parseOtpauthUrl("otpauth://hotp/a?secret=GEZDGNBV"), null, "counter-based");
|
||||
assert.equal(parseOtpauthUrl("otpauth://totp/a"), null, "no secret");
|
||||
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=!!!"), null, "unusable secret");
|
||||
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&algorithm=MD5"), null);
|
||||
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&digits=99"), null);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* TOTP (RFC 6238) — just enough to enrol a second factor safely.
|
||||
*
|
||||
* Stalwart stores the otpauth:// URL and checks codes at login, but it does
|
||||
* *not* check the new secret when 2FA is switched on: it verifies the
|
||||
* credentials that are already on the account. A user whose authenticator was
|
||||
* mistyped or whose clock has drifted would be locked out of their mailbox at
|
||||
* the next sign-in. So ihasmail proves the enrolment itself, before asking the
|
||||
* server to store anything.
|
||||
*/
|
||||
|
||||
export interface TotpParams {
|
||||
secret: string;
|
||||
algorithm: "SHA1" | "SHA256" | "SHA512";
|
||||
digits: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Omit<TotpParams, "secret"> = { algorithm: "SHA1", digits: 6, period: 30 };
|
||||
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function base32Encode(buf: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = "";
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += BASE32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decode base32, tolerating lowercase, padding and the spaces users paste. */
|
||||
export function base32Decode(input: string): Buffer | null {
|
||||
const clean = input.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
|
||||
if (!clean || /[^A-Z2-7]/.test(clean)) return null;
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of clean) {
|
||||
value = (value << 5) | BASE32.indexOf(ch);
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 255);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
/** A fresh 160-bit secret — the size RFC 4226 recommends for HMAC-SHA1. */
|
||||
export function generateSecret(): string {
|
||||
return base32Encode(randomBytes(20));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the otpauth:// URL that authenticator apps scan and Stalwart stores.
|
||||
* The label is "issuer:account" with the issuer repeated as a parameter, which
|
||||
* is what totp-rs (Stalwart's parser) and every common app expect.
|
||||
*/
|
||||
export function otpauthUrl(opts: { secret: string; account: string; issuer: string }): string {
|
||||
const label = `${encodeURIComponent(opts.issuer)}:${encodeURIComponent(opts.account)}`;
|
||||
const params = new URLSearchParams({
|
||||
secret: opts.secret,
|
||||
issuer: opts.issuer,
|
||||
algorithm: DEFAULTS.algorithm,
|
||||
digits: String(DEFAULTS.digits),
|
||||
period: String(DEFAULTS.period),
|
||||
});
|
||||
return `otpauth://totp/${label}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function parseOtpauthUrl(url: string): TotpParams | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== "otpauth:" || parsed.host.toLowerCase() !== "totp") return null;
|
||||
const secret = parsed.searchParams.get("secret");
|
||||
if (!secret || !base32Decode(secret)) return null;
|
||||
const algorithm = (parsed.searchParams.get("algorithm") ?? DEFAULTS.algorithm).toUpperCase();
|
||||
if (algorithm !== "SHA1" && algorithm !== "SHA256" && algorithm !== "SHA512") return null;
|
||||
const digits = Number(parsed.searchParams.get("digits") ?? DEFAULTS.digits);
|
||||
const period = Number(parsed.searchParams.get("period") ?? DEFAULTS.period);
|
||||
if (!Number.isInteger(digits) || digits < 6 || digits > 10) return null;
|
||||
if (!Number.isInteger(period) || period < 5 || period > 300) return null;
|
||||
return { secret, algorithm, digits, period };
|
||||
}
|
||||
|
||||
/** The HOTP code for one counter value. */
|
||||
function hotp(key: Buffer, counter: number, algorithm: string, digits: number): string {
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeBigUInt64BE(BigInt(counter));
|
||||
const digest = createHmac(algorithm.toLowerCase(), key).update(buf).digest();
|
||||
const offset = digest[digest.length - 1]! & 0x0f;
|
||||
const binary = digest.readUInt32BE(offset) & 0x7fffffff;
|
||||
return (binary % 10 ** digits).toString().padStart(digits, "0");
|
||||
}
|
||||
|
||||
/** The code an authenticator app would show at `now`. */
|
||||
export function totpCode(params: TotpParams, now = Date.now()): string {
|
||||
const key = base32Decode(params.secret);
|
||||
if (!key || !key.length) throw new Error("unusable TOTP secret");
|
||||
return hotp(key, Math.floor(now / 1000 / params.period), params.algorithm, params.digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a user-supplied code, allowing `window` steps of clock skew either way
|
||||
* (one step = 30s by default, so the default tolerates ±30s).
|
||||
*/
|
||||
export function verifyTotp(params: TotpParams, code: string, opts: { window?: number; now?: number } = {}): boolean {
|
||||
const digits = params.digits;
|
||||
const cleaned = code.replace(/\s/g, "");
|
||||
if (cleaned.length !== digits || !/^\d+$/.test(cleaned)) return false;
|
||||
const key = base32Decode(params.secret);
|
||||
if (!key || !key.length) return false;
|
||||
const window = opts.window ?? 1;
|
||||
const counter = Math.floor((opts.now ?? Date.now()) / 1000 / params.period);
|
||||
let ok = false;
|
||||
// Check every candidate rather than returning early, so the time taken does
|
||||
// not reveal which step matched.
|
||||
for (let i = -window; i <= window; i++) {
|
||||
const step = counter + i;
|
||||
if (step < 0) continue; // only reachable for times within a step of the epoch
|
||||
const expected = hotp(key, step, params.algorithm, digits);
|
||||
if (safeEqual(expected, cleaned)) ok = true;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { config } from "./config.js";
|
||||
|
||||
export interface UpstreamSession {
|
||||
capabilities: Record<string, unknown>;
|
||||
accounts: Record<string, unknown>;
|
||||
primaryAccounts: Record<string, string>;
|
||||
username: string;
|
||||
apiUrl: string;
|
||||
downloadUrl: string;
|
||||
uploadUrl: string;
|
||||
eventSourceUrl: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export class UpstreamError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionCache = new Map<string, { session: UpstreamSession; fetchedAt: number }>();
|
||||
const SESSION_CACHE_MS = 5 * 60_000;
|
||||
|
||||
export function wellKnownUrl(): string {
|
||||
return `${config.stalwartUrl}/.well-known/jmap`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the JMAP session resource from Stalwart using the given Authorization
|
||||
* header. Throws UpstreamError(401) on bad credentials.
|
||||
*/
|
||||
export async function fetchUpstreamSession(authorization: string): Promise<UpstreamSession> {
|
||||
const res = await fetch(wellKnownUrl(), {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new UpstreamError("Invalid credentials", 401);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new UpstreamError(`Upstream session request failed (${res.status})`, 502);
|
||||
}
|
||||
const session = (await res.json()) as UpstreamSession;
|
||||
if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getUpstreamSession(sessionId: string, authorization: string, force = false) {
|
||||
const cached = sessionCache.get(sessionId);
|
||||
if (!force && cached && Date.now() - cached.fetchedAt < SESSION_CACHE_MS) return cached.session;
|
||||
const session = await fetchUpstreamSession(authorization);
|
||||
sessionCache.set(sessionId, { session, fetchedAt: Date.now() });
|
||||
return session;
|
||||
}
|
||||
|
||||
export function forgetUpstreamSession(sessionId: string): void {
|
||||
sessionCache.delete(sessionId);
|
||||
infoCache.delete(sessionId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Account locale */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
|
||||
/**
|
||||
* Whether this server has Stalwart's JMAP registry — the `x:` objects that
|
||||
* carry credentials, account settings and the newer FileNode shape.
|
||||
*
|
||||
* `urn:stalwart:jmap` is the marker, but **not** in the session-level
|
||||
* `capabilities`, which is where a JMAP client would naturally look. Stalwart
|
||||
* builds that list from a fixed set that has never included this capability;
|
||||
* it hands it out per-account instead, so it turns up in `primaryAccounts` and
|
||||
* in each account's `accountCapabilities`. Checking only the session level
|
||||
* therefore reported every real 0.16 server as older than 0.16 — which routed
|
||||
* self-service credentials to a REST endpoint 0.16 had removed, and told the
|
||||
* About page the wrong thing. The session level is still checked last, in case
|
||||
* a later release advertises it there as well.
|
||||
*
|
||||
* This is now what sign-in tests to decide whether a server is supported at
|
||||
* all, so the same mistake would lock every user out of a working server
|
||||
* rather than merely misroute them.
|
||||
*/
|
||||
export function hasStalwartRegistry(session: Pick<UpstreamSession, "capabilities" | "accounts" | "primaryAccounts"> | undefined): boolean {
|
||||
if (!session) return false;
|
||||
if (session.primaryAccounts && STALWART_CAP in session.primaryAccounts) return true;
|
||||
for (const account of Object.values(session.accounts ?? {})) {
|
||||
const caps = (account as { accountCapabilities?: Record<string, unknown> } | null)?.accountCapabilities;
|
||||
if (caps && STALWART_CAP in caps) return true;
|
||||
}
|
||||
return Boolean(session.capabilities && STALWART_CAP in session.capabilities);
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
/** BCP-47 tag configured for the account, or null if unreadable. */
|
||||
locale: string | null;
|
||||
/** "oss" | "community" | "enterprise", where the server reports it. */
|
||||
edition: string | null;
|
||||
}
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
const INFO_CACHE_MS = 30 * 60_000;
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, edition: null };
|
||||
|
||||
/**
|
||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||
* "sr_RS@latin" is Latin Serbian (sr-Latn-RS), not sr-RS. Anything not listed
|
||||
* here (@valencia, @saaho, @euro …) carries no script and is dropped.
|
||||
*/
|
||||
const SCRIPT_MODIFIERS: Record<string, string> = {
|
||||
latin: "Latn",
|
||||
latn: "Latn",
|
||||
cyrillic: "Cyrl",
|
||||
cyrl: "Cyrl",
|
||||
devanagari: "Deva",
|
||||
iqtelif: "Latn",
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
|
||||
* ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for
|
||||
* anything that does not look like a language tag.
|
||||
*/
|
||||
export function normalizeLocale(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const [head, modifier] = raw.trim().split("@");
|
||||
const base = head!.split(".")[0]!.replace(/_/g, "-");
|
||||
if (!base || base === "C" || base.toUpperCase() === "POSIX") return null;
|
||||
if (!/^[A-Za-z]{2,8}(-[A-Za-z0-9]{2,8})*$/.test(base)) return null;
|
||||
const script = modifier ? SCRIPT_MODIFIERS[modifier.toLowerCase()] : undefined;
|
||||
try {
|
||||
const [canonical] = Intl.getCanonicalLocales(base);
|
||||
if (!canonical) return null;
|
||||
if (!script) return canonical;
|
||||
const loc = new Intl.Locale(canonical);
|
||||
// Adding the script only helps when it differs from the one the locale
|
||||
// already implies (ru-RU is Cyrillic, so "ru_RU@cyrillic" is just ru-RU).
|
||||
const implied = loc.script ?? loc.maximize().script;
|
||||
return implied === script ? canonical : new Intl.Locale(canonical, { script }).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort lookup of what the server can tell us about this account.
|
||||
*
|
||||
* The locale used to come from `x:Account/get`, which needs the `sysAccountGet`
|
||||
* permission — a tenant/admin one that ordinary users are not granted, so the
|
||||
* setting silently fell back to the browser locale for exactly the people most
|
||||
* likely to want it. Stalwart 0.16 exposes the same field on `x:AccountSettings`,
|
||||
* whose `sysAccountSettingsGet` permission *is* part of the built-in user role.
|
||||
* Ask for both in one request and take whichever the server allows, which also
|
||||
* tells us which generation we are talking to.
|
||||
*/
|
||||
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
// Sign-in refuses a server without the registry, so this should not happen —
|
||||
// but a session we cannot read capabilities from is not one to ask.
|
||||
if (!session.capabilities || !hasStalwartRegistry(session)) return EMPTY_INFO;
|
||||
const accountId =
|
||||
session.primaryAccounts?.[STALWART_CAP] ??
|
||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
Object.keys(session.accounts ?? {})[0];
|
||||
if (!accountId) return EMPTY_INFO;
|
||||
const res = await fetch(absoluteUpstream(session.apiUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
using: [JMAP_CORE, STALWART_CAP],
|
||||
methodCalls: [
|
||||
["x:AccountSettings/get", { accountId, ids: ["singleton"], properties: ["locale"] }, "s"],
|
||||
["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "a"],
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
// A locale request that fails — a permission we lack, a hiccup upstream —
|
||||
// costs us the locale and nothing else.
|
||||
if (!res.ok) return EMPTY_INFO;
|
||||
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||
return interpretAccountInfo(body.methodResponses ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pair of replies: prefer the locale from `x:AccountSettings`, whose
|
||||
* permission the built-in user role has, and fall back to `x:Account` for the
|
||||
* accounts allowed the admin-only `sysAccountGet` instead. Both are 0.16
|
||||
* methods; this is a permissions fallback, not a version one.
|
||||
*/
|
||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
||||
const settings = responses.find((r) => r[2] === "s");
|
||||
const account = responses.find((r) => r[2] === "a");
|
||||
return { locale: localeOf(settings) ?? localeOf(account), edition: null };
|
||||
}
|
||||
|
||||
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
||||
if (!call || call[0] === "error") return null;
|
||||
const list = call[1]?.list;
|
||||
if (!Array.isArray(list) || !list.length) return null;
|
||||
return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which edition the server is running. Stalwart deliberately does not publish
|
||||
* its version number to clients, but 0.16 does report its edition here.
|
||||
*/
|
||||
async function fetchEdition(authorization: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { edition?: unknown };
|
||||
return typeof body.edition === "string" ? body.edition : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
const cached = infoCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
|
||||
let info = EMPTY_INFO;
|
||||
try {
|
||||
info = await fetchAccountInfo(authorization, session);
|
||||
info = { ...info, edition: await fetchEdition(authorization) };
|
||||
} catch {
|
||||
/* all of this is a nicety - never fail the session over it */
|
||||
}
|
||||
infoCache.set(sessionId, { info, fetchedAt: Date.now() });
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the upstream session so the browser talks to our same-origin proxy
|
||||
* endpoints instead of Stalwart directly (no CORS, no credentials in browser).
|
||||
*/
|
||||
export function localizeSession(s: UpstreamSession, extras: Record<string, unknown>): Record<string, unknown> {
|
||||
const caps = { ...s.capabilities };
|
||||
// We proxy push as Server-Sent Events; hide the upstream websocket endpoint.
|
||||
delete caps["urn:ietf:params:jmap:websocket"];
|
||||
return {
|
||||
...s,
|
||||
capabilities: caps,
|
||||
apiUrl: "/api/jmap",
|
||||
downloadUrl: "/api/blob/{accountId}/{blobId}/{name}?accept={type}",
|
||||
uploadUrl: "/api/upload/{accountId}",
|
||||
eventSourceUrl: "/api/events?types={types}&closeafter={closeafter}&ping={ping}",
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a possibly-relative upstream URL template against STALWART_URL. */
|
||||
export function absoluteUpstream(url: string): string {
|
||||
try {
|
||||
return new URL(url, config.stalwartUrl).toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function expandTemplate(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_m, k: string) => encodeURIComponent(vars[k] ?? ""));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatVersion, resolveVersion, UNVERSIONED, versionFromGit } from "../../scripts/version.mjs";
|
||||
|
||||
/**
|
||||
* The version is this build's public identity: it names the image, and it is
|
||||
* what About and /api/health report. It had no tests while it was
|
||||
* `2.16.<pr>`; it has them now that the rules moved.
|
||||
*/
|
||||
|
||||
test("a pull request merge is named by its number", () => {
|
||||
assert.equal(
|
||||
formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from Coffey-Labs/link-project-site-v2", sha: "1fa6578" }),
|
||||
"2026.8.30+pr129",
|
||||
);
|
||||
});
|
||||
|
||||
test("a commit that did not come through a pull request carries its SHA", () => {
|
||||
// Claiming the last PR would say it *is* that PR rather than something after it.
|
||||
assert.equal(formatVersion({ date: "2026-08-30", subject: "Fix a thing directly on main", sha: "1fa6578" }), "2026.8.30+g1fa6578");
|
||||
});
|
||||
|
||||
test("leading zeros are stripped, since a version field may not carry them", () => {
|
||||
assert.equal(formatVersion({ date: "2026-09-05", subject: "Merge pull request #7 from x/y", sha: "abc1234" }), "2026.9.5+pr7");
|
||||
assert.equal(formatVersion({ date: "2027-01-01", subject: "", sha: "abc1234" }), "2027.1.1+gabc1234");
|
||||
});
|
||||
|
||||
test("it sorts forward from the versions it replaces", () => {
|
||||
// 2.16.129 was deployed. 2.1.x would have read as a downgrade, which is the
|
||||
// whole reason the Stalwart generation left the version.
|
||||
const [older, newer] = ["2.16.129", "2026.8.30"].map((v) => v.split(".").map(Number));
|
||||
assert.ok(newer![0]! > older![0]!, "the leading field has to increase");
|
||||
});
|
||||
|
||||
test("two builds from the same day differ, even though they rank the same", () => {
|
||||
const a = formatVersion({ date: "2026-08-30", subject: "Merge pull request #128 from x/y", sha: "aaaaaaa" });
|
||||
const b = formatVersion({ date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "bbbbbbb" });
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(a.split("+")[0], b.split("+")[0]);
|
||||
});
|
||||
|
||||
test("the same commit always resolves to the same version", () => {
|
||||
// Built from the commit's own date, not today's, so an old commit rebuilt
|
||||
// now reports what it reported then.
|
||||
const commit = { date: "2026-08-30", subject: "Merge pull request #129 from x/y", sha: "1fa6578" };
|
||||
assert.equal(formatVersion(commit), formatVersion(commit));
|
||||
});
|
||||
|
||||
test("an explicit IHASMAIL_VERSION wins, because the Docker build has no git", () => {
|
||||
const before = process.env.IHASMAIL_VERSION;
|
||||
process.env.IHASMAIL_VERSION = "2026.8.30+pr129";
|
||||
try {
|
||||
assert.equal(resolveVersion(), "2026.8.30+pr129");
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.IHASMAIL_VERSION;
|
||||
else process.env.IHASMAIL_VERSION = before;
|
||||
}
|
||||
});
|
||||
|
||||
test("a checkout with git resolves to a real version, and an unversioned build looks wrong", () => {
|
||||
assert.match(versionFromGit() ?? "", /^\d{4}\.\d{1,2}\.\d{1,2}\+(pr\d+|g[0-9a-f]+)$/);
|
||||
assert.equal(UNVERSIONED, "0.0.0");
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
def test_root_redirect():
|
||||
client = TestClient(app)
|
||||
r = client.get("/", allow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<!--
|
||||
One tag, no media query: applyTheme() keeps it in step with the chosen
|
||||
theme, which a media query cannot do — it only knows what the OS prefers,
|
||||
not what the user picked here. There used to be two, both with media
|
||||
attributes, which meant the selector in applyTheme (:not([media])) matched
|
||||
neither and the colour never moved off whatever the OS implied.
|
||||
|
||||
The initial value is the default theme's background, so the browser chrome
|
||||
is right from the first paint rather than only once JS has run.
|
||||
-->
|
||||
<meta name="theme-color" content="#0d2430" />
|
||||
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>ihasmail</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@ihasmail/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"wouter": "^3.6.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 242 KiB |