Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa10563ed0 | ||
|
|
b7e0fc0c7d | ||
|
|
abd2269581 | ||
|
|
3530a0cc64 | ||
|
|
ba819369f4 | ||
|
|
bae29e647e | ||
|
|
4a12a898d1 | ||
|
|
aa13fcae7f | ||
|
|
55721faed2 | ||
|
|
a9dd6b3569 | ||
|
|
1fc24e6273 | ||
|
|
ea36270adf | ||
|
|
b8ab46270d | ||
|
|
d77928426a | ||
|
|
1514fd9e0a | ||
|
|
62242493f8 | ||
|
|
f7db2f23fa | ||
|
|
4a9c0c1c55 | ||
|
|
acc9df299e | ||
|
|
0dd0aed0dd | ||
|
|
ca2c4544dc | ||
|
|
a5c971ace4 | ||
|
|
aad599d802 | ||
|
|
25046e85e3 | ||
|
|
145abef3fe | ||
|
|
e29e3b35b0 | ||
|
|
99382486b8 | ||
|
|
9e2987f43c | ||
|
|
788eb0b0e1 | ||
|
|
b7f4b4e496 | ||
|
|
0fc4bd7a52 | ||
|
|
56e1f4f1be | ||
|
|
a9231a335c | ||
|
|
00868f053c | ||
|
|
ea55b1b2aa | ||
|
|
a027b312c5 | ||
|
|
db6afb72ff | ||
|
|
047a0029bf | ||
|
|
b0be465e50 | ||
|
|
fe9285062c | ||
|
|
9a2f355a4a | ||
|
|
4740ca46eb | ||
|
|
c04bc7a31b | ||
|
|
377e3aba2a | ||
|
|
27dbc8ac41 | ||
|
|
cc073693f4 | ||
|
|
a9ff405135 | ||
|
|
65443a235a | ||
|
|
0cbebed645 | ||
|
|
16dd867707 | ||
|
|
9a7f37b540 | ||
|
|
9a8c7516d7 | ||
|
|
6170fc3944 | ||
|
|
ee998eff46 | ||
|
|
d4d218f078 | ||
|
|
e9c183f2a5 | ||
|
|
be46c0c3c9 | ||
|
|
05c9bd46c2 | ||
|
|
d1d3041ce6 | ||
|
|
b0944b2f42 | ||
|
|
ca2c71e858 | ||
|
|
2f8af1ce94 | ||
|
|
6127a77458 | ||
|
|
afecc7d1dc | ||
|
|
d756d3ca68 | ||
|
|
f1f762c228 | ||
|
|
232e518d55 | ||
|
|
287af22ef8 | ||
|
|
409e5578a0 | ||
|
|
f1a2972d3a | ||
|
|
3521487f6c | ||
|
|
f5373c6fcd | ||
|
|
c4b9741c6d | ||
|
|
1aee40a169 | ||
|
|
d5468277d6 | ||
|
|
0a49c914eb | ||
|
|
57ff18bb7f | ||
|
|
b8263aa785 | ||
|
|
ee7542fd86 | ||
|
|
c92a68aba1 | ||
|
|
41f4cc7f8c | ||
|
|
c13a5375ab | ||
|
|
4025812c5d | ||
|
|
ad0b913efb | ||
|
|
337c46ebda | ||
|
|
c55b54163f | ||
|
|
3621e81d0c | ||
|
|
c4731fc8e0 | ||
|
|
0db2acb52b | ||
|
|
15d64c6320 | ||
|
|
e87ba09d70 | ||
|
|
54c256e69e | ||
|
|
98edc18570 | ||
|
|
9cdf84203b | ||
|
|
24fac8204a | ||
|
|
eadec49b4f | ||
|
|
95e442b2dd | ||
|
|
3ec4dc44fb | ||
|
|
7caa847737 | ||
|
|
0b278ca3c0 | ||
|
|
860cda22ab | ||
|
|
ecbcd76372 | ||
|
|
4846b5515c | ||
|
|
b870ee1910 | ||
|
|
36c19d639b | ||
|
|
d0828d67ed | ||
|
|
be893ef482 | ||
|
|
86660497b1 | ||
|
|
626a48e678 | ||
|
|
49c06e6efe | ||
|
|
487da2fbca | ||
|
|
c17887e48e |
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
server/data
|
||||
@@ -0,0 +1,47 @@
|
||||
# ---- 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).
|
||||
SESSION_FILE=./data/sessions.json
|
||||
|
||||
# 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,20 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
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,29 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
# ---- build stage ----
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY server/package.json server/
|
||||
COPY web/package.json web/
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
COPY pyproject.toml README.md /app/
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
COPY app /app/app
|
||||
COPY .env.example /app/.env.example
|
||||
|
||||
ENV PORT=8000
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"]
|
||||
# ---- runtime stage ----
|
||||
FROM node:22-alpine AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8080 \
|
||||
STATIC_DIR=/app/web/dist \
|
||||
SESSION_FILE=/data/sessions.json
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY server/package.json server/
|
||||
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
|
||||
VOLUME ["/data"]
|
||||
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"]
|
||||
|
||||
@@ -1,16 +1,661 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2025 John Coffey
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, version 3 of the License.
|
||||
Preamble
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.PHONY: run dev test build
|
||||
|
||||
run:
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
dev:
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
test:
|
||||
pytest
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
@@ -1,60 +1,247 @@
|
||||
<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="Tested against Stalwart 0.16.19 and 0.15.5" src="https://img.shields.io/badge/Stalwart-0.16.19%20%7C%200.15.5-6366f1?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
|
||||
|
||||

