Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/LINUXexpert-org/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
|
||||||
|
.
|
||||||
|
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/LINUXexpert-org/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
|
# ---- build stage ----
|
||||||
|
FROM node:22-alpine AS build
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
# 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
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
# 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
|
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/
|
# ---- runtime stage ----
|
||||||
RUN pip install --no-cache-dir -e .
|
FROM node:22-alpine AS runtime
|
||||||
|
# Re-declared: an ARG does not cross stages.
|
||||||
COPY app /app/app
|
ARG IHASMAIL_VERSION=""
|
||||||
COPY .env.example /app/.env.example
|
ENV NODE_ENV=production \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
ENV PORT=8000
|
PORT=8080 \
|
||||||
EXPOSE 8000
|
STATIC_DIR=/app/web/dist \
|
||||||
CMD ["uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"]
|
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,39 @@
|
|||||||
|
# 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.19**, and as of **2026-08-26 there is nothing
|
||||||
|
left pending**: every entry below has been exercised against it. 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/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
|
||||||
|
|
||||||
|
- **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/LINUXexpert-org/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/LINUXexpert-org/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/LINUXexpert-org/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 has to be aimed at the base event: `CalendarEvent/set` refuses a synthetic id with *"Updating synthetic ids is not yet supported"*, which is why RSVP resolves `baseEventId` first. 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.
|
||||||
|
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
|
||||||
|
- 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
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
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
|
Preamble
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, version 3 of the License.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
software and other kinds of works, specifically designed to ensure
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
cooperation with the community in the case of network server software.
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
The licenses for most software and other practical works are designed
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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,213 @@
|
|||||||
|
<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.19" src="https://img.shields.io/badge/Stalwart-0.16.19-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://linuxexpert.org" target="_blank" rel="noreferrer"><img alt="by LINUXexpert.org" src="https://img.shields.io/badge/by-LINUXexpert.org-0f766e?style=flat-square"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
# ihasmail
|
# 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
|
This file is for people working *on* ihasmail. Everything about running it
|
||||||
- Login with Stalwart mailbox (HTTP Basic against JMAP session or bearer token if provided)
|
lives in the docs.
|
||||||
- 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
|
|
||||||
|
|
||||||
> 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/LINUXexpert-org/ihasmail/releases/tag/stalwart-0.15-support).
|
||||||
|
- Upgrading? [stalwart-migrator](https://github.com/LINUXexpert-org/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
|
```bash
|
||||||
# 1) Configure environment
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit JMAP_BASE, CALDAV_BASE, WEBDAV_BASE, APP_SECRET
|
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
|
||||||
|
|
||||||
# 2) Build & run
|
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
|
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
|
||||||
# 3) Reverse proxy (Nginx/Caddy) to http://127.0.0.1:8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
Users sign in with their Stalwart mailbox credentials. **An account with
|
||||||
- `APP_SECRET` – random string for signing cookies (required)
|
two-factor authentication needs an app password**, created in Stalwart's own
|
||||||
- `JMAP_BASE` – e.g., `https://mail.example.com/jmap`
|
settings — Stalwart accepts a TOTP code only through an OAuth flow and offers no
|
||||||
- `CALDAV_BASE` – e.g., `https://mail.example.com/caldav/`
|
password grant, so no client holding a username and password can exchange them
|
||||||
- `WEBDAV_BASE` – e.g., `https://mail.example.com/webdav/`
|
plus a code for a token.
|
||||||
- `COOKIE_NAME` – cookie name (default: `stalwart_webmail`)
|
|
||||||
- `TRUST_PROXY` – `1` to honor `X-Forwarded-*` (default: `1`)
|
Full instructions, TLS, and every environment variable:
|
||||||
- `UPSTREAM_TIMEOUT` – seconds for upstream HTTP (default: `15`)
|
[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
|
```bash
|
||||||
python -m venv .venv && source .venv/bin/activate
|
docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||||
pip install -e ".[dev]"
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
pytest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security & Hardening
|
`IMMUTABLE=1` is an assertion the server checks at startup rather than a switch
|
||||||
- Prefer **bearer tokens** if Stalwart issues them; update `jmap_session()` to store `accessToken`
|
that changes what it does: it refuses to start if `SESSION_FILE` is still set,
|
||||||
- Set explicit `accountId` from the JMAP session `primaryAccounts`
|
or if the filesystem it is installed on turns out to be writable after all.
|
||||||
- Add mailbox/folder navigation via `Mailbox/query` + `Mailbox/get`
|
Without it the same misconfiguration is silent — sessions are held in memory
|
||||||
- Sanitize HTML bodies (e.g., `bleach`) before rendering
|
and persisting them is best-effort, so a read-only `/data` costs one warning at
|
||||||
- Add Sieve UI via `urn:ietf:params:jmap:sieve`
|
the first sign-in and nothing else until the instance is replaced and everyone
|
||||||
- Consider rate limiting and security headers in the reverse proxy
|
is signed out.
|
||||||
- Serve static assets via proxy/CDN
|
|
||||||
|
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 v2.16.84` — `2` is ihasmail's own major, `16` the Stalwart generation
|
||||||
|
this build targets, `84` the pull request the commit came from. The first two
|
||||||
|
live in the root `package.json`; the third comes from git at build time, since
|
||||||
|
it does not exist until the PR has merged. A commit that did not arrive through
|
||||||
|
a PR carries the last number plus its short SHA — `2.16.84+g1fa6578`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/version.mjs # the version for the current checkout
|
||||||
|
docker build --build-arg IHASMAIL_VERSION="$(node scripts/version.mjs)" -t ihasmail:2.16 .
|
||||||
|
```
|
||||||
|
|
||||||
|
`.dockerignore` excludes `.git` deliberately, so an image build cannot work this
|
||||||
|
out for itself — pass it in. Left out, the build falls back to the base version
|
||||||
|
from `package.json`, so a version with no PR number means whoever built the
|
||||||
|
image did not pass one.
|
||||||
|
|
||||||
|
### 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 v2.16.84 --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
|
## License
|
||||||
GPL-3.0-or-later
|
|
||||||
|
Copyright (C) 2026 LINUXexpert.org — 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,12 @@
|
|||||||
|
# Roadmap / not yet
|
||||||
|
|
||||||
|
Things ihasmail does not do, and why. Anything with an issue number is tracked
|
||||||
|
in [the issue tracker](https://github.com/LINUXexpert-org/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. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
|
||||||
@@ -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,239 @@
|
|||||||
|
#!/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 runs both guards, says what it would deploy, and stops before
|
||||||
|
# building or touching the container.
|
||||||
|
#
|
||||||
|
# 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) sed -n '2,28p' "$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/^/ /'
|
||||||
|
if [ "$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
|
||||||
|
else
|
||||||
|
echo "==> already at $(git log --oneline -1 "$TARGET"); rebuilding"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo "==> dry run: would deploy $(git log --oneline -1 "$TARGET"); nothing was changed"
|
||||||
|
exit 0
|
||||||
|
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 "+", which a version for a commit that did not
|
||||||
|
# come through a pull request does: 2.16.57+g1fa6578. 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:
|
services:
|
||||||
ihasmail:
|
ihasmail:
|
||||||
build: .
|
build: .
|
||||||
image: ihasmail:latest
|
image: ihasmail:2
|
||||||
env_file: .env
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks: [edge]
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8000"
|
- "8080:8080"
|
||||||
networks:
|
environment:
|
||||||
edge: {}
|
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/LINUXexpert-org/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/claude-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/claude-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": "2.16.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,4 @@
|
|||||||
|
/** Types for `version.mjs`, which is plain JS so the Dockerfile and shell can run it directly. */
|
||||||
|
export function baseVersion(): string;
|
||||||
|
export function versionFromGit(): string | null;
|
||||||
|
export function resolveVersion(): string;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Work out this build's version: `2.16.57`.
|
||||||
|
*
|
||||||
|
* 2 ihasmail's own major
|
||||||
|
* 16 the Stalwart major this build targets — 0.16, the oldest it supports
|
||||||
|
* 57 the pull request the checked-out commit came from
|
||||||
|
*
|
||||||
|
* The first two are the `version` in the root package.json, so there is one
|
||||||
|
* place to bump them; the third is read from git, because it does not exist
|
||||||
|
* until the pull request has actually merged. 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.
|
||||||
|
*
|
||||||
|
* A commit that did not arrive through a pull request has no number of its
|
||||||
|
* own, so it carries the last one plus its own short SHA — `2.16.57+g1fa6578`
|
||||||
|
* — which is honest about being past that PR rather than silently claiming to
|
||||||
|
* be it.
|
||||||
|
*
|
||||||
|
* `.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 { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
/** "2.16" — ihasmail major and the Stalwart major this build is built for. */
|
||||||
|
export function baseVersion() {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||||
|
const [major, minor] = String(pkg.version).split(".");
|
||||||
|
return `${major}.${minor}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
try {
|
||||||
|
head = git("rev-parse", "--short", "HEAD");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const base = baseVersion();
|
||||||
|
try {
|
||||||
|
// Walk back over first parents: a merge commit's subject names its PR, and
|
||||||
|
// anything after the newest one is work that has not been through one.
|
||||||
|
const log = git("log", "--first-parent", "--format=%H%x00%s", "-n", "200");
|
||||||
|
const commits = log ? log.split("\n").map((l) => l.split("\0")) : [];
|
||||||
|
for (const [sha, subject = ""] of commits) {
|
||||||
|
const pr = PR_SUBJECT.exec(subject)?.[1];
|
||||||
|
if (!pr) continue;
|
||||||
|
// The PR's own merge commit is the version; anything above it is past it.
|
||||||
|
const exact = sha.startsWith(git("rev-parse", "HEAD"));
|
||||||
|
return exact ? `${base}.${pr}` : `${base}.${pr}+g${head}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* a shallow clone, or no history to read */
|
||||||
|
}
|
||||||
|
return `${base}.0+g${head}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whatever the environment was told, else git, else just the base. */
|
||||||
|
export function resolveVersion() {
|
||||||
|
const fromEnv = process.env.IHASMAIL_VERSION?.trim();
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
return versionFromGit() ?? `${baseVersion()}.0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `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,37 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
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, {
|
||||||
|
headers: { authorization: session.authorization },
|
||||||
|
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 = res.headers.get("content-length");
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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/LINUXexpert-org/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,999 @@
|
|||||||
|
/**
|
||||||
|
* A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail
|
||||||
|
* uses. For local development and demos only: `npm run mock` then point the
|
||||||
|
* server at it with STALWART_URL=http://127.0.0.1:8788 (user: demo / pass: demo).
|
||||||
|
*/
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||||
|
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||||
|
/**
|
||||||
|
* Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested
|
||||||
|
* against a server ihasmail does not support. This is only that: the rest of
|
||||||
|
* the mock still behaves like 0.16. Emulating 0.15 properly went with the
|
||||||
|
* support for it.
|
||||||
|
*/
|
||||||
|
const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1";
|
||||||
|
/**
|
||||||
|
* Stalwart advertises FUTURERELEASE in the session but only honours it when
|
||||||
|
* the MTA's own `futureRelease` setting is on -- and that setting defaults to
|
||||||
|
* off, in which case the hold is dropped without a word and the message goes
|
||||||
|
* out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap.
|
||||||
|
*/
|
||||||
|
const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
|
||||||
|
/** What the session advertises, matching Stalwart's own 30 days. */
|
||||||
|
const MAX_DELAYED_SEND = 86400 * 30;
|
||||||
|
const ACCOUNT = "a1";
|
||||||
|
/** An account somebody has shared with the demo user. See the session below. */
|
||||||
|
const SHARED_ACCOUNT = "a2";
|
||||||
|
const SHARED_CAPS: Obj = {
|
||||||
|
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
|
||||||
|
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
|
||||||
|
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
|
||||||
|
};
|
||||||
|
const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||||
|
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||||
|
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||||
|
const PASS = process.env.MOCK_PASS ?? "demo";
|
||||||
|
/**
|
||||||
|
* Credential state, mutable so the self-service flows can be exercised against
|
||||||
|
* the mock the way they run against a real 0.16 server: the password changes,
|
||||||
|
* 2FA starts demanding a code on every request, and app passwords keep working
|
||||||
|
* without one.
|
||||||
|
*/
|
||||||
|
export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] };
|
||||||
|
const MASKED = "[********]";
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
const state = { n: 1 };
|
||||||
|
const nextState = () => String(state.n++);
|
||||||
|
|
||||||
|
/* ---------- data ---------- */
|
||||||
|
/*
|
||||||
|
* The names are Stalwart's own defaults, which follow the Exchange convention:
|
||||||
|
* "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the
|
||||||
|
* short forms, so anything built from a folder's name read differently here
|
||||||
|
* than in production -- "Empty Trash" against the mock, "Empty Deleted Items"
|
||||||
|
* against a real server -- and every screenshot in the README showed a folder
|
||||||
|
* list no user has. The role is what the client branches on; the name is only
|
||||||
|
* ever displayed, which is exactly why it has to look right.
|
||||||
|
*/
|
||||||
|
/** Push subscriptions, as a fresh account has none. */
|
||||||
|
const pushSubscriptions: Obj[] = [];
|
||||||
|
|
||||||
|
const mailboxes: Obj[] = [
|
||||||
|
mb("inbox", "Inbox", "inbox"),
|
||||||
|
mb("drafts", "Drafts", "drafts"),
|
||||||
|
mb("sent", "Sent Items", "sent"),
|
||||||
|
mb("junk", "Junk Mail", "junk"),
|
||||||
|
mb("trash", "Deleted Items", "trash"),
|
||||||
|
mb("archive", "Archive", "archive"),
|
||||||
|
mb("work", "Work", null),
|
||||||
|
mb("work-inv", "Invoices", null, "work"),
|
||||||
|
mb("news", "Newsletters", null),
|
||||||
|
];
|
||||||
|
function mb(id: string, name: string, role: string | null, parentId: string | null = null): Obj {
|
||||||
|
return { id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobs = new Map<string, { type: string; data: Buffer }>();
|
||||||
|
function putBlob(data: Buffer | string, type: string): string {
|
||||||
|
const id = `b${randomUUID().slice(0, 8)}`;
|
||||||
|
blobs.set(id, { type, data: Buffer.isBuffer(data) ? data : Buffer.from(data) });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = [
|
||||||
|
["Ada Lovelace", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
|
||||||
|
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
|
||||||
|
["Stalwart Labs", "[email protected]"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||||||
|
];
|
||||||
|
const subjects = [
|
||||||
|
"Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff",
|
||||||
|
"Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?",
|
||||||
|
"Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in",
|
||||||
|
];
|
||||||
|
const emails: Obj[] = [];
|
||||||
|
let counter = 1;
|
||||||
|
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; inReplyTo?: string }) {
|
||||||
|
const id = `e${counter++}`;
|
||||||
|
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||||
|
const html = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||||
|
const textBlob = putBlob(text, "text/plain");
|
||||||
|
const htmlBlob = putBlob(html, "text/html");
|
||||||
|
const attachments: Obj[] = [];
|
||||||
|
if (o.attach) {
|
||||||
|
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
|
||||||
|
attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null });
|
||||||
|
}
|
||||||
|
if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" });
|
||||||
|
const e: Obj = {
|
||||||
|
id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"),
|
||||||
|
threadId: o.threadId ?? `t${id}`, mailboxIds: { [o.mailbox]: true },
|
||||||
|
keywords: { ...(o.unread ? {} : { $seen: true }), ...(o.flagged ? { $flagged: true } : {}) },
|
||||||
|
size: 4000 + Math.floor(Math.random() * 20000), receivedAt: received, sentAt: received,
|
||||||
|
messageId: [`${id}@mock`], inReplyTo: o.inReplyTo ? [o.inReplyTo] : null, references: o.inReplyTo ? [o.inReplyTo] : null,
|
||||||
|
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
|
||||||
|
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
|
||||||
|
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||||
|
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [],
|
||||||
|
attachments,
|
||||||
|
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: html, isEncodingProblem: false, isTruncated: false } } : {}) },
|
||||||
|
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
|
||||||
|
"header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : null,
|
||||||
|
"header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null,
|
||||||
|
};
|
||||||
|
emails.push(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
// Seed
|
||||||
|
for (let i = 0; i < 45; i++) {
|
||||||
|
const p = people[i % people.length]!;
|
||||||
|
const subj = subjects[i % subjects.length]!;
|
||||||
|
const e = addEmail({ from: [p[0]!, p[1]!], subject: subj, daysAgo: i * 0.7, mailbox: i % 9 === 8 ? "news" : i % 11 === 10 ? "work" : "inbox", unread: i % 3 === 0, flagged: i % 7 === 0, html: i % 2 === 0, attach: i % 5 === 0 });
|
||||||
|
if (i % 4 === 0) {
|
||||||
|
// thread replies
|
||||||
|
addEmail({ from: ["Demo User", USER], to: p[1]!, subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.2, mailbox: "sent", threadId: e.threadId as string, inReplyTo: `${e.id}@mock`, html: true });
|
||||||
|
addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||||||
|
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||||
|
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||||||
|
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
|
||||||
|
// A thread whose unread message is not the last one: someone's server queued
|
||||||
|
// their reply for hours, so it landed after messages that answer it and sits in
|
||||||
|
// the middle of the conversation. Opening this thread at the newest message
|
||||||
|
// left that reply above the fold until the mark-read timer swept it (#87).
|
||||||
|
{
|
||||||
|
const subj = "Compiler timings for the release";
|
||||||
|
const t = addEmail({ from: ["Grace Hopper", "[email protected]"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true });
|
||||||
|
const tid = t.threadId as string;
|
||||||
|
const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) =>
|
||||||
|
addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` });
|
||||||
|
reply({ from: ["Alan Turing", "[email protected]"], daysAgo: 5.5, mailbox: "inbox", unread: true });
|
||||||
|
// Long enough after the unread one that the thread scrolls: opening at the
|
||||||
|
// bottom put four messages between the reader and the mail they had not read.
|
||||||
|
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 5, mailbox: "sent", html: true });
|
||||||
|
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 4.5, mailbox: "inbox" });
|
||||||
|
reply({ from: ["Margaret Hamilton", "[email protected]"], daysAgo: 4, mailbox: "inbox", html: true });
|
||||||
|
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 3.5, mailbox: "sent" });
|
||||||
|
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 3, mailbox: "inbox", html: true });
|
||||||
|
}
|
||||||
|
// Invitation email
|
||||||
|
{
|
||||||
|
const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:[email protected]\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||||
|
const e = addEmail({ from: ["Ada Lovelace", "[email protected]"], subject: "Invitation: Project kickoff", daysAgo: 0.3, mailbox: "inbox", unread: true });
|
||||||
|
const b = putBlob(ics, "text/calendar");
|
||||||
|
(e.bodyStructure as Obj).subParts = [...((e.bodyStructure as Obj).subParts as Obj[]), { partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }];
|
||||||
|
(e.attachments as Obj[]).push({ partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null });
|
||||||
|
e.hasAttachment = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identities: Obj[] = [
|
||||||
|
{ id: "i1", name: "Demo User", email: USER, replyTo: null, bcc: null, textSignature: "-- \nDemo User\nihasmail", htmlSignature: "<div>-- <br><b>Demo User</b><br>ihasmail</div>", mayDelete: false },
|
||||||
|
{ id: "i2", name: "Demo (alias)", email: "[email protected]", replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true },
|
||||||
|
];
|
||||||
|
let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null };
|
||||||
|
const sieveScripts: Obj[] = [];
|
||||||
|
/* A calendar in the shared account, so "Shared with me" and a colleague's
|
||||||
|
events appearing in the grid can be exercised. Read-only, as a share is. */
|
||||||
|
const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }];
|
||||||
|
const sharedEvents: Obj[] = [];
|
||||||
|
const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events);
|
||||||
|
const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars);
|
||||||
|
const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }];
|
||||||
|
function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; }
|
||||||
|
const events: Obj[] = [];
|
||||||
|
{
|
||||||
|
const now = new Date();
|
||||||
|
const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; };
|
||||||
|
const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`;
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
|
||||||
|
events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` });
|
||||||
|
events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null });
|
||||||
|
events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" });
|
||||||
|
// Two in the shared account, so a colleague's calendar has something in it.
|
||||||
|
sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" });
|
||||||
|
sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null });
|
||||||
|
}
|
||||||
|
const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
|
||||||
|
const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write });
|
||||||
|
const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }];
|
||||||
|
/* A book in the shared account, so "Shared with me" and addressing a message
|
||||||
|
from somebody else's contacts can be exercised at all. Read-only, which is
|
||||||
|
what a share usually is. */
|
||||||
|
const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }];
|
||||||
|
const sharedCards: Obj[] = [
|
||||||
|
{ id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||||
|
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||||
|
];
|
||||||
|
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
|
||||||
|
const cards: Obj[] = people.slice(0, 6).map((p, i) => {
|
||||||
|
const [given, surname] = p[0]!.split(" ");
|
||||||
|
return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined };
|
||||||
|
});
|
||||||
|
const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
|
||||||
|
const fileNodes: Obj[] = [
|
||||||
|
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" },
|
||||||
|
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||||
|
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* What the shared account holds. Its own nodes, so opening the share in Files
|
||||||
|
shows something different from the reader's own folders rather than the same
|
||||||
|
list under another name. */
|
||||||
|
const sharedFileNodes: Obj[] = [
|
||||||
|
{ id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||||
|
{ id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
|
||||||
|
];
|
||||||
|
/** The node list an account owns. */
|
||||||
|
const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes);
|
||||||
|
|
||||||
|
function fr() {
|
||||||
|
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recount() {
|
||||||
|
for (const m of mailboxes) {
|
||||||
|
const inBox = emails.filter((e) => (e.mailboxIds as Obj)[m.id as string]);
|
||||||
|
m.totalEmails = inBox.length;
|
||||||
|
m.unreadEmails = inBox.filter((e) => !(e.keywords as Obj).$seen).length;
|
||||||
|
const threads = new Set(inBox.map((e) => e.threadId));
|
||||||
|
m.totalThreads = threads.size;
|
||||||
|
m.unreadThreads = new Set(inBox.filter((e) => !(e.keywords as Obj).$seen).map((e) => e.threadId)).size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recount();
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
function pick(o: Obj, props?: string[] | null): Obj {
|
||||||
|
if (!props) return o;
|
||||||
|
const out: Obj = { id: o.id };
|
||||||
|
for (const p of props) if (p in o) out[p] = o[p];
|
||||||
|
else if (p.startsWith("header:")) out[p] = null;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record<string, string>): Obj {
|
||||||
|
const out: Obj = {};
|
||||||
|
for (const [k, v] of Object.entries(args)) {
|
||||||
|
if (k.startsWith("#")) {
|
||||||
|
const r = v as { resultOf: string; name: string; path: string };
|
||||||
|
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
|
||||||
|
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
|
||||||
|
} else out[k] = resolveCreationIds(v, creations, k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
|
||||||
|
* go, pointing at something created earlier in the same request. Sending a
|
||||||
|
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
|
||||||
|
* without this the mock quietly declines to create any submission at all.
|
||||||
|
*
|
||||||
|
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
|
||||||
|
* the method that receives them resolves them itself.
|
||||||
|
*/
|
||||||
|
function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
|
||||||
|
if (key === "onSuccessUpdateEmail") return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const out: Obj = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Obj)) {
|
||||||
|
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
|
||||||
|
out[nk] = resolveCreationIds(v, creations, k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function jsonPointer(obj: unknown, path: string): unknown {
|
||||||
|
const parts = path.split("/").filter(Boolean);
|
||||||
|
let cur: unknown = obj;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const p = parts[i]!;
|
||||||
|
if (p === "*") {
|
||||||
|
const rest = parts.slice(i + 1).join("/");
|
||||||
|
const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; });
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
cur = (cur as Obj)?.[p];
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
function matchFilter(e: Obj, f: Obj | undefined): boolean {
|
||||||
|
if (!f) return true;
|
||||||
|
if (f.operator) {
|
||||||
|
const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c));
|
||||||
|
return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean);
|
||||||
|
}
|
||||||
|
const kw = e.keywords as Obj;
|
||||||
|
if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false;
|
||||||
|
if (f.hasKeyword && !kw[f.hasKeyword as string]) return false;
|
||||||
|
if (f.notKeyword && kw[f.notKeyword as string]) return false;
|
||||||
|
if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false;
|
||||||
|
const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase();
|
||||||
|
for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false;
|
||||||
|
if (f.before && String(e.receivedAt) >= String(f.before)) return false;
|
||||||
|
if (f.after && String(e.receivedAt) < String(f.after)) return false;
|
||||||
|
if (f.minSize && Number(e.size) < Number(f.minSize)) return false;
|
||||||
|
if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function applyPatch(obj: Obj, patch: Obj) {
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k.includes("/")) {
|
||||||
|
const [root, ...rest] = k.split("/");
|
||||||
|
const key = rest.join("/");
|
||||||
|
const target = (obj[root!] as Obj) ?? {};
|
||||||
|
if (v === null) delete target[key];
|
||||||
|
else target[key] = v;
|
||||||
|
obj[root!] = target;
|
||||||
|
} else obj[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- method handlers ---------- */
|
||||||
|
type Handler = (args: Obj) => Obj | [string, Obj][];
|
||||||
|
/** A method-level failure, surfaced as ["error", {type, description}, id]. */
|
||||||
|
class MethodError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly type: string,
|
||||||
|
description?: string,
|
||||||
|
) {
|
||||||
|
super(description ?? type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_OBJECTS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart refuses a whole method call that carries more objects than it will
|
||||||
|
* process at once - it does not quietly handle the first 500. Enforce the same
|
||||||
|
* ceiling the session advertises, so an unbatched client fails here too.
|
||||||
|
*/
|
||||||
|
function enforceLimits(name: string, args: Obj): void {
|
||||||
|
const tooLarge = () => {
|
||||||
|
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
|
||||||
|
};
|
||||||
|
if (name.endsWith("/get")) {
|
||||||
|
const ids = args.ids as unknown[] | null | undefined;
|
||||||
|
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
|
||||||
|
}
|
||||||
|
if (name.endsWith("/set")) {
|
||||||
|
const n =
|
||||||
|
Object.keys((args.create as Obj) ?? {}).length +
|
||||||
|
Object.keys((args.update as Obj) ?? {}).length +
|
||||||
|
((args.destroy as unknown[] | undefined)?.length ?? 0);
|
||||||
|
if (n > MAX_OBJECTS) tooLarge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stalwart does not return `shareWith` unless a client asks for it by name: a
|
||||||
|
* `/get` with no `properties` comes back without the field at all. Confirmed on
|
||||||
|
* 0.16.19 (2026-08-27) against a calendar and an address book that really were
|
||||||
|
* shared. The mock handing it over unasked meant a client that never asked
|
||||||
|
* still saw every share, and the one place that did not -- the real server --
|
||||||
|
* showed nothing shared at all.
|
||||||
|
*/
|
||||||
|
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||||
|
if (a.properties) return res;
|
||||||
|
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericGet(list: Obj[]) {
|
||||||
|
return (a: Obj) => {
|
||||||
|
const ids = a.ids as string[] | null | undefined;
|
||||||
|
const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list;
|
||||||
|
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
||||||
|
class SetError extends Error {
|
||||||
|
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
||||||
|
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||||
|
return (a: Obj) => {
|
||||||
|
const created: Obj = {};
|
||||||
|
const updated: Obj = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
const id = `${prefix}${randomUUID().slice(0, 6)}`;
|
||||||
|
const o = { ...(obj as Obj), id };
|
||||||
|
try {
|
||||||
|
onCreate?.(o);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof SetError)) throw err;
|
||||||
|
notCreated[cid] = err.toJSON();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
list.push(o);
|
||||||
|
created[cid] = { id };
|
||||||
|
}
|
||||||
|
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
||||||
|
const o = list.find((x) => x.id === id);
|
||||||
|
if (o) { applyPatch(o, patch as Obj); updated[id] = null; }
|
||||||
|
}
|
||||||
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
|
const i = list.findIndex((x) => x.id === id);
|
||||||
|
if (i >= 0) { list.splice(i, 1); destroyed.push(id); }
|
||||||
|
}
|
||||||
|
return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- submissions ---------- */
|
||||||
|
/**
|
||||||
|
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
||||||
|
* envelope's FUTURERELEASE parameter rather than set by the client, and
|
||||||
|
* `undoStatus` reports whether the message is still in the queue.
|
||||||
|
*/
|
||||||
|
const submissions: Obj[] = [];
|
||||||
|
|
||||||
|
function submissionView(sub: Obj): Obj {
|
||||||
|
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
|
||||||
|
if (!f) return true;
|
||||||
|
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
|
||||||
|
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
|
||||||
|
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers: Record<string, Handler> = {
|
||||||
|
// 0.16 exposes the account locale here, under a permission ordinary users
|
||||||
|
// actually have (unlike x:Account below, which needs sysAccountGet).
|
||||||
|
"x:AccountSettings/get": (a) => {
|
||||||
|
const ids = (a.ids as string[] | null) ?? ["singleton"];
|
||||||
|
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
|
||||||
|
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
|
||||||
|
},
|
||||||
|
// Stalwart's directory extension - the client reads the account locale from here.
|
||||||
|
"x:Account/get": (a) => {
|
||||||
|
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
|
||||||
|
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
|
||||||
|
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
|
||||||
|
},
|
||||||
|
"Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never,
|
||||||
|
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||||
|
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||||
|
"Email/query": (a) => {
|
||||||
|
let list = emails.filter((e) => matchFilter(e, a.filter as Obj));
|
||||||
|
list.sort((x, y) => String(y.receivedAt).localeCompare(String(x.receivedAt)));
|
||||||
|
if (a.collapseThreads) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; });
|
||||||
|
}
|
||||||
|
const pos = Number(a.position ?? 0);
|
||||||
|
const limit = Number(a.limit ?? 50);
|
||||||
|
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit };
|
||||||
|
},
|
||||||
|
"Email/get": (a) => genericGet(emails)(a),
|
||||||
|
/*
|
||||||
|
* Real changes, not an empty answer.
|
||||||
|
*
|
||||||
|
* This used to return three empty arrays whatever had happened, so the
|
||||||
|
* client's whole reconciliation path -- `Email/changes`, then deciding what
|
||||||
|
* to do with what came back -- never ran against the mock. A bug living in
|
||||||
|
* that path could not be reproduced here at all, which is how one reached
|
||||||
|
* production and survived being "fixed" once (#100). The log below is what
|
||||||
|
* the real server can answer from.
|
||||||
|
*/
|
||||||
|
"Email/changes": (a) => {
|
||||||
|
const since = Number(a.sinceState ?? 0);
|
||||||
|
const relevant = emailChanges.filter((c) => c.state > since);
|
||||||
|
const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))];
|
||||||
|
return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") };
|
||||||
|
},
|
||||||
|
"Email/set": (a) => {
|
||||||
|
const r = genericSet(emails, "e", (o) => {
|
||||||
|
const bv = (o.bodyValues as Record<string, { value: string }>) ?? {};
|
||||||
|
const walk = (p: Obj | undefined, acc: Obj[]) => { if (!p) return; if (p.partId && bv[p.partId as string]) acc.push({ ...p, blobId: putBlob(bv[p.partId as string]!.value, p.type as string), size: bv[p.partId as string]!.value.length }); (p.subParts as Obj[] | undefined)?.forEach((s) => walk(s, acc)); };
|
||||||
|
const parts: Obj[] = [];
|
||||||
|
walk(o.bodyStructure as Obj, parts);
|
||||||
|
o.textBody = parts.filter((p) => p.type === "text/plain");
|
||||||
|
o.htmlBody = parts.filter((p) => p.type === "text/html");
|
||||||
|
o.attachments = [];
|
||||||
|
const collect = (p: Obj | undefined) => { if (!p) return; if (p.blobId && !p.partId && p.type !== "multipart/mixed") (o.attachments as Obj[]).push({ ...p, size: p.size ?? 0 }); (p.subParts as Obj[] | undefined)?.forEach(collect); };
|
||||||
|
collect(o.bodyStructure as Obj);
|
||||||
|
o.hasAttachment = (o.attachments as Obj[]).length > 0;
|
||||||
|
o.threadId = o.inReplyTo ? (emails.find((e) => (e.messageId as string[] | null)?.[0] === (o.inReplyTo as string[])[0])?.threadId ?? `t${o.id}`) : `t${o.id}`;
|
||||||
|
o.receivedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
o.size = 2000;
|
||||||
|
o.preview = (bv.text?.value ?? "").slice(0, 100);
|
||||||
|
o.messageId = [`${o.id}@mock`];
|
||||||
|
o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822");
|
||||||
|
})(a);
|
||||||
|
recount();
|
||||||
|
nextState();
|
||||||
|
recordEmailChange({
|
||||||
|
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).map((x) => x.id),
|
||||||
|
updated: Object.keys((a.update as Obj) ?? {}),
|
||||||
|
destroyed: (r.destroyed as string[] | undefined) ?? [],
|
||||||
|
});
|
||||||
|
/* A real server pushes a state change after a set, and the client acts on
|
||||||
|
it -- `Email/changes` runs and the store reconciles what came back. The
|
||||||
|
mock stayed silent, so that whole path never ran here and a bug living
|
||||||
|
in it could not be reproduced: marking a message read went round the
|
||||||
|
server and back on the live instance, and did nothing at all on the mock
|
||||||
|
(#100). Announced now, the way Stalwart does. */
|
||||||
|
broadcast(["Email", "Mailbox", "Thread"]);
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
|
||||||
|
"Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; },
|
||||||
|
// Stalwart 0.16 registry objects backing self-service credentials.
|
||||||
|
"x:AccountPassword/get": () => ({
|
||||||
|
accountId: ACCOUNT,
|
||||||
|
state: String(state.n),
|
||||||
|
list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }],
|
||||||
|
notFound: [],
|
||||||
|
}),
|
||||||
|
"x:AccountPassword/set": (a) => {
|
||||||
|
const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined;
|
||||||
|
if (!patch) return setResp({ updated: {} });
|
||||||
|
const current = patch.currentSecret as string | undefined;
|
||||||
|
const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined;
|
||||||
|
if (!current) {
|
||||||
|
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } });
|
||||||
|
}
|
||||||
|
if (current !== account.password) {
|
||||||
|
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
|
||||||
|
}
|
||||||
|
if (account.otpUrl && !code) {
|
||||||
|
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } });
|
||||||
|
}
|
||||||
|
if (account.otpUrl && !checkOtp(code!)) {
|
||||||
|
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
|
||||||
|
}
|
||||||
|
const secret = patch.secret as string | undefined;
|
||||||
|
if (secret !== undefined && secret !== MASKED) {
|
||||||
|
if (secret.length < 8) {
|
||||||
|
return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } });
|
||||||
|
}
|
||||||
|
account.password = secret;
|
||||||
|
}
|
||||||
|
if ("otpAuth/otpUrl" in patch) {
|
||||||
|
const url = patch["otpAuth/otpUrl"] as string | null;
|
||||||
|
if (url !== MASKED) account.otpUrl = url;
|
||||||
|
}
|
||||||
|
state.n++;
|
||||||
|
return setResp({ updated: { singleton: null } });
|
||||||
|
},
|
||||||
|
/*
|
||||||
|
* Push subscriptions. The JMAP half can be modelled; delivery cannot -- that
|
||||||
|
* runs through the browser vendor's real push service, so nothing local will
|
||||||
|
* ever make a notification appear.
|
||||||
|
*
|
||||||
|
* What is worth reproducing is the handshake, because it is the part that
|
||||||
|
* fails quietly: a subscription is created unverified and stays silent until
|
||||||
|
* the client echoes back a code the server pushed. A mock that marked one
|
||||||
|
* verified on creation would let a client ship without ever implementing
|
||||||
|
* that, and the symptom in production is "registered, and no notifications".
|
||||||
|
*/
|
||||||
|
"PushSubscription/get": (a) => {
|
||||||
|
const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string);
|
||||||
|
const list = pushSubscriptions.filter((s) => ids.includes(s.id as string));
|
||||||
|
// `keys` is write-only in JMAP: the server never hands it back.
|
||||||
|
return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) };
|
||||||
|
},
|
||||||
|
"PushSubscription/set": (a) => {
|
||||||
|
const created: Obj = {};
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
const updated: Obj = {};
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
const o = obj as Obj;
|
||||||
|
const keys = (o.keys ?? {}) as Obj;
|
||||||
|
// Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push
|
||||||
|
// API produces; padding it would be the client inventing a shape.
|
||||||
|
for (const k of ["p256dh", "auth"]) {
|
||||||
|
const v = String(keys[k] ?? "");
|
||||||
|
if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; }
|
||||||
|
if (v.includes("=") || v.includes("+") || v.includes("/")) {
|
||||||
|
notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notCreated[cid]) continue;
|
||||||
|
if (!String(o.url ?? "").startsWith("https://")) {
|
||||||
|
notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A filter condition with a null value is not a filter -- the real server
|
||||||
|
// answers "Invalid filter" and refuses the whole subscription. ihasmail
|
||||||
|
// shipped `inMailbox: null` meaning "the inbox", which meant nothing at
|
||||||
|
// all here, and the mock accepted it happily. It does not any more.
|
||||||
|
const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => {
|
||||||
|
const f = ((cfg as Obj)?.filter ?? {}) as Obj;
|
||||||
|
return Object.values(f).some((v) => v === null || v === undefined);
|
||||||
|
});
|
||||||
|
if (badFilter) {
|
||||||
|
notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// One per device: re-subscribing replaces rather than accumulates.
|
||||||
|
const deviceId = String(o.deviceClientId ?? "");
|
||||||
|
const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId);
|
||||||
|
if (clash >= 0) pushSubscriptions.splice(clash, 1);
|
||||||
|
const id = `ps${randomUUID().slice(0, 6)}`;
|
||||||
|
pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires: null, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` });
|
||||||
|
created[cid] = { id, expires: null };
|
||||||
|
state.n++;
|
||||||
|
}
|
||||||
|
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
||||||
|
const s = pushSubscriptions.find((x) => x.id === id);
|
||||||
|
if (!s) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||||
|
const code = (patch as Obj).verificationCode;
|
||||||
|
if (code !== undefined) {
|
||||||
|
if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; }
|
||||||
|
s.verified = true;
|
||||||
|
}
|
||||||
|
updated[id] = null;
|
||||||
|
state.n++;
|
||||||
|
}
|
||||||
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
|
const i = pushSubscriptions.findIndex((x) => x.id === id);
|
||||||
|
if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; }
|
||||||
|
}
|
||||||
|
return setResp({ created, notCreated, updated, notUpdated, destroyed });
|
||||||
|
},
|
||||||
|
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
||||||
|
"x:AppPassword/set": (a) => {
|
||||||
|
const created: Obj = {};
|
||||||
|
const destroyed: string[] = [];
|
||||||
|
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
const id = `ap${randomUUID().slice(0, 6)}`;
|
||||||
|
// Real app passwords carry their credential id, so the server can spot
|
||||||
|
// one by its shape alone. Mirror that.
|
||||||
|
const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
||||||
|
const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret };
|
||||||
|
account.appPasswords.push(row);
|
||||||
|
created[cid] = { id, secret, createdAt: row.createdAt };
|
||||||
|
}
|
||||||
|
for (const id of (a.destroy as string[]) ?? []) {
|
||||||
|
const i = account.appPasswords.findIndex((x) => x.id === id);
|
||||||
|
if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); }
|
||||||
|
}
|
||||||
|
state.n++;
|
||||||
|
return setResp({ created, destroyed });
|
||||||
|
},
|
||||||
|
"Identity/get": genericGet(identities),
|
||||||
|
"Identity/set": (a) => {
|
||||||
|
// Stalwart's cap is `value.len() < 2048` on a Rust string: 2047 bytes of
|
||||||
|
// UTF-8, not characters. Anything longer is refused by name.
|
||||||
|
for (const [where, entries] of [["notCreated", (a.create as Obj) ?? {}], ["notUpdated", (a.update as Obj) ?? {}]] as const) {
|
||||||
|
for (const [key, obj] of Object.entries(entries)) {
|
||||||
|
const over = ["htmlSignature", "textSignature"].find((prop) => {
|
||||||
|
const v = (obj as Obj)[prop];
|
||||||
|
return typeof v === "string" && Buffer.byteLength(v, "utf8") > 2047;
|
||||||
|
});
|
||||||
|
if (over) return setResp({ [where]: { [key]: { type: "invalidProperties", properties: [over], description: "Invalid property." } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a);
|
||||||
|
},
|
||||||
|
"EmailSubmission/get": (a) => {
|
||||||
|
const ids = a.ids as string[] | null | undefined;
|
||||||
|
const found = ids ? ids.map((id) => submissions.find((x) => x.id === id)).filter(Boolean) as Obj[] : submissions;
|
||||||
|
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(submissionView(x), a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !submissions.some((x) => x.id === id)) : [] };
|
||||||
|
},
|
||||||
|
"EmailSubmission/query": (a) => {
|
||||||
|
const list = submissions.filter((s) => matchSubmissionFilter(s, a.filter as Obj | undefined));
|
||||||
|
list.sort((x, y) => String(x.sendAt).localeCompare(String(y.sendAt)));
|
||||||
|
const pos = Number(a.position ?? 0);
|
||||||
|
const limit = Number(a.limit ?? 50);
|
||||||
|
return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((s) => s.id), total: list.length, limit };
|
||||||
|
},
|
||||||
|
"EmailSubmission/set": (a) => {
|
||||||
|
const created: Obj = {};
|
||||||
|
const notCreated: Obj = {};
|
||||||
|
const updated: Obj = {};
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) {
|
||||||
|
const sub = raw as Obj;
|
||||||
|
const emailId = sub.emailId as string;
|
||||||
|
const e = emails.find((x) => x.id === emailId);
|
||||||
|
if (!e) {
|
||||||
|
notCreated[cid] = { type: "invalidProperties", properties: ["emailId"], description: "Blob for email not found." };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hold = holdUntilOf(sub.envelope as Obj | undefined, Date.now());
|
||||||
|
if (Number.isNaN(hold)) {
|
||||||
|
notCreated[cid] = { type: "invalidProperties", properties: ["envelope"], description: "Failed to parse mailFrom parameters." };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Stalwart rejects MAIL FROM outright past its own limit.
|
||||||
|
if (hold !== null && hold > Date.now() + MAX_DELAYED_SEND * 1000) {
|
||||||
|
notCreated[cid] = { type: "forbiddenMailFrom", description: `Server rejected MAIL-FROM: 501 5.5.4 Requested release time exceeds maximum of ${new Date(Date.now() + MAX_DELAYED_SEND * 1000).toISOString()}.` };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// With the MTA extension off, the hold is dropped in silence.
|
||||||
|
const sendAt = hold !== null && !NO_FUTURE_RELEASE ? hold : Date.now();
|
||||||
|
const rec: Obj = {
|
||||||
|
id: `s${randomUUID().slice(0, 6)}`,
|
||||||
|
identityId: sub.identityId ?? null,
|
||||||
|
emailId,
|
||||||
|
threadId: e.threadId ?? null,
|
||||||
|
envelope: sub.envelope ?? null,
|
||||||
|
sendAt: new Date(sendAt).toISOString(),
|
||||||
|
undoStatus: null,
|
||||||
|
deliveryStatus: null,
|
||||||
|
};
|
||||||
|
submissions.push(rec);
|
||||||
|
created[cid] = { id: rec.id, sendAt: rec.sendAt, undoStatus: undoStatusOf(rec, Date.now()) };
|
||||||
|
const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined;
|
||||||
|
if (patch) applyPatch(e, patch);
|
||||||
|
}
|
||||||
|
for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) {
|
||||||
|
const patch = raw as Obj;
|
||||||
|
const sub = submissions.find((x) => x.id === id);
|
||||||
|
if (!sub) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||||
|
if (patch.undoStatus !== "canceled") {
|
||||||
|
notUpdated[id] = { type: "invalidProperties", properties: ["undoStatus"], description: "Only cancellation is supported." };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const status = undoStatusOf(sub, Date.now());
|
||||||
|
if (status !== "pending") {
|
||||||
|
notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already cancelled." : "The message has already been sent." };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sub.undoStatus = "canceled";
|
||||||
|
updated[id] = null;
|
||||||
|
}
|
||||||
|
recount();
|
||||||
|
return setResp({
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||||
|
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacation], notFound: [] }),
|
||||||
|
"VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacation = { ...vacation, ...p }; return setResp({ updated: { singleton: null } }); },
|
||||||
|
"Quota/get": () => ({ accountId: ACCOUNT, state: "1", list: [{ id: "q1", resourceType: "octets", used: 734003200, hardLimit: 2147483648, scope: "account", name: "Storage", types: ["Email"] }], notFound: [] }),
|
||||||
|
"SieveScript/get": genericGet(sieveScripts),
|
||||||
|
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
|
||||||
|
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
|
||||||
|
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||||
|
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
|
||||||
|
"CalendarEvent/query": (a) => { const list = eventsFor(a.accountId); return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: list.length }; },
|
||||||
|
"CalendarEvent/get": (a) => genericGet(eventsFor(a.accountId))(a),
|
||||||
|
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||||
|
// participants addressed the RFC 8984 way. The mock did neither, which is how
|
||||||
|
// #26 and #30 reached a live server unnoticed — so it now does both.
|
||||||
|
"CalendarEvent/set": genericSet(events, "ev", (o) => {
|
||||||
|
if (o.recurrenceRules) throw new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]);
|
||||||
|
const parts = o.participants as Record<string, Obj> | undefined;
|
||||||
|
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||||
|
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||||
|
return Object.assign(o, { uid: o.uid ?? randomUUID() });
|
||||||
|
}),
|
||||||
|
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||||
|
"ParticipantIdentity/get": genericGet(participantIdentities),
|
||||||
|
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
|
||||||
|
"Principal/get": genericGet(principals),
|
||||||
|
"Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }),
|
||||||
|
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never,
|
||||||
|
"AddressBook/set": (a) => {
|
||||||
|
/* Stalwart refuses any update to a book shared read-only, `isSubscribed`
|
||||||
|
included -- "You are not allowed to modify this address book", confirmed
|
||||||
|
live on 0.16.19 (2026-08-27) from the account holding the share. A mock
|
||||||
|
that accepted it would have agreed that subscribing works, which is
|
||||||
|
exactly the belief that shipped. Calendars accept the same write; the
|
||||||
|
difference is the server's, not ours. */
|
||||||
|
if (a.accountId === SHARED_ACCOUNT && a.update) {
|
||||||
|
const notUpdated: Obj = {};
|
||||||
|
for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." };
|
||||||
|
return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated };
|
||||||
|
}
|
||||||
|
return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a);
|
||||||
|
},
|
||||||
|
"ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; },
|
||||||
|
"ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
|
||||||
|
"ContactCard/set": genericSet(cards, "cc"),
|
||||||
|
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
|
||||||
|
"FileNode/query": (a) => {
|
||||||
|
const f = (a.filter as Obj) ?? {};
|
||||||
|
const fileNodes = nodesFor(a.accountId);
|
||||||
|
// `nodeType` is a filter 0.16.19 really applies -- checked live on
|
||||||
|
// 2026-08-27, where it returned the two directories out of seven nodes. The
|
||||||
|
// mock ignoring it was worse than not having it: the sidebar tree asks for
|
||||||
|
// directories and was handed files, which it then drew as folders.
|
||||||
|
const list = fileNodes.filter((n) => {
|
||||||
|
if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false;
|
||||||
|
if (f.nodeType && n.nodeType !== f.nodeType) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||||||
|
},
|
||||||
|
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
|
||||||
|
"FileNode/set": (a) => {
|
||||||
|
return genericSet(nodesFor(a.accountId), "f", (o) => {
|
||||||
|
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
|
||||||
|
// Without nodeType, a node is a directory precisely when it carries no
|
||||||
|
// file properties. Keep it internally so query and get stay consistent.
|
||||||
|
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
|
||||||
|
})(a);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- http ---------- */
|
||||||
|
function unauthorized(res: ServerResponse) {
|
||||||
|
res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' });
|
||||||
|
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
|
||||||
|
}
|
||||||
|
function checkOtp(code: string | undefined): boolean {
|
||||||
|
if (!account.otpUrl) return true;
|
||||||
|
const params = parseOtpauthUrl(account.otpUrl);
|
||||||
|
return Boolean(code && params && verifyTotp(params, code));
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAuth(req: IncomingMessage): boolean {
|
||||||
|
const h = req.headers.authorization ?? "";
|
||||||
|
if (!h.startsWith("Basic ")) return false;
|
||||||
|
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||||||
|
const sep = raw.indexOf(":");
|
||||||
|
if (sep < 0) return false;
|
||||||
|
const u = raw.slice(0, sep);
|
||||||
|
const p = raw.slice(sep + 1);
|
||||||
|
if (u !== USER) return false;
|
||||||
|
// App passwords are recognised by shape and skip the second factor, which is
|
||||||
|
// exactly what lets a webmail session survive 2FA being switched on.
|
||||||
|
if (account.appPasswords.some((a) => a.secret === p)) return true;
|
||||||
|
if (!account.otpUrl) return p === account.password;
|
||||||
|
const at = p.lastIndexOf("$");
|
||||||
|
if (at < 0) return false;
|
||||||
|
return p.slice(0, at) === account.password && checkOtp(p.slice(at + 1));
|
||||||
|
}
|
||||||
|
function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||||
|
return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = () => ({
|
||||||
|
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" },
|
||||||
|
"urn:ietf:params:jmap:emailpush": {},
|
||||||
|
"urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
||||||
|
/*
|
||||||
|
* Two accounts: the demo user's own, and one somebody has shared.
|
||||||
|
*
|
||||||
|
* The shared one carries the *same* capability list, because that is what
|
||||||
|
* Stalwart does -- checked on 0.16.19 (2026-08-27), where a shared account
|
||||||
|
* advertised mail, calendars, contacts and the rest, identical to a personal
|
||||||
|
* one, whatever had actually been shared. Giving the mock a truthful shared
|
||||||
|
* account is the only way to exercise the Files "Shared with me" list, and
|
||||||
|
* the only way this stays honest about what can be inferred from a
|
||||||
|
* capability, which is nothing.
|
||||||
|
*/
|
||||||
|
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||||
|
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
|
||||||
|
username: USER,
|
||||||
|
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||||
|
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||||
|
uploadUrl: `http://127.0.0.1:${PORT}/jmap/upload/{accountId}/`,
|
||||||
|
eventSourceUrl: `http://127.0.0.1:${PORT}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`,
|
||||||
|
state: String(state.n),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sseClients = new Set<ServerResponse>();
|
||||||
|
/** What changed and when, so `Email/changes` can answer honestly. */
|
||||||
|
const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = [];
|
||||||
|
function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
|
||||||
|
emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] });
|
||||||
|
// A window is plenty; the client refetches from scratch if it falls behind.
|
||||||
|
if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(types: string[]) {
|
||||||
|
const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`;
|
||||||
|
for (const c of sseClients) c.write(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported so tests can drive the mock in-process and shut it down. */
|
||||||
|
export const server = createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
|
||||||
|
if (!checkAuth(req)) return unauthorized(res);
|
||||||
|
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify(session()));
|
||||||
|
}
|
||||||
|
// The account info endpoint; the only place a server reports its edition.
|
||||||
|
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
||||||
|
}
|
||||||
|
if (url.pathname === "/jmap/" && req.method === "POST") {
|
||||||
|
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
|
||||||
|
// A capability the server cannot parse fails the whole request, not the one
|
||||||
|
// call that wanted it - which is why an over-eager `using` is so damaging.
|
||||||
|
// Stalwart decides this by parsing the urn, not by looking it up in the
|
||||||
|
// session, so a capability it hands out per-account is still usable here:
|
||||||
|
// `urn:stalwart:jmap` never appears in the session-level capabilities and
|
||||||
|
// the registry calls that name it work all the same.
|
||||||
|
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
|
||||||
|
const unknown = (body.using ?? []).find((u) => !known.has(u));
|
||||||
|
if (unknown) {
|
||||||
|
res.writeHead(400, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:unknownCapability", status: 400, detail: `Unknown capability: ${JSON.stringify(unknown)}` }));
|
||||||
|
}
|
||||||
|
const responses: [string, Obj, string][] = [];
|
||||||
|
const touched = new Set<string>();
|
||||||
|
const creations: Record<string, string> = {};
|
||||||
|
for (const [name, rawArgs, id] of body.methodCalls) {
|
||||||
|
const h = handlers[name];
|
||||||
|
// The registry, and every x: method with it, arrived in 0.16.
|
||||||
|
if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
|
||||||
|
try {
|
||||||
|
const args = resolveRefs(rawArgs, responses, creations);
|
||||||
|
enforceLimits(name, args);
|
||||||
|
const r = h(args);
|
||||||
|
responses.push([name, r as Obj, id]);
|
||||||
|
for (const [cid, obj] of Object.entries(((r as Obj).created as Obj) ?? {})) {
|
||||||
|
const newId = (obj as Obj)?.id;
|
||||||
|
if (typeof newId === "string") creations[cid] = newId;
|
||||||
|
}
|
||||||
|
if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MethodError) responses.push(["error", { type: err.type, description: err.message }, id]);
|
||||||
|
else responses.push(["error", { type: "serverFail", description: String(err) }, id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); }
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ methodResponses: responses, sessionState: "1" }));
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/jmap/upload/") && req.method === "POST") {
|
||||||
|
const data = await readBody(req);
|
||||||
|
const type = req.headers["content-type"] ?? "application/octet-stream";
|
||||||
|
const blobId = putBlob(data, type);
|
||||||
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ accountId: ACCOUNT, blobId, type, size: data.length }));
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/jmap/download/")) {
|
||||||
|
const [, , , , blobId] = url.pathname.split("/");
|
||||||
|
const b = blobs.get(blobId ?? "");
|
||||||
|
if (!b) { res.writeHead(404); return res.end(); }
|
||||||
|
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
|
||||||
|
return res.end(b.data);
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/jmap/eventsource")) {
|
||||||
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
||||||
|
res.write(`event: ping\ndata: {}\n\n`);
|
||||||
|
sseClients.add(res);
|
||||||
|
const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000);
|
||||||
|
req.on("close", () => { clearInterval(t); sseClients.delete(res); });
|
||||||
|
// Simulate a new message every 90s
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "not found" }));
|
||||||
|
}).listen(PORT, "127.0.0.1", () => {
|
||||||
|
console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`);
|
||||||
|
console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodically inject a new inbox email to demo push
|
||||||
|
setInterval(() => {
|
||||||
|
const p = people[Math.floor(Math.random() * people.length)]!;
|
||||||
|
addEmail({ from: [p[0]!, p[1]!], subject: `Live update ${new Date().toLocaleTimeString()}`, daysAgo: 0, mailbox: "inbox", unread: true, html: true });
|
||||||
|
recount();
|
||||||
|
nextState();
|
||||||
|
broadcast(["Email", "Mailbox", "Thread"]);
|
||||||
|
}, 120_000).unref();
|
||||||
@@ -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,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": "2.16.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 |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "ihasmail",
|
||||||
|
"short_name": "ihasmail",
|
||||||
|
"description": "Fast, friendly JMAP webmail for Stalwart",
|
||||||
|
"start_url": "/mail",
|
||||||
|
"scope": "/",
|
||||||
|
"protocol_handlers": [
|
||||||
|
{
|
||||||
|
"protocol": "mailto",
|
||||||
|
"url": "/mail?mailto=%s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#0f766e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/img/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/img/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/img/icon-maskable.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Compose",
|
||||||
|
"url": "/mail?compose=new",
|
||||||
|
"description": "Write a new message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Calendar",
|
||||||
|
"url": "/calendar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Contacts",
|
||||||
|
"url": "/contacts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||