|
||||
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
|
||||
|
||||
A polished, FastAPI + HTMX/Jinja webmail for Stalwart, with JMAP mail/contacts/calendar, Sieve UI, DAV browsing, and reverse-proxy friendly deploy.
|
||||
ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters and every other modern feature Stalwart exposes, 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.
|
||||
|
||||
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.
|
||||
> Status: 2.0 rewrite, in QA against a live Stalwart server — **0.16.19**
|
||||
> since 2026-08-25, 0.15.5 before that. The previous FastAPI/HTMX prototype
|
||||
> has been removed entirely (only the logo survived, and it has since lost
|
||||
> the `.com` wordmark it used to carry — ihasmail is the software, not the
|
||||
> hosted instance).
|
||||
|
||||
ihasmail supports both generations of Stalwart, which are less alike than the
|
||||
version numbers suggest: 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. Where the two differ, ihasmail detects
|
||||
which it is talking to rather than assuming — see [Known issues / pending
|
||||
QA](#known-issues--pending-qa) for what is verified on which.
|
||||
|
||||
The live instance was moved from 0.15.5 to 0.16.19 with
|
||||
[stalwart-migrator](https://github.com/LINUXexpert-org/stalwart-migrator), a
|
||||
companion project: an in-place upgrade tool that checkpoints every phase,
|
||||
refuses to start on the things that cannot be fixed mid-migration, and
|
||||
validates the server afterwards. The upgrade is genuinely treacherous by hand
|
||||
— the store is migrated in place with no way back, and Stalwart's own
|
||||
converter drops settings without saying so — and that migration took eight
|
||||
seconds of downtime with nothing lost.
|
||||
|
||||
## Screenshots
|
||||
|
||||
*All screenshots are taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Inbox & conversation view (dark)**  | **Inbox & conversation view (light)**  |
|
||||
| **Reply composer** — identities, Reply-To, rich text, signature, quoted text  | **Calendar (month view)**  |
|
||||
| **Contacts**  | **Sieve filter builder** — also reachable from a message's right-click menu  |
|
||||
| **Sign-in**  | **Mobile layout** <img src="docs/screenshots/mobile.jpg" alt="Mobile" width="300"> |
|
||||
|
||||
## Features
|
||||
- Login with Stalwart mailbox (HTTP Basic against JMAP session or bearer token if provided)
|
||||
- Inbox listing, read messages (plain text), compose & send via JMAP (`Email`, `EmailSubmission`)
|
||||
- Contacts/Directory via JMAP `Contact`
|
||||
- Calendar view via JMAP `CalendarEvent`
|
||||
- WebDAV browser (read-only sample) and CalDAV endpoints (external DAV clients)
|
||||
- CSRF on POST, signed session cookie, proxy-friendly
|
||||
- Dockerfile + docker-compose for easy deploy
|
||||
|
||||
> HTML rendering and attachment streaming are stubbed—extend using the JMAP `downloadUrl` and sanitize HTML before display.
|
||||
**Mail**
|
||||
- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system theme with accent colours
|
||||
- Virtualised, infinitely-scrolling message list; multi-select (click, ⇧-click, ⌃-click), drag & drop to folders, right-click context menus, hover actions, Gmail keyboard shortcuts (`j/k`, `e`, `#`, `r/a/f`, `g i`, `/`, `?` …)
|
||||
- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo**
|
||||
- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP)
|
||||
- Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's)
|
||||
- Messages sit on a light card by default, untouched as the sender designed them. *Appearance › Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone
|
||||
- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer
|
||||
- **Read receipts**: when a sender asks for one, the message offers to send it — a real RFC 8098 `multipart/report`, never automatically. Bulk mail, mailing lists and anything marked `Auto-Submitted` are not offered one at all, and a receipt aimed somewhere other than the sender says so before you send it. Sending is recorded with RFC 3503's `$mdnsent` keyword, so a second look — or another client — knows not to ask again
|
||||
- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click
|
||||
- **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address
|
||||
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
|
||||
- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, **scheduled send** (quick picks or an exact date and time; the message waits in the server's queue, so it goes out whether or not ihasmail is open), autosaved drafts, reply/reply-all/forward with quoting and inline images preserved
|
||||
- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge
|
||||
- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default with chevrons in their own gutter so every icon lines up; unread folders are bold (a parent is bold when a subfolder has unread mail); right-click a folder to mark it read *including subfolders*, create/rename/hide/share/empty, quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts
|
||||
|
||||
## Quick Start (Docker)
|
||||
**Calendar** (JMAP Calendars / JSCalendar)
|
||||
- Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars
|
||||
- Create events by click or drag, edit everything: all-day, time zones, recurrence (presets + custom rule builder), location, meeting link, description, reminders, status/privacy/free-busy, colour
|
||||
- Attendees with invitations (`sendSchedulingMessages`), RSVP, and **free/busy lookup** via `Principal/getAvailability`
|
||||
- **Right-click menus** on events (open, edit, duplicate, colour, category, delete) and on empty slots/days (new event here, go to day/week)
|
||||
- **Outlook-style colour categories**: named colours managed in Settings, assigned from the context menu or editor; stored as JSCalendar `categories` (+ `color`) so they sync
|
||||
|
||||
**Contacts** (JMAP Contacts / JSContact)
|
||||
- Address books (create/rename/share/default), contact list with search and letter index, full contact editor (names, emails, phones, addresses, org/title, birthday, website, notes, photo), **groups**, vCard import/export, compose-to-contact
|
||||
|
||||
**Files** (JMAP FileNode)
|
||||
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
||||
|
||||
**Settings**
|
||||
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
|
||||
- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live)
|
||||
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance
|
||||
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
|
||||
- **Settings follow the account, not the browser** (Stalwart 0.16+): they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later. On Stalwart 0.15 nothing changes — settings stay local, as before
|
||||
|
||||
**Platform**
|
||||
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
|
||||
- **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings › General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in
|
||||
- **About** reports the Stalwart generation ihasmail detected (0.16+ or older) and the edition where the server gives one. Stalwart does not publish a version number to clients, so no version is shown rather than a made-up one
|
||||
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
|
||||
|
||||
## 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 stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …).
|
||||
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
|
||||
|
||||
Stalwart 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, for the account locale and to tell the generations apart). Features degrade gracefully when a capability is missing.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
# 1) Configure environment
|
||||
cp .env.example .env
|
||||
# Edit JMAP_BASE, CALDAV_BASE, WEBDAV_BASE, APP_SECRET
|
||||
|
||||
# 2) Build & run
|
||||
# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48)
|
||||
docker compose up --build -d
|
||||
|
||||
# 3) Reverse proxy (Nginx/Caddy) to http://127.0.0.1:8080
|
||||
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
- `APP_SECRET` – random string for signing cookies (required)
|
||||
- `JMAP_BASE` – e.g., `https://mail.example.com/jmap`
|
||||
- `CALDAV_BASE` – e.g., `https://mail.example.com/caldav/`
|
||||
- `WEBDAV_BASE` – e.g., `https://mail.example.com/webdav/`
|
||||
- `COOKIE_NAME` – cookie name (default: `stalwart_webmail`)
|
||||
- `TRUST_PROXY` – `1` to honor `X-Forwarded-*` (default: `1`)
|
||||
- `UPSTREAM_TIMEOUT` – seconds for upstream HTTP (default: `15`)
|
||||
Users sign in with their Stalwart mailbox credentials (TOTP codes are supported via the "two-factor code" field, which Stalwart accepts as `password$code`).
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10.
|
||||
|
||||
## Dev
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
uvicorn app.main:app --reload
|
||||
pytest
|
||||
npm install
|
||||
|
||||
# against a real Stalwart (set STALWART_URL in .env or the environment)
|
||||
npm run dev # server on :8080 (tsx watch) + Vite dev server on :5173 (proxying /api)
|
||||
|
||||
# against the built-in mock Stalwart ([email protected] / demo) — no real mailbox needed
|
||||
npm run dev:mock # mock on :8788, server on :8080, Vite on :5173
|
||||
|
||||
# the same, with the mock impersonating Stalwart 0.15 instead of 0.16
|
||||
npm run dev:mock:legacy
|
||||
|
||||
# the same, with the mock advertising FUTURERELEASE but dropping every hold —
|
||||
# the shape of a real server whose `futureRelease` setting was never turned on
|
||||
npm run dev:mock:no-future-release
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Security & Hardening
|
||||
- Prefer **bearer tokens** if Stalwart issues them; update `jmap_session()` to store `accessToken`
|
||||
- Set explicit `accountId` from the JMAP session `primaryAccounts`
|
||||
- Add mailbox/folder navigation via `Mailbox/query` + `Mailbox/get`
|
||||
- Sanitize HTML bodies (e.g., `bleach`) before rendering
|
||||
- Add Sieve UI via `urn:ietf:params:jmap:sieve`
|
||||
- Consider rate limiting and security headers in the reverse proxy
|
||||
- Serve static assets via proxy/CDN
|
||||
Open http://localhost:5173 in dev (or http://localhost:8080 for the production build).
|
||||
|
||||
### The mock, and which Stalwart it pretends to be
|
||||
|
||||
`npm run mock` impersonates **0.16** by default; `MOCK_STALWART=0.15` (or
|
||||
`npm run mock:legacy`) impersonates the generation before the registry. The
|
||||
older mode is not a smaller mock — it reproduces the specific ways that
|
||||
generation differs, none of which the server reports as an error:
|
||||
|
||||
- `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
|
||||
parse fails the **whole request**, not the one call that wanted it. On 0.16
|
||||
it *is* known — but advertised per-account, in `primaryAccounts` and each
|
||||
account's `accountCapabilities`, never in the session-level `capabilities`.
|
||||
Stalwart validates `using` by parsing the urn rather than looking it up in
|
||||
the session, so naming it works regardless; a client that tests for it in
|
||||
the obvious place, though, mistakes every 0.16 server for an older one
|
||||
- `x:` methods do not exist, so the registry — credentials, account settings —
|
||||
is unreachable, and self-service credentials live at `POST /api/account/auth`
|
||||
- `FileNode/query` masks its results to non-containers, so it returns files and
|
||||
**never folders**, silently; `FileNode/get` has no such mask
|
||||
- FileNode has no `nodeType` (a directory is a node with no file properties),
|
||||
and rights are only `mayRead`/`mayWrite`/`mayShare`
|
||||
|
||||
Both modes enforce the 2047-**byte** cap on identity signatures. Every one of
|
||||
these cost a live debugging session against a real 0.15.5 server, because the
|
||||
0.16-shaped mock could not express them; `server/src/account-legacy.test.ts`
|
||||
now pins them.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables (see `.env.example`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `STALWART_URL` | `https://mail.example.com` | Base URL of Stalwart; the JMAP session is discovered at `/.well-known/jmap` |
|
||||
| `APP_SECRET` | *(required in production)* | Secret used to derive session encryption keys |
|
||||
| `PORT` / `HOST` | `8080` / `0.0.0.0` | Listen address |
|
||||
| `TRUST_PROXY` | `1` | Honour `X-Forwarded-*`, but only from a peer listed in `TRUSTED_PROXIES` |
|
||||
| `TRUSTED_PROXIES` | *(loopback + private ranges)* | Comma-separated CIDRs or addresses whose forwarding headers are believed. Anything else is attributed by its socket address, whatever it claims |
|
||||
| `SECURE_COOKIES` | `auto` | `auto` (Secure on https), `1`, or `0` for plain-HTTP dev |
|
||||
| `SESSION_TTL` / `SESSION_REMEMBER_TTL` | `43200` / `2592000` | Idle session lifetime (seconds), with/without "keep me signed in" |
|
||||
| `SESSION_FILE` | *(unset)* | Persist sessions across restarts (ciphertext only) |
|
||||
| `IMAGE_PROXY` | `1` | Route remote images through the privacy proxy |
|
||||
| `MAX_UPLOAD_BYTES` | `52428800` | Upload size limit (Stalwart has its own limit too) |
|
||||
| `APP_NAME` | `ihasmail` | Branding |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
Press `?` anywhere. Highlights: `c` compose · `/` search · `j`/`k` navigate · `o`/`Enter` open · `u` back · `e` archive · `#` delete · `!` spam · `s` star · `r`/`a`/`f` reply/reply-all/forward · `v` move · `l` label · `x` select · `⇧I`/`⇧U` read/unread · `g i` inbox · `g l` calendar · `g c` contacts · `Ctrl+Enter` send.
|
||||
|
||||
## Known issues / pending QA
|
||||
|
||||
The live instance ran **0.15.5** until 2026-08-25 and runs **0.16.19** now,
|
||||
so both generations have been exercised against a real server. Everything
|
||||
below says which.
|
||||
|
||||
Verified against a live **0.15.5**: the mail flows, self-service credentials
|
||||
over the REST path, Files, and signatures.
|
||||
|
||||
The 0.16 registry path was previously recorded here as verified live. That
|
||||
was wrong, and the entry below says why: ihasmail looked for
|
||||
`urn:stalwart:jmap` in the session-level capabilities, where Stalwart has
|
||||
never put it, so **every** real 0.16 server was taken for a pre-0.16 one.
|
||||
Self-service credentials went to a REST endpoint 0.16 had removed, About
|
||||
reported the wrong generation, and Files ran on the older code path. The mock
|
||||
advertised the capability in the wrong place too, which is why nothing caught
|
||||
it. Fixed, and the mock now advertises it where the real server does — but
|
||||
the registry path is **awaiting live re-verification**.
|
||||
|
||||
- **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`); **not yet exercised against the live server**.
|
||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look. 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 pre-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 pre-0.16 code path. It now looks in all three places. Two related soft spots went with it: a transport error while probing the registry no longer downgrades a server to the legacy REST path (which would have posted the current password to an endpoint that is not there), and a locale request that is merely refused no longer discards a generation the capability had already settled.
|
||||
- **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.
|
||||
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete were also exercised — but under the capability-placement bug below, which means what ran there was this older path against a 0.16 server, not the 0.16 path. Files now takes the 0.16 path and wants checking again on its own terms. The older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
|
||||
- **Self-service credentials** — the **0.15.x REST path was confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 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 mock enforces the same rules either way (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. What is still mock-only is the rest of the journey: the **Scheduled** folder reconciling on the way in, and a hold actually expiring and being delivered.
|
||||
- **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.
|
||||
|
||||
## Roadmap / not yet
|
||||
|
||||
- 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)
|
||||
|
||||
## License
|
||||
GPL-3.0-or-later
|
||||
|
||||
Copyright (C) 2026 LINUXexpert.org
|
||||
|
||||
ihasmail 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. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
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: anyone running a modified
|
||||
ihasmail for other people has to offer them its source, which the GPL alone
|
||||
does not require.
|
||||
|
||||
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, so the people using your instance are told where
|
||||
the code they are actually running can be found.
|
||||
|
||||
@@ -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: {}
|
||||
@@ -1,11 +1,18 @@
|
||||
services:
|
||||
ihasmail:
|
||||
build: .
|
||||
image: ihasmail:latest
|
||||
env_file: .env
|
||||
image: ihasmail:2
|
||||
restart: unless-stopped
|
||||
networks: [edge]
|
||||
ports:
|
||||
- "127.0.0.1:8080:8000"
|
||||
networks:
|
||||
edge: {}
|
||||
- "8080:8080"
|
||||
environment:
|
||||
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
|
||||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
|
||||
APP_NAME: ${APP_NAME:-ihasmail}
|
||||
SOURCE_URL: ${SOURCE_URL:-https://github.com/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,272 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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");
|
||||
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");
|
||||
|
||||
// --- 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: 56 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 45 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,31 @@
|
||||
{
|
||||
"name": "ihasmail",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"build": "npm run build -w web && npm run build -w server",
|
||||
"start": "node server/dist/index.js",
|
||||
"typecheck": "npm run typecheck -w web && npm run typecheck -w server",
|
||||
"test": "npm run test -w web && npm run test -w server",
|
||||
"lint": "npm run typecheck",
|
||||
"mock": "npm run mock -w server",
|
||||
"dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"",
|
||||
"dev:mock:legacy": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock:legacy -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,27 @@
|
||||
{
|
||||
"name": "@ihasmail/server",
|
||||
"version": "2.0.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:legacy": "MOCK_STALWART=0.15 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,183 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* The same self-service flows, against a mock impersonating Stalwart 0.15.
|
||||
*
|
||||
* That generation has no registry: credentials live behind a REST endpoint,
|
||||
* `urn:stalwart:jmap` is not a capability it knows, and naming one it cannot
|
||||
* parse fails the whole request. Until now this adapter had no coverage at all
|
||||
* — it was the least-tested code in the project, verified only by hand.
|
||||
*/
|
||||
|
||||
const PORT = 18799;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_STALWART = "0.15";
|
||||
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-legacy-flows";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.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 legacy mock");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
test("the older server is recognised, and reported as such", async () => {
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.generation, "pre-0.16");
|
||||
assert.equal(res.body.ihasmail.server.edition, null, "no edition is reported before 0.16");
|
||||
assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "the capability does not exist here");
|
||||
});
|
||||
|
||||
test("credentials fall back to the REST endpoint", async () => {
|
||||
const res = await call("/api/account/security");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.backend, "legacy");
|
||||
assert.equal(res.body.otpEnabled, false);
|
||||
assert.equal(res.body.appPasswordsKeyedByName, true, "this generation has only names to go on");
|
||||
});
|
||||
|
||||
test("app passwords round-trip, keyed by their name", async () => {
|
||||
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
||||
assert.equal(created.status, 200);
|
||||
assert.ok(created.body.secret, "a secret is generated for the user to copy");
|
||||
assert.equal(created.body.id, "Thunderbird", "the name is the identifier here");
|
||||
|
||||
const listed = await call("/api/account/security");
|
||||
assert.deepEqual(listed.body.appPasswords.map((a: { description: string }) => a.description), ["Thunderbird"]);
|
||||
|
||||
await post("/api/account/app-passwords/revoke", { id: "Thunderbird" });
|
||||
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
||||
});
|
||||
|
||||
test("the current password is verified before it is changed", async () => {
|
||||
// The REST endpoint would take our word for it, so ihasmail proves it first.
|
||||
const wrong = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
|
||||
assert.equal(wrong.status, 403);
|
||||
assert.match(wrong.body.message, /incorrect/i);
|
||||
assert.equal((mock as { account: { password: string } }).account.password, "demo-password", "nothing was changed");
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal((mock as { account: { password: string } }).account.password, "a-brand-new-password");
|
||||
assert.equal((await call("/api/auth/session")).status, 200, "the session was re-sealed");
|
||||
});
|
||||
|
||||
test("2FA is enabled with a code proved against the new secret", async () => {
|
||||
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||
const begin = await post("/api/account/2fa/begin", {});
|
||||
const params = parseOtpauthUrl(begin.body.url);
|
||||
assert.ok(params);
|
||||
|
||||
const bad = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal((mock as { account: { otpUrl: string | null } }).account.otpUrl, null, "nothing was stored");
|
||||
|
||||
const good = await post("/api/account/2fa/enable", { url: begin.body.url, code: totpCode(params), current: "a-brand-new-password" });
|
||||
assert.equal(good.status, 200);
|
||||
assert.equal(good.body.sessionKept, true, "the session moved onto an app password");
|
||||
assert.equal((await call("/api/account/security")).body.otpEnabled, true);
|
||||
});
|
||||
|
||||
test("2FA is switched off again", async () => {
|
||||
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
||||
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);
|
||||
});
|
||||
|
||||
/**
|
||||
* The mock is only worth having if it is faithful, so these pin the specific
|
||||
* behaviours that cost us a live debugging session each. Every one of them was
|
||||
* invisible to the 0.16 mock, which is how the bugs shipped.
|
||||
*/
|
||||
|
||||
const jmap = (using: string[], methodCalls: unknown[]) => post("/api/jmap", { using, methodCalls });
|
||||
const CORE = "urn:ietf:params:jmap:core";
|
||||
const MAIL = "urn:ietf:params:jmap:mail";
|
||||
const FILES = "urn:ietf:params:jmap:filenode";
|
||||
|
||||
test("naming a capability it cannot parse fails the whole request", async () => {
|
||||
const res = await jmap([CORE, "urn:stalwart:jmap"], [["Mailbox/get", { accountId: "a1", ids: null }, "c0"]]);
|
||||
assert.notEqual(res.status, 200, "not one failed call - the entire request");
|
||||
});
|
||||
|
||||
test("x: methods do not exist, so they come back unknownMethod", async () => {
|
||||
const res = await jmap([CORE], [["x:AccountPassword/get", { accountId: "a1", ids: ["singleton"] }, "c0"]]);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.methodResponses[0][0], "error");
|
||||
assert.equal(res.body.methodResponses[0][1].type, "unknownMethod");
|
||||
});
|
||||
|
||||
test("FileNode/set refuses nodeType by name", async () => {
|
||||
const res = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "New", nodeType: "directory" } } }, "c0"]]);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notCreated.d.type, "invalidProperties");
|
||||
assert.deepEqual(set.notCreated.d.properties, ["nodeType"]);
|
||||
});
|
||||
|
||||
test("a directory is a node with no file properties, and query cannot see it", async () => {
|
||||
const made = await jmap([CORE, FILES], [["FileNode/set", { accountId: "a1", create: { d: { parentId: null, name: "Reports" } } }, "c0"]]);
|
||||
const id = made.body.methodResponses[0][1].created.d.id;
|
||||
assert.ok(id);
|
||||
|
||||
const queried = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1" }, "c0"]]);
|
||||
assert.equal(queried.body.methodResponses[0][1].ids.includes(id), false, "query masks out containers");
|
||||
|
||||
// get carries no such mask, which is the only way to find a folder here.
|
||||
const got = await jmap([CORE, FILES], [["FileNode/get", { accountId: "a1", ids: null }, "c0"]]);
|
||||
const list = got.body.methodResponses[0][1].list as { id: string; nodeType?: string; myRights: Record<string, boolean> }[];
|
||||
const dir = list.find((n) => n.id === id);
|
||||
assert.ok(dir, "get returns the directory");
|
||||
assert.equal(dir!.nodeType, undefined, "nodeType is not a property here");
|
||||
assert.deepEqual(Object.keys(dir!.myRights).sort(), ["mayRead", "mayShare", "mayWrite"], "the coarser rights");
|
||||
});
|
||||
|
||||
test("FileNode/query refuses the filters and sorts this generation lacks", async () => {
|
||||
const filtered = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", filter: { isTopLevel: true } }, "c0"]]);
|
||||
assert.equal(filtered.body.methodResponses[0][1].type, "unsupportedFilter");
|
||||
const sorted = await jmap([CORE, FILES], [["FileNode/query", { accountId: "a1", sort: [{ property: "nodeType" }] }, "c0"]]);
|
||||
assert.equal(sorted.body.methodResponses[0][1].type, "unsupportedSort");
|
||||
});
|
||||
|
||||
test("an identity signature is capped in bytes, not characters", async () => {
|
||||
// 1200 CJK characters: comfortably under 2047 counted as characters, and
|
||||
// 3600 bytes once encoded.
|
||||
const tooBig = "日".repeat(1200);
|
||||
assert.ok(tooBig.length < 2047 && Buffer.byteLength(tooBig, "utf8") > 2047);
|
||||
const res = await jmap([CORE, MAIL], [["Identity/set", { accountId: "a1", update: { i1: { htmlSignature: tooBig } } }, "c0"]]);
|
||||
const set = res.body.methodResponses[0][1];
|
||||
assert.equal(set.notUpdated.i1.type, "invalidProperties");
|
||||
assert.deepEqual(set.notUpdated.i1.properties, ["htmlSignature"]);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
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();
|
||||
});
|
||||
|
||||
/**
|
||||
* What the About page reads. Stalwart advertises `urn:stalwart:jmap` only
|
||||
* per-account, so a session that looks for it at the top level reports a real
|
||||
* 0.16 server as older than 0.16 — the same mistake that sent credentials to
|
||||
* the removed REST endpoint.
|
||||
*/
|
||||
test("the session reports the 0.16 generation the server actually is", async () => {
|
||||
const res = await call("/api/auth/session");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ihasmail.server.generation, "0.16+");
|
||||
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 0.16 registry backend is detected and reported empty", async () => {
|
||||
const res = await call("/api/account/security");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.backend, "registry");
|
||||
assert.equal(res.body.otpEnabled, false);
|
||||
assert.deepEqual(res.body.appPasswords, []);
|
||||
assert.equal(res.body.appPasswordsKeyedByName, false);
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, hasStalwartRegistry, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Self-service credential management, across two incompatible Stalwart APIs.
|
||||
*
|
||||
* 0.16+ JMAP registry objects: x:AccountPassword (a singleton holding the
|
||||
* password and the otpauth URL) and x:AppPassword.
|
||||
* 0.15.x a REST endpoint, POST /api/account/auth, taking a list of actions.
|
||||
*
|
||||
* The registry crate does not exist before 0.16 and the REST endpoint is gone
|
||||
* after it, so which one answers is the only reliable way to tell them apart.
|
||||
*/
|
||||
|
||||
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 type Backend = "registry" | "legacy";
|
||||
|
||||
export interface AppPasswordRow {
|
||||
/** Registry object id, or the name itself on legacy servers. */
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityState {
|
||||
backend: Backend;
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordRow[];
|
||||
/**
|
||||
* Legacy servers key app passwords by name and hand back nothing else, so
|
||||
* the UI must keep names unique and cannot show when one was created.
|
||||
*/
|
||||
appPasswordsKeyedByName: boolean;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Backend detection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const backendCache = new Map<string, { backend: Backend; at: number }>();
|
||||
const BACKEND_CACHE_MS = 30 * 60_000;
|
||||
|
||||
export function forgetBackend(sessionId: string): void {
|
||||
backendCache.delete(sessionId);
|
||||
}
|
||||
|
||||
export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backend> {
|
||||
const cached = backendCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend;
|
||||
const backend = await probeBackend(ctx);
|
||||
backendCache.set(sessionId, { backend, at: Date.now() });
|
||||
return backend;
|
||||
}
|
||||
|
||||
async function probeBackend(ctx: Ctx): Promise<Backend> {
|
||||
// A server with the registry answers x:AccountPassword/get; one without it
|
||||
// fails to parse the method name at all and returns unknownMethod.
|
||||
if (hasStalwartRegistry(ctx.session)) {
|
||||
try {
|
||||
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
|
||||
const [name, args] = res.methodResponses?.[0] ?? [];
|
||||
if (name && name !== "error") return "registry";
|
||||
const type = (args as { type?: string } | undefined)?.type;
|
||||
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
|
||||
} catch {
|
||||
// The capability already told us this server has the registry, so a
|
||||
// request we could not read is a fault to surface, not evidence of an
|
||||
// older server. Falling back here would post the user's password to a
|
||||
// REST endpoint 0.16 removed and report the feature as unsupported.
|
||||
return "registry";
|
||||
}
|
||||
// It named the capability and then disowned the method: nothing else to try.
|
||||
return "registry";
|
||||
}
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transports */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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][] };
|
||||
}
|
||||
|
||||
async function legacy<T>(ctx: Ctx, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account/auth`, {
|
||||
...init,
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||
if (res.status === 404) {
|
||||
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; details?: string; reason?: string };
|
||||
detail = body.details ?? body.reason ?? body.error ?? "";
|
||||
} catch {
|
||||
/* fall through to the generic message */
|
||||
}
|
||||
throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream");
|
||||
}
|
||||
return ((await res.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(sessionId: string, ctx: Ctx): Promise<SecurityState> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "legacy") {
|
||||
const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" });
|
||||
return {
|
||||
backend,
|
||||
otpEnabled: Boolean(data.otpEnabled),
|
||||
appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })),
|
||||
appPasswordsKeyedByName: true,
|
||||
};
|
||||
}
|
||||
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 {
|
||||
backend,
|
||||
// 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,
|
||||
})),
|
||||
appPasswordsKeyedByName: false,
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; next: string; otpCode?: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
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");
|
||||
return;
|
||||
}
|
||||
// The legacy endpoint changes the password without asking for the old one,
|
||||
// so anyone holding a live session could set it. Prove it ourselves first.
|
||||
await assertCurrentPassword(ctx, opts.current, opts.otpCode);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) });
|
||||
}
|
||||
|
||||
export async function createAppPassword(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { description: string },
|
||||
): Promise<{ id: string; secret: string }> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
const description = opts.description.trim() || "App password";
|
||||
if (backend === "registry") {
|
||||
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 };
|
||||
}
|
||||
// Legacy servers take a secret of our choosing and key it by name.
|
||||
const secret = readableSecret();
|
||||
await legacy<unknown>(ctx, {
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]),
|
||||
});
|
||||
return { id: description, secret };
|
||||
}
|
||||
|
||||
export async function revokeAppPassword(sessionId: string, ctx: Ctx, id: string): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
||||
setResult(res, "destroyed");
|
||||
return;
|
||||
}
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { url: string; code: string; current: string },
|
||||
): Promise<void> {
|
||||
assertEnrolmentCode(opts.url, opts.code);
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) });
|
||||
}
|
||||
|
||||
export async function disableOtp(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; code: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
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");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current, opts.code);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a password by authenticating with it, for the legacy endpoint that
|
||||
* would otherwise take our word for it.
|
||||
*/
|
||||
async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise<void> {
|
||||
const secret = otpCode ? `${current}$${otpCode}` : current;
|
||||
const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`;
|
||||
const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new AccountError("That password is incorrect.", 403, "bad_password");
|
||||
}
|
||||
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
|
||||
}
|
||||
|
||||
/**
|
||||
* A legacy app password a person can read off a screen and type.
|
||||
*
|
||||
* Drawn by rejection sampling. Plain `% alphabet.length` would favour the
|
||||
* first 25 characters, because 256 is not a multiple of 33: each of those
|
||||
* would come up on 8 byte values and the remaining 8 on only 7.
|
||||
*/
|
||||
export function readableSecret(): string {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
|
||||
const limit = 256 - (256 % alphabet.length);
|
||||
const chars: string[] = [];
|
||||
while (chars.length < 20) {
|
||||
for (const b of randomBytes(32)) {
|
||||
if (b >= limit) continue; // the tail that would skew the alphabet
|
||||
chars.push(alphabet[b % alphabet.length]!);
|
||||
if (chars.length === 20) break;
|
||||
}
|
||||
}
|
||||
return (chars.join("").match(/.{5}/g) ?? []).join("-");
|
||||
}
|
||||
|
||||
export { MASKED };
|
||||
@@ -0,0 +1,131 @@
|
||||
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.
|
||||
*/
|
||||
|
||||
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");
|
||||
assert.equal(info.generation, "0.16+");
|
||||
});
|
||||
|
||||
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 older server is recognised by its unknownMethod, and still yields a locale", () => {
|
||||
const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]);
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
assert.equal(info.locale, "en-GB");
|
||||
});
|
||||
|
||||
test("a server answering the new method is 0.16+ even with no locale set", () => {
|
||||
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
|
||||
assert.equal(info.generation, "0.16+");
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("neither answering leaves everything unknown rather than guessing", () => {
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]);
|
||||
assert.deepEqual(info, { locale: null, generation: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: 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 that never heard of the Stalwart capability is reported as pre-0.16", async () => {
|
||||
// 0.16 always advertises urn:stalwart:jmap and nothing older knows it at all,
|
||||
// so its absence is the answer - and asking anyway would fail the whole
|
||||
// request on those servers. This is what the live 0.15.5 box hits.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-pre-016", "Basic x", session as never);
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
assert.equal(info.locale, null);
|
||||
assert.equal(info.edition, null);
|
||||
});
|
||||
|
||||
test("no capabilities at all leaves the generation unknown", async () => {
|
||||
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
|
||||
assert.equal(info.generation, 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 pre-0.16, which sent self-service credentials to a
|
||||
* REST endpoint 0.16 had removed and made the About page report the wrong
|
||||
* generation.
|
||||
*/
|
||||
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 pre-0.16", () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
test("a locale request that fails does not talk us out of a generation we proved", () => {
|
||||
// The capability settled it. A forbidden reply costs the locale, nothing more.
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")], "0.16+");
|
||||
assert.equal(info.generation, "0.16+");
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("a server that disowns the method is still older, whatever we came in believing", () => {
|
||||
const info = interpretAccountInfo([failed("s", "unknownMethod")], "0.16+");
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
});
|
||||
@@ -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,644 @@
|
||||
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 LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
import {
|
||||
type AccountInfo,
|
||||
UpstreamError,
|
||||
absoluteUpstream,
|
||||
expandTemplate,
|
||||
fetchUpstreamSession,
|
||||
forgetUpstreamSession,
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
import {
|
||||
AccountError,
|
||||
assertEnrolmentCode,
|
||||
beginOtpEnrolment,
|
||||
changePassword,
|
||||
createAppPassword,
|
||||
disableOtp,
|
||||
enableOtp,
|
||||
forgetBackend,
|
||||
getState,
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
export const sessions = 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: "2.0.0" }));
|
||||
|
||||
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);
|
||||
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) {
|
||||
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 the pre-0.16 API is REST rather than JMAP (the browser
|
||||
* only ever sees /api/jmap), and because changing a credential means
|
||||
* re-sealing the session cookie that holds it.
|
||||
*/
|
||||
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(session.id, 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(session.id, 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(session.id, await accountCtx(c));
|
||||
return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName });
|
||||
} 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(session.id, 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(session.id, 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(session.id, 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(session.id, 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(session.id, 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(session.id, 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);
|
||||
forgetBackend(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, generation: 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: { generation: info.generation, 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,96 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync } 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(/\/+$/, "");
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
/**
|
||||
* 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: process.env.SESSION_FILE ?? "",
|
||||
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,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,824 @@
|
||||
/**
|
||||
* 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);
|
||||
/**
|
||||
* Which Stalwart generation to impersonate. "0.16" (the default) has the
|
||||
* registry — the `x:` methods, `nodeType` on FileNode, the finer-grained
|
||||
* rights. "0.15" is the older shape, and differs in ways that mostly do not
|
||||
* announce themselves: its FileNode/query cannot see directories at all, it
|
||||
* refuses a `using` naming a capability it does not know, and self-service
|
||||
* credentials live behind a REST endpoint instead.
|
||||
*/
|
||||
const LEGACY = process.env.MOCK_STALWART === "0.15";
|
||||
/**
|
||||
* 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";
|
||||
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 ---------- */
|
||||
const mailboxes: Obj[] = [
|
||||
mb("inbox", "Inbox", "inbox"),
|
||||
mb("drafts", "Drafts", "drafts"),
|
||||
mb("sent", "Sent", "sent"),
|
||||
mb("junk", "Junk Mail", "junk"),
|
||||
mb("trash", "Trash", "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 });
|
||||
// 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[] = [];
|
||||
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" });
|
||||
}
|
||||
const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }];
|
||||
const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true } }];
|
||||
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(), 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() },
|
||||
{ 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() },
|
||||
];
|
||||
function fr() {
|
||||
// 0.16 split what used to be a single mayWrite into four.
|
||||
return LEGACY
|
||||
? { mayRead: true, mayWrite: true, mayShare: true }
|
||||
: { 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 });
|
||||
|
||||
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": genericGet(mailboxes),
|
||||
"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),
|
||||
"Email/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], 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();
|
||||
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 } });
|
||||
},
|
||||
"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": genericGet(calendars),
|
||||
"Calendar/set": genericSet(calendars, "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 })),
|
||||
"CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }),
|
||||
"CalendarEvent/get": genericGet(events),
|
||||
// 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": genericGet(addressBooks),
|
||||
"AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })),
|
||||
"ContactCard/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: cards.map((c) => c.id), total: cards.length }),
|
||||
"ContactCard/get": genericGet(cards),
|
||||
"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) ?? {};
|
||||
if (LEGACY) {
|
||||
// Sorting is refused outright, and isTopLevel / nodeType are not filters
|
||||
// this generation knows.
|
||||
if (a.sort) throw new MethodError("unsupportedSort", "Sorting is not supported on FileNode");
|
||||
if ("isTopLevel" in f || "nodeType" in f) throw new MethodError("unsupportedFilter", "Unsupported filter");
|
||||
}
|
||||
let list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
||||
// The pre-0.16 query masks its results to non-containers, so a directory
|
||||
// never comes back — with nothing to say it was left out.
|
||||
if (LEGACY) list = list.filter((n) => n.nodeType !== "directory");
|
||||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||||
},
|
||||
"FileNode/get": (a) => {
|
||||
const res = genericGet(fileNodes)(a);
|
||||
// nodeType does not exist before 0.16; the shape is all the client gets.
|
||||
if (LEGACY) res.list = (res.list as Obj[]).map((n) => { const { nodeType: _drop, ...rest } = n; return rest; });
|
||||
return res;
|
||||
},
|
||||
"FileNode/set": (a) => {
|
||||
if (LEGACY) {
|
||||
for (const obj of [...Object.values((a.create as Obj) ?? {}), ...Object.values((a.update as Obj) ?? {})]) {
|
||||
if (obj && typeof obj === "object" && "nodeType" in (obj as Obj)) {
|
||||
return setResp({ notCreated: Object.fromEntries(Object.keys((a.create as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])), notUpdated: Object.fromEntries(Object.keys((a.update as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return genericSet(fileNodes, "f", (o) => {
|
||||
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), 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: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": {} },
|
||||
accounts: { [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": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "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>();
|
||||
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()));
|
||||
}
|
||||
// Before 0.16, self-service credentials are a REST endpoint rather than
|
||||
// registry objects: GET reports the state, POST takes a list of actions.
|
||||
if (LEGACY && url.pathname === "/api/account/auth") {
|
||||
if (req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ data: { otpEnabled: Boolean(account.otpUrl), appPasswords: account.appPasswords.map((a) => a.description) } }));
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const actions = JSON.parse((await readBody(req)).toString()) as { type: string; password?: string; url?: string | null; name?: string }[];
|
||||
// Password and OTP changes are only accepted over Basic auth.
|
||||
if (actions.some((a) => ["setPassword", "enableOtpAuth", "disableOtpAuth"].includes(a.type)) && !(req.headers.authorization ?? "").startsWith("Basic ")) {
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ error: "unauthorized", details: "Password changes only allowed using Basic auth" }));
|
||||
}
|
||||
for (const a of actions) {
|
||||
if (a.type === "setPassword") account.password = a.password ?? account.password;
|
||||
else if (a.type === "enableOtpAuth") account.otpUrl = a.url ?? null;
|
||||
else if (a.type === "disableOtpAuth") account.otpUrl = null;
|
||||
else if (a.type === "addAppPassword") account.appPasswords.push({ id: `ap${randomUUID().slice(0, 6)}`, description: a.name ?? "App password", secret: a.password ?? "", createdAt: new Date().toISOString(), expiresAt: null });
|
||||
else if (a.type === "removeAppPassword") {
|
||||
const i = account.appPasswords.findIndex((p) => p.description === a.name);
|
||||
if (i >= 0) account.appPasswords.splice(i, 1);
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ data: null }));
|
||||
}
|
||||
}
|
||||
|
||||
// 0.16's account info endpoint; the only place a server reports its edition.
|
||||
if (!LEGACY && 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 || (LEGACY && name.startsWith("x:"))) { 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] impersonating Stalwart ${LEGACY ? "0.15 (pre-registry)" : "0.16+"}`);
|
||||
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,99 @@
|
||||
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);
|
||||
});
|
||||
|
||||
test("generated app passwords are unbiased and long enough", async () => {
|
||||
const { readableSecret } = await import("./account.js");
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789";
|
||||
const counts = new Map<string, number>();
|
||||
let samples = 0;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const secret = readableSecret();
|
||||
assert.match(secret, /^[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}-[a-z2-9]{5}$/, secret);
|
||||
for (const ch of secret.replace(/-/g, "")) {
|
||||
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
assert.equal(samples, 2000 * 20);
|
||||
|
||||
/*
|
||||
* `% 33` over a byte maps 25 characters onto 8 values each and the last 8
|
||||
* onto 7, so the digits — the tail of the alphabet — would come up about
|
||||
* 7/8 as often as they should. Testing each character on its own cannot see
|
||||
* a skew that size against the noise, so weigh the whole tail at once:
|
||||
* uniform puts 8/33 of the draw there, the biased version 7/8 of that, and
|
||||
* over 40,000 draws the two are more than four standard deviations apart.
|
||||
*/
|
||||
const tail = alphabet.slice(25); // "23456789"
|
||||
const tailSeen = [...tail].reduce((n, ch) => n + (counts.get(ch) ?? 0), 0);
|
||||
const p = tail.length / alphabet.length;
|
||||
const expected = samples * p;
|
||||
const sigma = Math.sqrt(samples * p * (1 - p));
|
||||
assert.ok(
|
||||
Math.abs(tailSeen - expected) < 4 * sigma,
|
||||
`digits appeared ${tailSeen} times, expected ~${Math.round(expected)} (sigma ${sigma.toFixed(1)}) - modulo bias?`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
|
||||
export class SessionStore {
|
||||
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: {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}): { 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): Array<Omit<StoredSession, "secretHash" | "salt" | "sealedCredentials">> {
|
||||
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,292 @@
|
||||
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 reports every real 0.16 server as pre-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.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* Which generation of Stalwart's API answered: "0.16+" has the registry
|
||||
* (`x:AccountSettings`), older builds only have `x:Account`. Null when the
|
||||
* server is not Stalwart or told us nothing.
|
||||
*/
|
||||
generation: "0.16+" | "pre-0.16" | 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, generation: null, edition: null };
|
||||
/** A server that has never heard of the registry: nothing to read, but dated. */
|
||||
const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null };
|
||||
const REGISTRY_INFO: AccountInfo = { locale: null, generation: "0.16+", 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> {
|
||||
// Every 0.16 build advertises urn:stalwart:jmap, and no earlier one knows it
|
||||
// at all, so its absence already answers the question — and asking anyway
|
||||
// would fail the whole request, since those servers reject a `using` naming
|
||||
// a capability they cannot parse.
|
||||
// A session with no capabilities at all is not one we can read anything from.
|
||||
if (!session.capabilities) return EMPTY_INFO;
|
||||
if (!hasStalwartRegistry(session)) return PRE_REGISTRY_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),
|
||||
});
|
||||
// The registry capability already settled the generation. A locale request
|
||||
// that fails — a permission we lack, a hiccup upstream — can only cost us the
|
||||
// locale; it must not talk us out of what we know.
|
||||
if (!res.ok) return REGISTRY_INFO;
|
||||
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||
return interpretAccountInfo(body.methodResponses ?? [], "0.16+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pair of replies: prefer the locale from `x:AccountSettings`, fall
|
||||
* back to `x:Account` for servers (or permissions) where only that one works,
|
||||
* and note which generation answered.
|
||||
*/
|
||||
export function interpretAccountInfo(
|
||||
responses: [string, Record<string, unknown>, string][],
|
||||
known: AccountInfo["generation"] = null,
|
||||
): AccountInfo {
|
||||
const settings = responses.find((r) => r[2] === "s");
|
||||
const account = responses.find((r) => r[2] === "a");
|
||||
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
|
||||
// `known` is what the session capability already proved, and outranks a reply
|
||||
// that merely refused us.
|
||||
const generation: AccountInfo["generation"] =
|
||||
settings && settings[0] !== "error"
|
||||
? "0.16+"
|
||||
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
|
||||
? "pre-0.16"
|
||||
: known;
|
||||
return { locale: localeOf(settings) ?? localeOf(account), generation, 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);
|
||||
if (info.generation === "0.16+") 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,23 @@
|
||||
<!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" />
|
||||
<meta name="theme-color" content="#0f766e" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)" />
|
||||
<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.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"wouter": "^3.6.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* ihasmail service worker: app-shell caching for installability & fast loads.
|
||||
API requests are never cached. */
|
||||
const VERSION = "ihasmail-v2";
|
||||
const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
// Hashed build assets: cache-first.
|
||||
if (url.pathname.startsWith("/assets/")) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
|
||||
const copy = res.clone();
|
||||
caches.open(VERSION).then((c) => c.put(req, copy));
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigations & everything else: network-first, fall back to cached shell.
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(fetch(req).catch(() => caches.match("/")));
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(req).catch(() => caches.match(req)));
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { Route, Switch, Redirect, useLocation } from "wouter";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { scheduleSupported, useScheduled } from "@/store/scheduled";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { push } from "@/jmap/push";
|
||||
import { client } from "@/jmap/client";
|
||||
import { ToastHost } from "@/ui/toast";
|
||||
import { ConfirmHost } from "@/ui/dialog";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { LoginPage } from "@/views/Login";
|
||||
import { AppShell } from "@/views/AppShell";
|
||||
import { MailView } from "@/views/mail/MailView";
|
||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { setUnreadBadge } from "@/lib/notify";
|
||||
import { useSettings, syncedPart } from "@/store/settings";
|
||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
|
||||
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
|
||||
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
|
||||
const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView })));
|
||||
const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView })));
|
||||
|
||||
export function App() {
|
||||
const status = useSession((s) => s.status);
|
||||
const bootstrap = useSession((s) => s.bootstrap);
|
||||
useEffect(() => {
|
||||
void bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="center" style={{ height: "100%" }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthedApp() {
|
||||
const accountId = useSession((s) => s.accountId);
|
||||
const [location] = useLocation();
|
||||
|
||||
// Settings that live with the account rather than the browser. The cached
|
||||
// ones have already painted, so this only has to correct them (issue #54).
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const remote = await loadRemoteSettings();
|
||||
if (cancelled) return;
|
||||
if (remote) useSettings.getState().hydrate(remote);
|
||||
// Pushes were held back until now so they could not race the load.
|
||||
armSettingsSync();
|
||||
// No file yet — seed one from what this browser has, so the next device
|
||||
// to sign in starts from these rather than from the defaults.
|
||||
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accountId]);
|
||||
|
||||
// Initial data + push wiring
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
const mail = useMail.getState();
|
||||
void mail.loadMailboxes();
|
||||
void mail.loadIdentities();
|
||||
void mail.loadQuota();
|
||||
// So a held message shows its banner wherever it is opened from, not just
|
||||
// after a visit to the Scheduled folder.
|
||||
if (scheduleSupported()) void useScheduled.getState().load();
|
||||
void useContacts.getState().init();
|
||||
void useCalendar.getState().init();
|
||||
void useFiles.getState().init();
|
||||
void useSieve.getState().init();
|
||||
push.start();
|
||||
const pending = new Map<string, Set<string>>();
|
||||
let timer: number | null = null;
|
||||
const unsub = push.subscribe((acct, type) => {
|
||||
const set = pending.get(acct) ?? new Set<string>();
|
||||
set.add(type);
|
||||
pending.set(acct, set);
|
||||
if (timer) return;
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
for (const [a, types] of pending) {
|
||||
if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types);
|
||||
if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types);
|
||||
if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types);
|
||||
if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types);
|
||||
if (a === useSieve.getState().accountId) useSieve.getState().applyChanges(types);
|
||||
}
|
||||
pending.clear();
|
||||
}, 400);
|
||||
});
|
||||
const unsubState = client.onSessionState(() => void useSession.getState().refresh());
|
||||
// Poll fallback when push is disconnected (every 2 minutes)
|
||||
const poll = window.setInterval(() => {
|
||||
if (!push.connected && document.visibilityState === "visible") {
|
||||
void useMail.getState().applyChanges(new Set(["Email", "Mailbox"]));
|
||||
}
|
||||
}, 120_000);
|
||||
return () => {
|
||||
unsub();
|
||||
unsubState();
|
||||
window.clearInterval(poll);
|
||||
push.stop();
|
||||
};
|
||||
}, [accountId]);
|
||||
|
||||
// Unread badge in title/favicon
|
||||
const inboxUnread = useMail((s) => {
|
||||
const id = s.roleId("inbox");
|
||||
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail");
|
||||
useEffect(() => {
|
||||
void import("@/lib/notify").then((m) => {
|
||||
m.setBaseTitle(appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
});
|
||||
}, [inboxUnread, appName]);
|
||||
|
||||
// Request notification permission lazily when enabled
|
||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||
useEffect(() => {
|
||||
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
|
||||
}, [notif]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<Suspense fallback={<Spinner size="lg" />}>
|
||||
<Switch>
|
||||
<Route path="/mail/:mailboxId?/:threadId?">{(p) => <MailView mailboxId={p.mailboxId} threadId={p.threadId} />}</Route>
|
||||
<Route path="/search/:threadId?">{(p) => <MailView search threadId={p.threadId} />}</Route>
|
||||
<Route path="/contacts/:id?">{(p) => <ContactsView id={p.id} />}</Route>
|
||||
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
|
||||
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
|
||||
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
|
||||
<Route path="/login">
|
||||
<Redirect to="/mail" />
|
||||
</Route>
|
||||
<Route>{location === "/" ? <Redirect to="/mail" /> : <Redirect to="/mail" />}</Route>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
<ComposerDock />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* The `using` property of a JMAP request is not decoration: Stalwart >= 0.16
|
||||
* refuses Identity/get and Identity/set with `unknownMethod` unless the
|
||||
* submission capability is named, which left users unable to see or create an
|
||||
* identity — and so unable to send at all (issue #12).
|
||||
*/
|
||||
|
||||
function session(caps: string[]): JmapSession {
|
||||
return {
|
||||
capabilities: Object.fromEntries(caps.map((c) => [c, {}])),
|
||||
accounts: {},
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
}
|
||||
|
||||
/** Capture the `using` array of the single request a batch produces. */
|
||||
function captureUsing(): () => string[] {
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, unknown, string][] };
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ methodResponses: body.methodCalls.map(([, , id]) => ["ok", {}, id]) }),
|
||||
} as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const init = fetchMock.mock.calls[0]![1];
|
||||
return (JSON.parse(init.body as string) as { using: string[] }).using;
|
||||
};
|
||||
}
|
||||
|
||||
const ALL = [CAP.core, CAP.mail, CAP.submission, CAP.contacts, CAP.contactsParse];
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = session(ALL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
client.session = null;
|
||||
});
|
||||
|
||||
describe("request `using`", () => {
|
||||
it("names the submission capability for Identity methods", async () => {
|
||||
const using = captureUsing();
|
||||
await client.call("Identity/get", { accountId: "a1", ids: null });
|
||||
expect(using()).toContain(CAP.submission);
|
||||
});
|
||||
|
||||
it("names it for Identity/set too, so identities can be created", async () => {
|
||||
const using = captureUsing();
|
||||
await client.call("Identity/set", { accountId: "a1", create: { n: { email: "[email protected]" } } });
|
||||
expect(using()).toContain(CAP.submission);
|
||||
});
|
||||
|
||||
it("unions the capabilities of every call batched into one request", async () => {
|
||||
const using = captureUsing();
|
||||
await Promise.all([
|
||||
client.call("Identity/get", { accountId: "a1", ids: null }),
|
||||
client.call("Mailbox/get", { accountId: "a1", ids: null }),
|
||||
]);
|
||||
expect(using()).toEqual(expect.arrayContaining([CAP.core, CAP.mail, CAP.submission]));
|
||||
});
|
||||
|
||||
it("drops capabilities the session never advertised", async () => {
|
||||
client.session = session([CAP.core, CAP.mail]);
|
||||
const using = captureUsing();
|
||||
await client.call("Identity/get", { accountId: "a1", ids: null });
|
||||
expect(using()).toEqual(expect.arrayContaining([CAP.core, CAP.mail]));
|
||||
expect(using()).not.toContain(CAP.submission);
|
||||
});
|
||||
|
||||
it("always keeps core, even before a session is known", async () => {
|
||||
client.session = null;
|
||||
const using = captureUsing();
|
||||
await client.call("Email/get", { accountId: "a1", ids: [] });
|
||||
expect(using()).toContain(CAP.core);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
|
||||
|
||||
export const CAP = {
|
||||
core: "urn:ietf:params:jmap:core",
|
||||
mail: "urn:ietf:params:jmap:mail",
|
||||
submission: "urn:ietf:params:jmap:submission",
|
||||
vacation: "urn:ietf:params:jmap:vacationresponse",
|
||||
sieve: "urn:ietf:params:jmap:sieve",
|
||||
contacts: "urn:ietf:params:jmap:contacts",
|
||||
contactsParse: "urn:ietf:params:jmap:contacts:parse",
|
||||
calendars: "urn:ietf:params:jmap:calendars",
|
||||
calendarsParse: "urn:ietf:params:jmap:calendars:parse",
|
||||
principals: "urn:ietf:params:jmap:principals",
|
||||
availability: "urn:ietf:params:jmap:principals:availability",
|
||||
quota: "urn:ietf:params:jmap:quota",
|
||||
blob: "urn:ietf:params:jmap:blob",
|
||||
filenode: "urn:ietf:params:jmap:filenode",
|
||||
websocket: "urn:ietf:params:jmap:websocket",
|
||||
} as const;
|
||||
|
||||
export class JmapMethodError extends Error {
|
||||
constructor(
|
||||
public readonly method: string,
|
||||
public readonly error: MethodError,
|
||||
) {
|
||||
super(`${method}: ${error.type}${error.description ? ` - ${error.description}` : ""}`);
|
||||
this.name = "JmapMethodError";
|
||||
}
|
||||
get type() {
|
||||
return this.error.type;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `${code} (${status})`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error?: string;
|
||||
message?: string;
|
||||
type?: string;
|
||||
detail?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
method: string;
|
||||
args: Record<string, unknown>;
|
||||
using: Set<string>;
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: unknown) => void;
|
||||
}
|
||||
|
||||
export type ResultRef = { resultOf: string; name: string; path: string };
|
||||
|
||||
const HEADERS = { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" };
|
||||
|
||||
/** Generic fetch against our same-origin API with CSRF header + auth handling. */
|
||||
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401 && !path.startsWith("/api/auth/login")) {
|
||||
client.handleUnauthenticated();
|
||||
throw new ApiError(401, "unauthenticated", "Your session has expired. Please sign in again.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let body: ApiErrorBody = {};
|
||||
try {
|
||||
body = (await res.json()) as ApiErrorBody;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(res.status, body.error ?? body.type ?? "error", body.message ?? body.detail ?? body.title ?? res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export class JmapClient {
|
||||
session: JmapSession | null = null;
|
||||
private pending: Pending[] = [];
|
||||
private flushScheduled = false;
|
||||
private callCounter = 0;
|
||||
private unauthHandlers = new Set<() => void>();
|
||||
private stateHandlers = new Set<(sessionState: string) => void>();
|
||||
|
||||
get maxCallsInRequest(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
|
||||
return core?.maxCallsInRequest ?? 16;
|
||||
}
|
||||
|
||||
get maxObjectsInGet(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxObjectsInGet?: number } | undefined;
|
||||
return core?.maxObjectsInGet ?? 500;
|
||||
}
|
||||
|
||||
get maxObjectsInSet(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxObjectsInSet?: number } | undefined;
|
||||
return core?.maxObjectsInSet ?? 500;
|
||||
}
|
||||
|
||||
get maxSizeUpload(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxSizeUpload?: number } | undefined;
|
||||
return core?.maxSizeUpload ?? 50_000_000;
|
||||
}
|
||||
|
||||
hasCapability(cap: string): boolean {
|
||||
return Boolean(this.session?.capabilities && cap in this.session.capabilities);
|
||||
}
|
||||
|
||||
accountHasCapability(accountId: Id, cap: string): boolean {
|
||||
const acc = this.session?.accounts[accountId];
|
||||
return Boolean(acc && cap in acc.accountCapabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server carries a capability at all, wherever it chose to
|
||||
* advertise it.
|
||||
*
|
||||
* Stalwart hands `urn:stalwart:jmap` out per-account rather than putting it
|
||||
* in the session-level `capabilities`, so `hasCapability` alone reports every
|
||||
* real 0.16 server as though it were older. Look in all three places.
|
||||
*/
|
||||
hasCapabilityAnywhere(cap: string): boolean {
|
||||
if (this.hasCapability(cap)) return true;
|
||||
if (this.session?.primaryAccounts && cap in this.session.primaryAccounts) return true;
|
||||
return Object.values(this.session?.accounts ?? {}).some((a) => cap in (a.accountCapabilities ?? {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The capability object itself, for the capabilities that carry limits.
|
||||
* Stalwart puts the interesting half of `urn:ietf:params:jmap:submission`
|
||||
* here and leaves the session-level copy empty.
|
||||
*/
|
||||
accountCapability<T>(accountId: Id, cap: string): T | undefined {
|
||||
const acc = this.session?.accounts[accountId];
|
||||
return acc?.accountCapabilities[cap] as T | undefined;
|
||||
}
|
||||
|
||||
primaryAccount(cap: string): Id | null {
|
||||
return this.session?.primaryAccounts[cap] ?? null;
|
||||
}
|
||||
|
||||
onUnauthenticated(fn: () => void): () => void {
|
||||
this.unauthHandlers.add(fn);
|
||||
return () => this.unauthHandlers.delete(fn);
|
||||
}
|
||||
|
||||
onSessionState(fn: (s: string) => void): () => void {
|
||||
this.stateHandlers.add(fn);
|
||||
return () => this.stateHandlers.delete(fn);
|
||||
}
|
||||
|
||||
handleUnauthenticated(): void {
|
||||
for (const fn of this.unauthHandlers) fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a single method call; calls made within the same tick are batched
|
||||
* into one HTTP request (up to maxCallsInRequest).
|
||||
*/
|
||||
call<T = Record<string, unknown>>(method: string, args: Record<string, unknown>, using: string[] = []): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.push({
|
||||
method,
|
||||
args,
|
||||
using: new Set([CAP.core, ...usingFor(method), ...using]),
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
if (!this.flushScheduled) {
|
||||
this.flushScheduled = true;
|
||||
queueMicrotask(() => void this.flush());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
this.flushScheduled = false;
|
||||
const batch = this.pending;
|
||||
this.pending = [];
|
||||
const max = this.maxCallsInRequest;
|
||||
for (let i = 0; i < batch.length; i += max) {
|
||||
void this.sendBatch(batch.slice(i, i + max));
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBatch(batch: Pending[]): Promise<void> {
|
||||
const using = new Set<string>();
|
||||
const calls: Invocation[] = batch.map((p, idx) => {
|
||||
for (const u of p.using) using.add(u);
|
||||
return [p.method, p.args, `c${this.callCounter++}_${idx}`];
|
||||
});
|
||||
try {
|
||||
const res = await this.request(calls, [...using]);
|
||||
const byId = new Map<string, Invocation[]>();
|
||||
for (const inv of res.methodResponses) {
|
||||
const arr = byId.get(inv[2]) ?? [];
|
||||
arr.push(inv);
|
||||
byId.set(inv[2], arr);
|
||||
}
|
||||
batch.forEach((p, idx) => {
|
||||
const responses = byId.get(calls[idx]![2]);
|
||||
const first = responses?.[0];
|
||||
if (!first) {
|
||||
p.reject(new JmapMethodError(p.method, { type: "serverFail", description: "No response for call" }));
|
||||
return;
|
||||
}
|
||||
if (first[0] === "error") p.reject(new JmapMethodError(p.method, first[1] as MethodError));
|
||||
else p.resolve(first[1]);
|
||||
});
|
||||
} catch (err) {
|
||||
for (const p of batch) p.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop capabilities this session never advertised.
|
||||
*
|
||||
* A server MUST reject the whole request with `unknownCapability` when
|
||||
* `using` names something it does not implement (RFC 8620), which would take
|
||||
* down every call in the batch — not just the one that wanted the capability.
|
||||
* Core always stays: it is the one urn every server has.
|
||||
*/
|
||||
private supportedUsing(using: string[]): string[] {
|
||||
if (!this.session?.capabilities) return using;
|
||||
// Anywhere counts: a capability advertised per-account is one the server
|
||||
// has, and Stalwart advertises its own that way and no other.
|
||||
return using.filter((u) => u === CAP.core || this.hasCapabilityAnywhere(u));
|
||||
}
|
||||
|
||||
/** Low-level request: send invocations verbatim, return raw response. */
|
||||
async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record<string, Id>): Promise<JmapResponse> {
|
||||
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
|
||||
if (createdIds) body.createdIds = createdIds;
|
||||
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
|
||||
if (res.sessionState && this.session && res.sessionState !== this.session.state) {
|
||||
for (const fn of this.stateHandlers) fn(res.sessionState);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a chain of invocations (which may use result references) and return
|
||||
* responses keyed by call id. Throws if any call errored, unless `allowErrors`.
|
||||
*/
|
||||
async chain(
|
||||
calls: Array<[method: string, args: Record<string, unknown>, id: string]>,
|
||||
opts: { using?: string[]; allowErrors?: boolean } = {},
|
||||
): Promise<Map<string, Record<string, unknown>[]>> {
|
||||
const using = new Set<string>([CAP.core]);
|
||||
for (const [m] of calls) for (const u of usingFor(m)) using.add(u);
|
||||
for (const u of opts.using ?? []) using.add(u);
|
||||
const res = await this.request(calls, [...using]);
|
||||
const out = new Map<string, Record<string, unknown>[]>();
|
||||
for (const [name, args, id] of res.methodResponses) {
|
||||
if (name === "error" && !opts.allowErrors) {
|
||||
const method = calls.find((c) => c[2] === id)?.[0] ?? id;
|
||||
throw new JmapMethodError(method, args as MethodError);
|
||||
}
|
||||
const arr = out.get(id) ?? [];
|
||||
arr.push(name === "error" ? { __error: args } : args);
|
||||
out.set(id, arr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
uploadUrl(accountId: Id): string {
|
||||
return `/api/upload/${encodeURIComponent(accountId)}`;
|
||||
}
|
||||
|
||||
downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string {
|
||||
const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_");
|
||||
const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`;
|
||||
return inline ? `${u}&inline=1` : u;
|
||||
}
|
||||
|
||||
/** Upload a blob with progress reporting (XHR because fetch lacks upload progress). */
|
||||
upload(
|
||||
accountId: Id,
|
||||
data: Blob,
|
||||
opts: { type?: string; onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<UploadResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", this.uploadUrl(accountId));
|
||||
xhr.setRequestHeader("content-type", opts.type || data.type || "application/octet-stream");
|
||||
xhr.setRequestHeader("x-requested-with", "ihasmail");
|
||||
xhr.responseType = "json";
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) opts.onProgress?.(e.loaded, e.total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
this.handleUnauthenticated();
|
||||
reject(new ApiError(401, "unauthenticated"));
|
||||
return;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300 && xhr.response) resolve(xhr.response as UploadResponse);
|
||||
else reject(new ApiError(xhr.status, (xhr.response as ApiErrorBody)?.error ?? "upload_failed", (xhr.response as ApiErrorBody)?.message ?? "Upload failed"));
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, "network_error", "Network error during upload"));
|
||||
xhr.onabort = () => reject(new ApiError(0, "aborted", "Upload cancelled"));
|
||||
opts.signal?.addEventListener("abort", () => xhr.abort());
|
||||
xhr.send(data);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a blob's content as text (via the download proxy). */
|
||||
async fetchBlobText(accountId: Id, blobId: Id, type = "text/plain"): Promise<string> {
|
||||
const res = await fetch(this.downloadUrl(accountId, blobId, "blob.txt", type), { credentials: "same-origin" });
|
||||
if (res.status === 401) {
|
||||
this.handleUnauthenticated();
|
||||
throw new ApiError(401, "unauthenticated");
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, "download_failed");
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
async fetchBlob(accountId: Id, blobId: Id, type = "application/octet-stream"): Promise<Blob> {
|
||||
const res = await fetch(this.downloadUrl(accountId, blobId, "blob", type), { credentials: "same-origin" });
|
||||
if (res.status === 401) {
|
||||
this.handleUnauthenticated();
|
||||
throw new ApiError(401, "unauthenticated");
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, "download_failed");
|
||||
return await res.blob();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map method name prefix → required capability URNs. */
|
||||
function usingFor(method: string): string[] {
|
||||
const type = method.split("/")[0] ?? "";
|
||||
switch (type) {
|
||||
case "Mailbox":
|
||||
case "Thread":
|
||||
case "Email":
|
||||
case "SearchSnippet":
|
||||
return [CAP.mail];
|
||||
// Identity belongs to the submission capability (RFC 8621), not mail:
|
||||
// Stalwart >= 0.16 rejects Identity/get and Identity/set outright when
|
||||
// "using" names only mail. Keep mail as well, so the filter in
|
||||
// supportedUsing() still leaves a usable urn on servers that predate
|
||||
// advertising submission.
|
||||
case "Identity":
|
||||
case "EmailSubmission":
|
||||
return [CAP.mail, CAP.submission];
|
||||
case "VacationResponse":
|
||||
return [CAP.mail, CAP.vacation];
|
||||
case "SieveScript":
|
||||
return [CAP.sieve];
|
||||
case "AddressBook":
|
||||
case "ContactCard":
|
||||
return [CAP.contacts, CAP.contactsParse];
|
||||
case "Calendar":
|
||||
case "CalendarEvent":
|
||||
case "ParticipantIdentity":
|
||||
case "CalendarEventNotification":
|
||||
return [CAP.calendars, CAP.calendarsParse];
|
||||
case "Principal":
|
||||
return [CAP.principals, CAP.availability];
|
||||
case "Quota":
|
||||
return [CAP.quota];
|
||||
case "Blob":
|
||||
return [CAP.blob];
|
||||
case "FileNode":
|
||||
return [CAP.filenode];
|
||||
case "PushSubscription":
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const client = new JmapClient();
|
||||
|
||||
/** Build a JMAP result reference argument ("#ids": {...}). */
|
||||
export function ref(resultOf: string, name: string, path: string): ResultRef {
|
||||
return { resultOf, name, path };
|
||||
}
|
||||
|
||||
/** Chunk ids so a /get or /set call stays under the server's per-call maximum. */
|
||||
export function chunk<T>(arr: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable message for a JMAP SetError.
|
||||
*
|
||||
* Servers name the offending field in `properties`, which is usually the whole
|
||||
* answer to "why was this rejected" — Stalwart's description alone is often
|
||||
* just "Invalid property or value." Keep both.
|
||||
*/
|
||||
export function setErrorMessage(err: { type: string; description?: string; properties?: string[] } | null | undefined): string {
|
||||
if (!err) return "Unknown error";
|
||||
const base = err.description ?? err.type;
|
||||
const props = err.properties?.length ? ` (${err.properties.join(", ")})` : "";
|
||||
return `${base}${props}`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Id, StateChange } from "./types";
|
||||
|
||||
export type PushListener = (accountId: Id, type: string, newState: string) => void;
|
||||
|
||||
/** Connected, trying to connect, or not trying. */
|
||||
export type PushState = "connected" | "connecting" | "disconnected";
|
||||
|
||||
/**
|
||||
* JMAP push over Server-Sent Events (proxied through our server).
|
||||
* Emits per-type state changes so stores can refresh incrementally.
|
||||
*/
|
||||
class PushManager {
|
||||
private es: EventSource | null = null;
|
||||
private listeners = new Set<PushListener>();
|
||||
private connectionListeners = new Set<(state: PushState) => void>();
|
||||
private backoff = 1000;
|
||||
private reconnectTimer: number | null = null;
|
||||
private stopped = true;
|
||||
private lastStates = new Map<string, string>();
|
||||
connected = false;
|
||||
/**
|
||||
* Finer than `connected`, which cannot tell "trying" from "given up".
|
||||
* "connecting" covers the first attempt and every backoff retry.
|
||||
*/
|
||||
state: PushState = "disconnected";
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.connect();
|
||||
document.addEventListener("visibilitychange", this.onVisibility);
|
||||
window.addEventListener("online", this.onOnline);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
document.removeEventListener("visibilitychange", this.onVisibility);
|
||||
window.removeEventListener("online", this.onOnline);
|
||||
if (this.reconnectTimer) window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
this.es?.close();
|
||||
this.es = null;
|
||||
this.setState("disconnected");
|
||||
}
|
||||
|
||||
subscribe(fn: PushListener): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
onConnection(fn: (state: PushState) => void): () => void {
|
||||
this.connectionListeners.add(fn);
|
||||
return () => this.connectionListeners.delete(fn);
|
||||
}
|
||||
|
||||
private setState(v: PushState) {
|
||||
if (this.state === v) return;
|
||||
this.state = v;
|
||||
this.connected = v === "connected";
|
||||
for (const fn of this.connectionListeners) fn(v);
|
||||
}
|
||||
|
||||
private onVisibility = () => {
|
||||
if (document.visibilityState === "visible" && !this.es && !this.stopped) this.connect();
|
||||
};
|
||||
|
||||
private onOnline = () => {
|
||||
if (!this.es && !this.stopped) this.connect();
|
||||
};
|
||||
|
||||
private connect(): void {
|
||||
if (this.stopped || this.es) return;
|
||||
if (this.state !== "connected") this.setState("connecting");
|
||||
const url = `/api/events?types=*&closeafter=no&ping=30`;
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
this.es = es;
|
||||
es.onopen = () => {
|
||||
this.backoff = 1000;
|
||||
this.setState("connected");
|
||||
};
|
||||
es.addEventListener("state", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data as string) as StateChange;
|
||||
if (data["@type"] !== "StateChange") return;
|
||||
for (const [accountId, types] of Object.entries(data.changed)) {
|
||||
for (const [type, state] of Object.entries(types)) {
|
||||
const key = `${accountId}/${type}`;
|
||||
if (this.lastStates.get(key) === state) continue;
|
||||
this.lastStates.set(key, state);
|
||||
for (const fn of this.listeners) fn(accountId, type, state);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
});
|
||||
es.addEventListener("ping", () => {
|
||||
/* keepalive */
|
||||
});
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
this.es = null;
|
||||
if (this.stopped) { this.setState("disconnected"); return; }
|
||||
// A retry is already scheduled below, so this is "trying", not "given up".
|
||||
this.setState("connecting");
|
||||
const delay = Math.min(this.backoff, 60_000);
|
||||
this.backoff = Math.min(this.backoff * 2, 60_000);
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const push = new PushManager();
|
||||
@@ -0,0 +1,793 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* JMAP core (RFC 8620) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type Id = string;
|
||||
export type UTCDate = string; // "2024-01-01T10:00:00Z"
|
||||
export type LocalDate = string; // "2024-01-01T10:00:00"
|
||||
|
||||
export interface Account {
|
||||
name: string;
|
||||
isPersonal: boolean;
|
||||
isReadOnly: boolean;
|
||||
accountCapabilities: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JmapSession {
|
||||
capabilities: Record<string, unknown>;
|
||||
accounts: Record<Id, Account>;
|
||||
primaryAccounts: Record<string, Id>;
|
||||
username: string;
|
||||
apiUrl: string;
|
||||
downloadUrl: string;
|
||||
uploadUrl: string;
|
||||
eventSourceUrl: string;
|
||||
state: string;
|
||||
ihasmail?: {
|
||||
appName: string;
|
||||
/** Where this instance's source can be had, for the AGPL's sake. */
|
||||
sourceUrl?: string;
|
||||
imageProxy: boolean;
|
||||
maxUploadBytes: number;
|
||||
sessionId: string;
|
||||
loginName: string;
|
||||
remember: boolean;
|
||||
/** Locale configured for the account in Stalwart, if the server exposes it. */
|
||||
userLocale?: string | null;
|
||||
/** What the upstream server was willing to say about itself. */
|
||||
server?: {
|
||||
/** Which API generation answered: Stalwart publishes no version number. */
|
||||
generation?: "0.16+" | "pre-0.16" | null;
|
||||
edition?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface CoreCapabilities {
|
||||
maxSizeUpload: number;
|
||||
maxConcurrentUpload: number;
|
||||
maxSizeRequest: number;
|
||||
maxConcurrentRequests: number;
|
||||
maxCallsInRequest: number;
|
||||
maxObjectsInGet: number;
|
||||
maxObjectsInSet: number;
|
||||
collationAlgorithms: string[];
|
||||
}
|
||||
|
||||
export interface MailCapabilities {
|
||||
maxMailboxesPerEmail: number | null;
|
||||
maxMailboxDepth: number | null;
|
||||
maxSizeMailboxName: number;
|
||||
maxSizeAttachmentsPerEmail: number;
|
||||
emailQuerySortOptions: string[];
|
||||
mayCreateTopLevelMailbox: boolean;
|
||||
}
|
||||
|
||||
export type Invocation = [name: string, args: Record<string, unknown>, callId: string];
|
||||
|
||||
export interface JmapResponse {
|
||||
methodResponses: Invocation[];
|
||||
sessionState: string;
|
||||
createdIds?: Record<string, Id>;
|
||||
}
|
||||
|
||||
export interface MethodError {
|
||||
type: string;
|
||||
description?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SetError {
|
||||
type: string;
|
||||
description?: string;
|
||||
properties?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SetResponse<T = Record<string, unknown>> {
|
||||
accountId: Id;
|
||||
oldState: string | null;
|
||||
newState: string;
|
||||
created?: Record<string, T>;
|
||||
updated?: Record<string, T | null>;
|
||||
destroyed?: Id[];
|
||||
notCreated?: Record<string, SetError>;
|
||||
notUpdated?: Record<string, SetError>;
|
||||
notDestroyed?: Record<string, SetError>;
|
||||
}
|
||||
|
||||
export interface GetResponse<T> {
|
||||
accountId: Id;
|
||||
state: string;
|
||||
list: T[];
|
||||
notFound: Id[];
|
||||
}
|
||||
|
||||
export interface QueryResponse {
|
||||
accountId: Id;
|
||||
queryState: string;
|
||||
canCalculateChanges: boolean;
|
||||
position: number;
|
||||
ids: Id[];
|
||||
total?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ChangesResponse {
|
||||
accountId: Id;
|
||||
oldState: string;
|
||||
newState: string;
|
||||
hasMoreChanges: boolean;
|
||||
created: Id[];
|
||||
updated: Id[];
|
||||
destroyed: Id[];
|
||||
}
|
||||
|
||||
export interface StateChange {
|
||||
"@type": "StateChange";
|
||||
changed: Record<Id, Record<string, string>>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mail (RFC 8621) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type MailboxRole =
|
||||
| "inbox"
|
||||
| "archive"
|
||||
| "drafts"
|
||||
| "sent"
|
||||
| "trash"
|
||||
| "junk"
|
||||
| "important"
|
||||
| "all"
|
||||
| "flagged"
|
||||
| "subscribed"
|
||||
| null;
|
||||
|
||||
export interface MailboxRights {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
id: Id;
|
||||
name: string;
|
||||
parentId: Id | null;
|
||||
role: MailboxRole;
|
||||
sortOrder: number;
|
||||
totalEmails: number;
|
||||
unreadEmails: number;
|
||||
totalThreads: number;
|
||||
unreadThreads: number;
|
||||
myRights: MailboxRights;
|
||||
isSubscribed: boolean;
|
||||
shareWith?: Record<Id, Partial<MailboxRights>> | null;
|
||||
}
|
||||
|
||||
export interface EmailAddress {
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface EmailAddressGroup {
|
||||
name: string | null;
|
||||
addresses: EmailAddress[];
|
||||
}
|
||||
|
||||
export interface EmailHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EmailBodyPart {
|
||||
partId: string | null;
|
||||
blobId: Id | null;
|
||||
size: number;
|
||||
headers?: EmailHeader[];
|
||||
name: string | null;
|
||||
type: string;
|
||||
charset: string | null;
|
||||
disposition: string | null;
|
||||
cid: string | null;
|
||||
language?: string[] | null;
|
||||
location?: string | null;
|
||||
subParts?: EmailBodyPart[] | null;
|
||||
}
|
||||
|
||||
export interface EmailBodyValue {
|
||||
value: string;
|
||||
isEncodingProblem: boolean;
|
||||
isTruncated: boolean;
|
||||
}
|
||||
|
||||
export interface Email {
|
||||
id: Id;
|
||||
blobId: Id;
|
||||
threadId: Id;
|
||||
mailboxIds: Record<Id, boolean>;
|
||||
keywords: Record<string, boolean>;
|
||||
size: number;
|
||||
receivedAt: UTCDate;
|
||||
messageId?: string[] | null;
|
||||
inReplyTo?: string[] | null;
|
||||
references?: string[] | null;
|
||||
sender?: EmailAddress[] | null;
|
||||
from?: EmailAddress[] | null;
|
||||
to?: EmailAddress[] | null;
|
||||
cc?: EmailAddress[] | null;
|
||||
bcc?: EmailAddress[] | null;
|
||||
replyTo?: EmailAddress[] | null;
|
||||
subject?: string | null;
|
||||
sentAt?: string | null;
|
||||
hasAttachment?: boolean;
|
||||
preview?: string;
|
||||
bodyStructure?: EmailBodyPart;
|
||||
bodyValues?: Record<string, EmailBodyValue>;
|
||||
textBody?: EmailBodyPart[];
|
||||
htmlBody?: EmailBodyPart[];
|
||||
attachments?: EmailBodyPart[];
|
||||
headers?: EmailHeader[];
|
||||
// convenience header fetches
|
||||
"header:List-Unsubscribe:asText"?: string | null;
|
||||
"header:List-Unsubscribe-Post:asText"?: string | null;
|
||||
"header:List-Id:asText"?: string | null;
|
||||
"header:Disposition-Notification-To:asAddresses"?: EmailAddress[] | null;
|
||||
"header:X-Priority:asText"?: string | null;
|
||||
"header:Importance:asText"?: string | null;
|
||||
"header:Auto-Submitted:asText"?: string | null;
|
||||
/** Bulk/list mail marks itself here; read receipts for it only confirm the address. */
|
||||
"header:Precedence:asText"?: string | null;
|
||||
"header:Return-Path:asText"?: string | null;
|
||||
"header:Authentication-Results:asText"?: string | null;
|
||||
"header:Received:asText:all"?: string[] | null;
|
||||
"header:X-Spam-Status:asText"?: string | null;
|
||||
"header:X-Spam-Result:asText"?: string | null;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: Id;
|
||||
emailIds: Id[];
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
id: Id;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: EmailAddress[] | null;
|
||||
bcc: EmailAddress[] | null;
|
||||
textSignature: string;
|
||||
htmlSignature: string;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface EmailSubmission {
|
||||
id: Id;
|
||||
identityId: Id;
|
||||
emailId: Id;
|
||||
threadId: Id;
|
||||
envelope: { mailFrom: { email: string; parameters?: Record<string, unknown> | null }; rcptTo: { email: string }[] } | null;
|
||||
sendAt: UTCDate;
|
||||
undoStatus: "pending" | "final" | "canceled";
|
||||
deliveryStatus: Record<string, { smtpReply: string; delivered: string; displayed: string }> | null;
|
||||
}
|
||||
|
||||
export interface VacationResponse {
|
||||
id: "singleton";
|
||||
isEnabled: boolean;
|
||||
fromDate: UTCDate | null;
|
||||
toDate: UTCDate | null;
|
||||
subject: string | null;
|
||||
textBody: string | null;
|
||||
htmlBody: string | null;
|
||||
}
|
||||
|
||||
export interface SearchSnippet {
|
||||
emailId: Id;
|
||||
subject: string | null;
|
||||
preview: string | null;
|
||||
}
|
||||
|
||||
export interface EmailFilterCondition {
|
||||
inMailbox?: Id;
|
||||
inMailboxOtherThan?: Id[];
|
||||
before?: UTCDate;
|
||||
after?: UTCDate;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
allInThreadHaveKeyword?: string;
|
||||
someInThreadHaveKeyword?: string;
|
||||
noneInThreadHaveKeyword?: string;
|
||||
hasKeyword?: string;
|
||||
notKeyword?: string;
|
||||
hasAttachment?: boolean;
|
||||
text?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
header?: string[];
|
||||
}
|
||||
|
||||
export interface FilterOperator<T> {
|
||||
operator: "AND" | "OR" | "NOT";
|
||||
conditions: Array<T | FilterOperator<T>>;
|
||||
}
|
||||
|
||||
export type EmailFilter = EmailFilterCondition | FilterOperator<EmailFilterCondition>;
|
||||
|
||||
export interface Comparator {
|
||||
property: string;
|
||||
isAscending?: boolean;
|
||||
collation?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Quota (RFC 9425) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface Quota {
|
||||
id: Id;
|
||||
resourceType: "count" | "octets";
|
||||
used: number;
|
||||
hardLimit: number;
|
||||
scope: "account" | "domain" | "global";
|
||||
name: string;
|
||||
types: string[];
|
||||
warnLimit?: number | null;
|
||||
softLimit?: number | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sieve (RFC 9661) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface SieveScript {
|
||||
id: Id;
|
||||
name: string;
|
||||
blobId: Id;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Principals (RFC 9670) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface Principal {
|
||||
id: Id;
|
||||
type: "individual" | "group" | "resource" | "location" | "other";
|
||||
name: string;
|
||||
description: string | null;
|
||||
email: string | null;
|
||||
timeZone: string | null;
|
||||
capabilities?: Record<string, unknown>;
|
||||
accounts?: Record<Id, Account> | null;
|
||||
}
|
||||
|
||||
export interface BusyPeriod {
|
||||
utcStart: UTCDate;
|
||||
utcEnd: UTCDate;
|
||||
busyStatus: "confirmed" | "tentative" | "unavailable";
|
||||
event: JSCalendarEvent | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Contacts (RFC 9610 / JSContact RFC 9553) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface AddressBookRights {
|
||||
mayRead: boolean;
|
||||
mayWrite: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface AddressBook {
|
||||
id: Id;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isDefault: boolean;
|
||||
isSubscribed: boolean;
|
||||
shareWith: Record<Id, AddressBookRights> | null;
|
||||
myRights: AddressBookRights;
|
||||
}
|
||||
|
||||
export interface JSContactNameComponent {
|
||||
"@type"?: "NameComponent";
|
||||
kind: "title" | "given" | "given2" | "surname" | "surname2" | "credential" | "generation" | "separator";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface JSContactName {
|
||||
"@type"?: "Name";
|
||||
components?: JSContactNameComponent[];
|
||||
isOrdered?: boolean;
|
||||
full?: string;
|
||||
defaultSeparator?: string;
|
||||
sortAs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface JSContactEmail {
|
||||
"@type"?: "EmailAddress";
|
||||
address: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactPhone {
|
||||
"@type"?: "Phone";
|
||||
number: string;
|
||||
features?: Record<string, boolean>;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactAddressComponent {
|
||||
"@type"?: "AddressComponent";
|
||||
kind: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface JSContactAddress {
|
||||
"@type"?: "Address";
|
||||
components?: JSContactAddressComponent[];
|
||||
isOrdered?: boolean;
|
||||
countryCode?: string;
|
||||
coordinates?: string;
|
||||
timeZone?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
full?: string;
|
||||
defaultSeparator?: string;
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface JSContactOrganization {
|
||||
"@type"?: "Organization";
|
||||
name?: string;
|
||||
units?: { "@type"?: "OrgUnit"; name: string }[];
|
||||
sortAs?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface JSContactTitle {
|
||||
"@type"?: "Title";
|
||||
name: string;
|
||||
kind?: "title" | "role";
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export interface JSContactAnniversary {
|
||||
"@type"?: "Anniversary";
|
||||
kind: "birth" | "death" | "wedding" | string;
|
||||
date: { "@type"?: "PartialDate" | "Timestamp"; year?: number; month?: number; day?: number; utc?: string };
|
||||
place?: JSContactAddress;
|
||||
}
|
||||
|
||||
export interface JSContactNote {
|
||||
"@type"?: "Note";
|
||||
note: string;
|
||||
created?: string;
|
||||
author?: { name?: string; uri?: string };
|
||||
}
|
||||
|
||||
export interface JSContactOnlineService {
|
||||
"@type"?: "OnlineService";
|
||||
service?: string;
|
||||
uri?: string;
|
||||
user?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactMedia {
|
||||
"@type"?: "Media";
|
||||
kind: "photo" | "sound" | "logo";
|
||||
uri?: string;
|
||||
blobId?: Id;
|
||||
mediaType?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactRelation {
|
||||
"@type"?: "Relation";
|
||||
relation?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactCard {
|
||||
id: Id;
|
||||
addressBookIds: Record<Id, boolean>;
|
||||
"@type"?: "Card";
|
||||
version?: "1.0";
|
||||
uid: string;
|
||||
kind?: "individual" | "group" | "org" | "location" | "device" | "application";
|
||||
created?: UTCDate;
|
||||
updated?: UTCDate;
|
||||
language?: string;
|
||||
prodId?: string;
|
||||
members?: Record<string, boolean>;
|
||||
name?: JSContactName;
|
||||
nicknames?: Record<string, { "@type"?: "Nickname"; name: string; contexts?: Record<string, boolean>; pref?: number }>;
|
||||
organizations?: Record<string, JSContactOrganization>;
|
||||
titles?: Record<string, JSContactTitle>;
|
||||
emails?: Record<string, JSContactEmail>;
|
||||
phones?: Record<string, JSContactPhone>;
|
||||
addresses?: Record<string, JSContactAddress>;
|
||||
onlineServices?: Record<string, JSContactOnlineService>;
|
||||
anniversaries?: Record<string, JSContactAnniversary>;
|
||||
notes?: Record<string, JSContactNote>;
|
||||
keywords?: Record<string, boolean>;
|
||||
media?: Record<string, JSContactMedia>;
|
||||
relatedTo?: Record<string, JSContactRelation>;
|
||||
links?: Record<string, { "@type"?: "Link"; uri: string; kind?: string; label?: string }>;
|
||||
preferredLanguages?: Record<string, { "@type"?: "LanguagePref"; language: string; pref?: number; contexts?: Record<string, boolean> }>;
|
||||
speakToAs?: { "@type"?: "SpeakToAs"; grammaticalGender?: string; pronouns?: Record<string, { pronouns: string }> };
|
||||
calendars?: Record<string, { "@type"?: "Calendar"; kind?: string; uri: string }>;
|
||||
schedulingAddresses?: Record<string, { "@type"?: "SchedulingAddress"; uri: string }>;
|
||||
personalInfo?: Record<string, { "@type"?: "PersonalInfo"; kind: string; value: string; level?: string }>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Calendars (draft-ietf-jmap-calendars / JSCalendar RFC 8984) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface CalendarRights {
|
||||
mayReadFreeBusy: boolean;
|
||||
mayReadItems: boolean;
|
||||
mayWriteAll: boolean;
|
||||
mayWriteOwn: boolean;
|
||||
mayUpdatePrivate: boolean;
|
||||
mayRSVP: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: Id;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
sortOrder: number;
|
||||
isSubscribed: boolean;
|
||||
isVisible: boolean;
|
||||
isDefault: boolean;
|
||||
includeInAvailability: "all" | "attending" | "none";
|
||||
defaultAlertsWithTime: Record<string, JSCalendarAlert> | null;
|
||||
defaultAlertsWithoutTime: Record<string, JSCalendarAlert> | null;
|
||||
timeZone: string | null;
|
||||
shareWith: Record<Id, CalendarRights> | null;
|
||||
myRights: CalendarRights;
|
||||
}
|
||||
|
||||
export interface JSCalendarAlert {
|
||||
"@type"?: "Alert";
|
||||
trigger:
|
||||
| { "@type"?: "OffsetTrigger"; offset: string; relativeTo?: "start" | "end" }
|
||||
| { "@type"?: "AbsoluteTrigger"; when: UTCDate };
|
||||
acknowledged?: UTCDate;
|
||||
action?: "display" | "email";
|
||||
relatedTo?: Record<string, JSContactRelation>;
|
||||
}
|
||||
|
||||
export interface JSCalendarNDay {
|
||||
"@type"?: "NDay";
|
||||
day: "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
|
||||
nthOfPeriod?: number;
|
||||
}
|
||||
|
||||
export interface JSCalendarRecurrenceRule {
|
||||
"@type"?: "RecurrenceRule";
|
||||
frequency: "yearly" | "monthly" | "weekly" | "daily" | "hourly" | "minutely" | "secondly";
|
||||
interval?: number;
|
||||
rscale?: string;
|
||||
skip?: string;
|
||||
firstDayOfWeek?: string;
|
||||
byDay?: JSCalendarNDay[];
|
||||
byMonthDay?: number[];
|
||||
byMonth?: string[];
|
||||
byYearDay?: number[];
|
||||
byWeekNo?: number[];
|
||||
byHour?: number[];
|
||||
byMinute?: number[];
|
||||
bySecond?: number[];
|
||||
bySetPosition?: number[];
|
||||
count?: number;
|
||||
until?: LocalDate;
|
||||
}
|
||||
|
||||
export interface JSCalendarParticipant {
|
||||
"@type"?: "Participant";
|
||||
name?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
sendTo?: Record<string, string>;
|
||||
/** Where Stalwart 0.16 keeps the address, in place of `sendTo` / `email`. */
|
||||
calendarAddress?: string;
|
||||
kind?: "individual" | "group" | "location" | "resource";
|
||||
roles: Record<string, boolean>;
|
||||
locationId?: string;
|
||||
language?: string;
|
||||
participationStatus?: "needs-action" | "accepted" | "declined" | "tentative" | "delegated";
|
||||
participationComment?: string;
|
||||
expectReply?: boolean;
|
||||
scheduleAgent?: "server" | "client" | "none";
|
||||
scheduleForceSend?: boolean;
|
||||
scheduleSequence?: number;
|
||||
scheduleStatus?: string[];
|
||||
scheduleUpdated?: UTCDate;
|
||||
sentBy?: string;
|
||||
invitedBy?: string;
|
||||
delegatedTo?: Record<string, boolean>;
|
||||
delegatedFrom?: Record<string, boolean>;
|
||||
memberOf?: Record<string, boolean>;
|
||||
links?: Record<string, unknown>;
|
||||
progress?: string;
|
||||
percentComplete?: number;
|
||||
}
|
||||
|
||||
export interface JSCalendarLocation {
|
||||
"@type"?: "Location";
|
||||
name?: string;
|
||||
description?: string;
|
||||
locationTypes?: Record<string, boolean>;
|
||||
relativeTo?: "start" | "end";
|
||||
timeZone?: string;
|
||||
coordinates?: string;
|
||||
links?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JSCalendarVirtualLocation {
|
||||
"@type"?: "VirtualLocation";
|
||||
name?: string;
|
||||
description?: string;
|
||||
uri: string;
|
||||
features?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface JSCalendarEvent {
|
||||
"@type"?: "Event";
|
||||
uid: string;
|
||||
relatedTo?: Record<string, JSContactRelation>;
|
||||
prodId?: string;
|
||||
created?: UTCDate;
|
||||
updated?: UTCDate;
|
||||
sequence?: number;
|
||||
method?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
descriptionContentType?: string;
|
||||
showWithoutTime?: boolean;
|
||||
locations?: Record<string, JSCalendarLocation>;
|
||||
virtualLocations?: Record<string, JSCalendarVirtualLocation>;
|
||||
links?: Record<string, { "@type"?: "Link"; href: string; contentType?: string; size?: number; rel?: string; display?: string; title?: string }>;
|
||||
locale?: string;
|
||||
keywords?: Record<string, boolean>;
|
||||
categories?: Record<string, boolean>;
|
||||
color?: string;
|
||||
recurrenceId?: LocalDate;
|
||||
recurrenceIdTimeZone?: string;
|
||||
recurrenceRules?: JSCalendarRecurrenceRule[];
|
||||
/** Stalwart 0.16 stores a single rule under this name instead of the array above. */
|
||||
recurrenceRule?: JSCalendarRecurrenceRule;
|
||||
excludedRecurrenceRules?: JSCalendarRecurrenceRule[];
|
||||
recurrenceOverrides?: Record<LocalDate, Record<string, unknown> | null>;
|
||||
excluded?: boolean;
|
||||
priority?: number;
|
||||
freeBusyStatus?: "free" | "busy";
|
||||
privacy?: "public" | "private" | "secret";
|
||||
replyTo?: Record<string, string>;
|
||||
/** Where Stalwart 0.16 keeps the organizer, in place of `replyTo`. */
|
||||
organizerCalendarAddress?: string;
|
||||
sentBy?: string;
|
||||
participants?: Record<string, JSCalendarParticipant>;
|
||||
requestStatus?: string;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, JSCalendarAlert>;
|
||||
localizations?: Record<string, Record<string, unknown>>;
|
||||
timeZone?: string | null;
|
||||
start: LocalDate;
|
||||
duration?: string;
|
||||
status?: "confirmed" | "cancelled" | "tentative";
|
||||
}
|
||||
|
||||
export interface CalendarEvent extends JSCalendarEvent {
|
||||
id: Id;
|
||||
baseEventId?: Id | null;
|
||||
calendarIds: Record<Id, boolean>;
|
||||
isDraft?: boolean;
|
||||
isOrigin?: boolean;
|
||||
utcStart?: UTCDate;
|
||||
utcEnd?: UTCDate;
|
||||
mayInviteSelf?: boolean;
|
||||
mayInviteOthers?: boolean;
|
||||
hideAttendees?: boolean;
|
||||
}
|
||||
|
||||
export interface ParticipantIdentity {
|
||||
id: Id;
|
||||
name: string;
|
||||
calendarAddress: string;
|
||||
sendTo: Record<string, string>;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarEventNotification {
|
||||
id: Id;
|
||||
created: UTCDate;
|
||||
changedBy: { name: string; email: string | null; principalId: Id | null; calendarAddress?: string | null };
|
||||
comment: string | null;
|
||||
type: "created" | "updated" | "destroyed";
|
||||
calendarEventId: Id;
|
||||
isDraft?: boolean;
|
||||
event: JSCalendarEvent;
|
||||
eventPatch?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Files (draft-ietf-jmap-filenode) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface FilesRights {
|
||||
mayRead: boolean;
|
||||
mayAddChildren: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
mayModifyContent: boolean;
|
||||
mayShare: boolean;
|
||||
}
|
||||
|
||||
export interface FileNode {
|
||||
id: Id;
|
||||
parentId: Id | null;
|
||||
nodeType: "file" | "directory" | "symlink";
|
||||
blobId: Id | null;
|
||||
target?: string[] | null;
|
||||
size: number | null;
|
||||
name: string;
|
||||
type: string | null;
|
||||
created: UTCDate;
|
||||
modified: UTCDate | null;
|
||||
accessed?: UTCDate | null;
|
||||
changed?: UTCDate;
|
||||
executable?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
myRights: FilesRights;
|
||||
shareWith?: Record<Id, FilesRights> | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Blob (RFC 9404) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface UploadResponse {
|
||||
accountId: Id;
|
||||
blobId: Id;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BlobGetResponse {
|
||||
id: Id;
|
||||
"data:asText"?: string | null;
|
||||
"data:asBase64"?: string | null;
|
||||
size?: number;
|
||||
isEncodingProblem?: boolean;
|
||||
isTruncated?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
|
||||
|
||||
describe("address parsing", () => {
|
||||
it("parses mixed lists", () => {
|
||||
const list = parseAddressList('Ann Example <[email protected]>, [email protected]; "Smith, John" <[email protected]>');
|
||||
expect(list).toEqual([
|
||||
{ name: "Ann Example", email: "[email protected]" },
|
||||
{ name: null, email: "[email protected]" },
|
||||
{ name: "Smith, John", email: "[email protected]" },
|
||||
]);
|
||||
});
|
||||
it("formats with quoting when needed", () => {
|
||||
expect(formatAddress({ name: "Smith, John", email: "[email protected]" })).toBe('"Smith, John" <[email protected]>');
|
||||
expect(formatAddress({ name: null, email: "[email protected]" })).toBe("[email protected]");
|
||||
});
|
||||
it("validates and initials", () => {
|
||||
expect(isValidEmail("[email protected]")).toBe(true);
|
||||
expect(isValidEmail("nope")).toBe(false);
|
||||
expect(initials({ name: "Grace Hopper", email: "" })).toBe("GH");
|
||||
expect(initials({ name: null, email: "[email protected]" })).toBe("LK");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mailto URLs", () => {
|
||||
it("takes recipients from the path, the to header, or both", () => {
|
||||
expect(parseMailto("mailto:[email protected]")).toMatchObject({ to: [{ name: null, email: "[email protected]" }] });
|
||||
expect(parseMailto("mailto:[email protected]").to).toEqual([{ name: null, email: "[email protected]" }]);
|
||||
expect(parseMailto("mailto:[email protected][email protected]").to).toHaveLength(2);
|
||||
expect(parseMailto("mailto:[email protected],[email protected]").to).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reads cc, bcc, subject and body", () => {
|
||||
const m = parseMailto("mailto:[email protected][email protected]&[email protected]&subject=Hello%20there&body=Line%20one");
|
||||
expect(m.cc).toEqual([{ name: null, email: "[email protected]" }]);
|
||||
expect(m.bcc).toEqual([{ name: null, email: "[email protected]" }]);
|
||||
expect(m.subject).toBe("Hello there");
|
||||
expect(m.body).toBe("Line one");
|
||||
});
|
||||
|
||||
it("is case-insensitive about headers and decodes plus as space", () => {
|
||||
const m = parseMailto("MAILTO:[email protected]?SUBJECT=Re:+lunch&Body=see+you");
|
||||
expect(m.subject).toBe("Re: lunch");
|
||||
expect(m.body).toBe("see you");
|
||||
});
|
||||
|
||||
it("keeps display names and survives malformed escapes", () => {
|
||||
expect(parseMailto('mailto:%22Smith%2C%20John%22%20%[email protected]%3E').to).toEqual([{ name: "Smith, John", email: "[email protected]" }]);
|
||||
expect(parseMailto("mailto:[email protected]?subject=100%").subject).toBe("100%");
|
||||
});
|
||||
|
||||
it("ignores headers it does not understand", () => {
|
||||
const m = parseMailto("mailto:[email protected]?x-random=1&subject=Hi");
|
||||
expect(m.subject).toBe("Hi");
|
||||
expect(m.to).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contactFromAddress, nameParts } from "../contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
const parts = (name: string | null, email = "[email protected]") =>
|
||||
nameParts(contactFromAddress({ name, email }) as ContactCard);
|
||||
|
||||
describe("contactFromAddress", () => {
|
||||
it("keeps the address as the preferred email", () => {
|
||||
const card = contactFromAddress({ name: "Ada Lovelace", email: "[email protected]" });
|
||||
const emails = Object.values(card.emails ?? {});
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0]).toMatchObject({ address: "[email protected]", pref: 1 });
|
||||
expect(card.kind).toBe("individual");
|
||||
});
|
||||
|
||||
it("splits a display name into components", () => {
|
||||
expect(parts("Ada Lovelace")).toMatchObject({ given: "Ada", surname: "Lovelace" });
|
||||
expect(parts("Ada King Lovelace")).toMatchObject({ given: "Ada", middle: "King", surname: "Lovelace" });
|
||||
expect(parts("Prince")).toMatchObject({ given: "Prince", surname: "" });
|
||||
});
|
||||
|
||||
it("unpicks the surname-first form", () => {
|
||||
expect(parts("Lovelace, Ada")).toMatchObject({ given: "Ada", surname: "Lovelace" });
|
||||
});
|
||||
|
||||
it("strips surrounding quotes", () => {
|
||||
expect(parts('"Ada Lovelace"')).toMatchObject({ given: "Ada", surname: "Lovelace" });
|
||||
});
|
||||
|
||||
it("leaves the name empty when the header carries an address, not a name", () => {
|
||||
expect(contactFromAddress({ name: "[email protected]", email: "[email protected]" }).name).toBeUndefined();
|
||||
expect(contactFromAddress({ name: null, email: "[email protected]" }).name).toBeUndefined();
|
||||
expect(contactFromAddress({ name: " ", email: "[email protected]" }).name).toBeUndefined();
|
||||
});
|
||||
});
|
||||