mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-25 10:09:55 +08:00
Compare commits
445 Commits
v0.5.1
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57b828f15c | ||
|
|
5dbd944371 | ||
|
|
6bc98c13f7 | ||
|
|
7a09deb4a4 | ||
|
|
1cd43578ad | ||
|
|
cff2db0ef6 | ||
|
|
80694987e9 | ||
|
|
49f933d9ab | ||
|
|
d09e44c055 | ||
|
|
3031589a4b | ||
|
|
6800dfed3f | ||
|
|
9109612322 | ||
|
|
21c5390723 | ||
|
|
e37c851e12 | ||
|
|
57c79e64ce | ||
|
|
167b1fccca | ||
|
|
ce0f5ef411 | ||
|
|
46efabd184 | ||
|
|
54afb10345 | ||
|
|
843bcd827d | ||
|
|
10760ccc44 | ||
|
|
43d9a9dfe9 | ||
|
|
dccca92928 | ||
|
|
005d74bfde | ||
|
|
aeb2ac687d | ||
|
|
108b8ef4ac | ||
|
|
2762741226 | ||
|
|
3ffa16234f | ||
|
|
3bcc0c19ce | ||
|
|
a3c62de42f | ||
|
|
624fc9bb96 | ||
|
|
12152fc893 | ||
|
|
a09ede8944 | ||
|
|
f9281818e9 | ||
|
|
5553c6484a | ||
|
|
d04c1a865d | ||
|
|
116ddc7324 | ||
|
|
2dcbad40ad | ||
|
|
95badf5aed | ||
|
|
b3666c0900 | ||
|
|
e499211197 | ||
|
|
342fe22e4f | ||
|
|
8c883b269f | ||
|
|
4c1e593d07 | ||
|
|
7eaa3b3b8e | ||
|
|
0581632b59 | ||
|
|
4ce22ef249 | ||
|
|
99b332345b | ||
|
|
565bb839db | ||
|
|
dbd1f8706d | ||
|
|
3f1d800e90 | ||
|
|
70b30c2494 | ||
|
|
1a1dd720c8 | ||
|
|
2d501d82cf | ||
|
|
7c57592742 | ||
|
|
41105ed803 | ||
|
|
c924b71a5c | ||
|
|
4f0b44de2e | ||
|
|
f6fcbe793c | ||
|
|
05a8ebb590 | ||
|
|
b7718100c5 | ||
|
|
bf786947e3 | ||
|
|
cfb31807f1 | ||
|
|
308fbe2f9a | ||
|
|
44b29aa646 | ||
|
|
2221342560 | ||
|
|
add0124cfd | ||
|
|
8324b133fb | ||
|
|
74c8e8f7e4 | ||
|
|
2df4de22d9 | ||
|
|
437db7c050 | ||
|
|
dd294037ab | ||
|
|
a9e2c89246 | ||
|
|
72bbfe8fd6 | ||
|
|
796a5e4ac5 | ||
|
|
347be5c762 | ||
|
|
0b17953097 | ||
|
|
ec3e9f0b1f | ||
|
|
fb6e77fded | ||
|
|
c703082391 | ||
|
|
4e01b4e26f | ||
|
|
7d68cbdffa | ||
|
|
6d43c107d8 | ||
|
|
46fbb4c099 | ||
|
|
c9698d03f1 | ||
|
|
5f955ccca6 | ||
|
|
7e7f824f88 | ||
|
|
eb62c37e02 | ||
|
|
1e50bb0933 | ||
|
|
063b6be2b1 | ||
|
|
e94ff52add | ||
|
|
296ddb8619 | ||
|
|
a5b64e1dc9 | ||
|
|
fa19dbbe02 | ||
|
|
ebeb94ed23 | ||
|
|
d1fb1f773b | ||
|
|
5c40eeec80 | ||
|
|
000cd0ddfa | ||
|
|
e772db8c3e | ||
|
|
a5aa475380 | ||
|
|
3221f5ae30 | ||
|
|
15e339282d | ||
|
|
e15b1b83d0 | ||
|
|
c297a49b2a | ||
|
|
de3f9e92ed | ||
|
|
832e996dd8 | ||
|
|
e81c9d0d9c | ||
|
|
163df5c908 | ||
|
|
c3058817ff | ||
|
|
16c4e43871 | ||
|
|
68cbfb9c32 | ||
|
|
e18285d3ef | ||
|
|
1584851a36 | ||
|
|
1cafbbf220 | ||
|
|
873a10ddb1 | ||
|
|
9689a1cbca | ||
|
|
ef475bab21 | ||
|
|
e6ef110ec9 | ||
|
|
42281cdc49 | ||
|
|
5248c03b6c | ||
|
|
b86d1faac4 | ||
|
|
a0db913952 | ||
|
|
4746983780 | ||
|
|
f08d062b32 | ||
|
|
8885948291 | ||
|
|
7c6d0d7c8a | ||
|
|
53c35062c8 | ||
|
|
1a7cfb8c95 | ||
|
|
d2c940aa2c | ||
|
|
db93828a81 | ||
|
|
be1bf71a47 | ||
|
|
424991a165 | ||
|
|
c97a9a278b | ||
|
|
a45d01f9fd | ||
|
|
03965f3612 | ||
|
|
64d11799b3 | ||
|
|
10f1f1f32b | ||
|
|
e77ab12140 | ||
|
|
79b9835fa2 | ||
|
|
6c58cd3c2e | ||
|
|
eeea512ab1 | ||
|
|
e35c246757 | ||
|
|
e7df77cac0 | ||
|
|
9ee21da8a9 | ||
|
|
5bb053fb7b | ||
|
|
7d880ef340 | ||
|
|
e6cc8e2ffd | ||
|
|
94c606959f | ||
|
|
75236e6a53 | ||
|
|
13c3879033 | ||
|
|
c5893a2944 | ||
|
|
5f3762ef58 | ||
|
|
10873e7887 | ||
|
|
fca9bade48 | ||
|
|
f5ca8afcce | ||
|
|
8cf1150b15 | ||
|
|
8341cae28f | ||
|
|
635e0f4456 | ||
|
|
e81d46262d | ||
|
|
13426b2fbd | ||
|
|
2a52fd35d5 | ||
|
|
e38015a5b6 | ||
|
|
f98bbce234 | ||
|
|
2f8183e024 | ||
|
|
e115b99271 | ||
|
|
12ab6e1430 | ||
|
|
6e6e3f0877 | ||
|
|
286dfabd5f | ||
|
|
aaae21e92e | ||
|
|
3df55dce91 | ||
|
|
13b009f6ab | ||
|
|
0c337a1942 | ||
|
|
372f7b4149 | ||
|
|
abad88b986 | ||
|
|
928a35b7cb | ||
|
|
006ddf4aa4 | ||
|
|
f55e8c9818 | ||
|
|
4ef4c0d938 | ||
|
|
f4255f33a1 | ||
|
|
a2d37b8183 | ||
|
|
9b7a80ef54 | ||
|
|
a5e5fceab5 | ||
|
|
0df74ee5cc | ||
|
|
2b33d953fa | ||
|
|
1f969738f5 | ||
|
|
4b378ca710 | ||
|
|
bafd003cbd | ||
|
|
723e1fe75d | ||
|
|
566c6536d1 | ||
|
|
bde08b9d55 | ||
|
|
56351ed963 | ||
|
|
9583f0e1c5 | ||
|
|
898324777e | ||
|
|
0f418d7e94 | ||
|
|
e4b6c82e92 | ||
|
|
d367bc92b2 | ||
|
|
f0da9289fc | ||
|
|
decede7ed3 | ||
|
|
e4c96c9868 | ||
|
|
2318e0f7e2 | ||
|
|
2cce3df213 | ||
|
|
a38a31a407 | ||
|
|
276093f113 | ||
|
|
36f8c4b3de | ||
|
|
8964d4461d | ||
|
|
a771446b9b | ||
|
|
50ab6756bd | ||
|
|
aee1f1942b | ||
|
|
3ebe22115a | ||
|
|
5e227d2b2d | ||
|
|
3b3968f3b4 | ||
|
|
499f65078b | ||
|
|
0d63142bd7 | ||
|
|
16ce1bf4e0 | ||
|
|
288eb38302 | ||
|
|
24366e2bff | ||
|
|
e5f62d4713 | ||
|
|
1836f931ee | ||
|
|
0f2836eebb | ||
|
|
b933aef7d9 | ||
|
|
fa72d7187f | ||
|
|
d15a4904a5 | ||
|
|
a25199eb34 | ||
|
|
7d485a7d0d | ||
|
|
abe812666f | ||
|
|
dbb55d948f | ||
|
|
a2a9f9e25f | ||
|
|
793901d349 | ||
|
|
113f9ad66b | ||
|
|
088bf3eefe | ||
|
|
024f9ba430 | ||
|
|
b337a44e62 | ||
|
|
eaeac8ebec | ||
|
|
7393519ba4 | ||
|
|
4ddc8e5c96 | ||
|
|
8b7ddae4f6 | ||
|
|
be36967b80 | ||
|
|
fac249ed31 | ||
|
|
bfd7d6811e | ||
|
|
b5c229b6c4 | ||
|
|
2728e9667b | ||
|
|
6109ab9e82 | ||
|
|
09a6cac8fe | ||
|
|
5f752c94f9 | ||
|
|
a2f3634c7e | ||
|
|
b62a3cbc3e | ||
|
|
8edb75587e | ||
|
|
de48661d0d | ||
|
|
a905ba5f06 | ||
|
|
6ae90be3bf | ||
|
|
5e24817de6 | ||
|
|
732189482e | ||
|
|
2bbde15f53 | ||
|
|
37cf0776b5 | ||
|
|
3fbace871c | ||
|
|
648e9f7adf | ||
|
|
ab2bfdd00f | ||
|
|
0565978930 | ||
|
|
89d8944e60 | ||
|
|
4084771621 | ||
|
|
840496c48f | ||
|
|
9843b35f54 | ||
|
|
bfd66f5019 | ||
|
|
0bc31360b0 | ||
|
|
267d9bb93e | ||
|
|
2cc84d565c | ||
|
|
c96d180591 | ||
|
|
1303b0f2a9 | ||
|
|
9f535a0a90 | ||
|
|
70109785c6 | ||
|
|
7fd10f2775 | ||
|
|
f59b8c7a1b | ||
|
|
312ac13185 | ||
|
|
e6c582be9f | ||
|
|
483c429feb | ||
|
|
da5482e095 | ||
|
|
de4646876a | ||
|
|
bbc8a96811 | ||
|
|
9ac9cd46b0 | ||
|
|
c694b07380 | ||
|
|
672c4c7273 | ||
|
|
ee023ac2e9 | ||
|
|
cc77bdf36d | ||
|
|
dec309a0fd | ||
|
|
9488543e44 | ||
|
|
50326bcc98 | ||
|
|
272b624b9b | ||
|
|
e230801a1c | ||
|
|
07833d5ca9 | ||
|
|
101a561894 | ||
|
|
327962432a | ||
|
|
6051d49315 | ||
|
|
95f361743b | ||
|
|
c6afc5d425 | ||
|
|
466f53254b | ||
|
|
ce0a10e6de | ||
|
|
26995982af | ||
|
|
0894ac0dc9 | ||
|
|
47e2cb56b4 | ||
|
|
32767176f0 | ||
|
|
31eb6c23d1 | ||
|
|
91a859bbcf | ||
|
|
525f5e2dce | ||
|
|
908fc0cc86 | ||
|
|
97d24b2087 | ||
|
|
983300acf4 | ||
|
|
144a792cb2 | ||
|
|
278f0112d0 | ||
|
|
764faebf9f | ||
|
|
d4f0c82e42 | ||
|
|
cf680e6349 | ||
|
|
c3987d364c | ||
|
|
3a542a8391 | ||
|
|
241e0b7b28 | ||
|
|
b43353ea47 | ||
|
|
6c334d32f6 | ||
|
|
7889d2edea | ||
|
|
2426e0b51a | ||
|
|
61434ab6f7 | ||
|
|
7f6a02ca38 | ||
|
|
6ae3b0d85e | ||
|
|
01e6cb1075 | ||
|
|
814f6fada2 | ||
|
|
31901aacc5 | ||
|
|
fb9b9f6ae4 | ||
|
|
095951ab45 | ||
|
|
37614ce6fa | ||
|
|
3f81fbee6d | ||
|
|
cf13236e7b | ||
|
|
36e9c611e6 | ||
|
|
047200c1c2 | ||
|
|
a22add0e14 | ||
|
|
7b1c4cc72a | ||
|
|
3870727a08 | ||
|
|
2bb033964c | ||
|
|
9db5a00b35 | ||
|
|
e161eb5d14 | ||
|
|
b604f56d56 | ||
|
|
52caf811f5 | ||
|
|
ee3884914b | ||
|
|
844fc52bbc | ||
|
|
b87b49f09d | ||
|
|
5bfa588f70 | ||
|
|
92620cdedb | ||
|
|
e9748be9fe | ||
|
|
479322c430 | ||
|
|
934e58e23b | ||
|
|
c964d77a59 | ||
|
|
8a03d3e57f | ||
|
|
6caba7c863 | ||
|
|
43e5bdc764 | ||
|
|
7bec0daba4 | ||
|
|
13e5adef17 | ||
|
|
440238133e | ||
|
|
4a881e2d2b | ||
|
|
b0bf7a5f13 | ||
|
|
a9bb8785ba | ||
|
|
0b48baff6d | ||
|
|
e0b5e80efd | ||
|
|
b0e36ac2aa | ||
|
|
51db19c85b | ||
|
|
e52b010aa4 | ||
|
|
8f6793402c | ||
|
|
e86c530116 | ||
|
|
0308f518da | ||
|
|
3c2a8ed056 | ||
|
|
5f45ec7c14 | ||
|
|
1b7ebc98c5 | ||
|
|
c102004f4d | ||
|
|
3c81e05a2f | ||
|
|
5ff2ceb5e8 | ||
|
|
6c82efb738 | ||
|
|
e99acdcc6e | ||
|
|
8f30505706 | ||
|
|
ddfa2c5d03 | ||
|
|
49b3f10838 | ||
|
|
cc9ac67319 | ||
|
|
7cc2a2b576 | ||
|
|
393c5902c3 | ||
|
|
5ece49a576 | ||
|
|
de80857e2c | ||
|
|
a57a42b2a1 | ||
|
|
a24cc1f642 | ||
|
|
4c6fd3c2af | ||
|
|
1cf38c1768 | ||
|
|
b5b59acdb3 | ||
|
|
6d4783e1cd | ||
|
|
34e3e1b439 | ||
|
|
56104cd23a | ||
|
|
3664028e06 | ||
|
|
9888f98d74 | ||
|
|
ac5605f17f | ||
|
|
a9719cb3ec | ||
|
|
5f4978645b | ||
|
|
621476cb79 | ||
|
|
c969c4b082 | ||
|
|
d90f54345d | ||
|
|
797b8bb019 | ||
|
|
7e5d142924 | ||
|
|
c6d0307eac | ||
|
|
ac31042e69 | ||
|
|
c733d3bf4d | ||
|
|
bf1243f4c4 | ||
|
|
15063b2e97 | ||
|
|
fc07f1cd87 | ||
|
|
9246550cc5 | ||
|
|
979b6eae1a | ||
|
|
10da337a9c | ||
|
|
9c5e8857af | ||
|
|
84b4baa99e | ||
|
|
b57d46244a | ||
|
|
5faae8796d | ||
|
|
a0805bc0ce | ||
|
|
d0ccc3ded1 | ||
|
|
163d9451f7 | ||
|
|
60dda7e3fe | ||
|
|
384eb9b041 | ||
|
|
38816cbf0f | ||
|
|
d7d1ba6b64 | ||
|
|
14725e9e9f | ||
|
|
2c1e63b8bc | ||
|
|
f3a1d980c5 | ||
|
|
75c48beb3b | ||
|
|
26ccfdd6e0 | ||
|
|
aa8f3b4d46 | ||
|
|
a749c829d2 | ||
|
|
4b2caf1a4b | ||
|
|
80a8848ed8 | ||
|
|
dcfc1b3721 | ||
|
|
b0a0a6a1ef | ||
|
|
00c671cf14 | ||
|
|
0b78d1ff4a | ||
|
|
d152a7ce9f | ||
|
|
21fed3fb00 | ||
|
|
9448b3c754 | ||
|
|
f1827f223a | ||
|
|
2a0a34869e | ||
|
|
881e66e484 | ||
|
|
de7c3d5176 | ||
|
|
720d097ed7 | ||
|
|
53a03dc6a0 | ||
|
|
72b99e0c5e | ||
|
|
c4d9fe1fb9 | ||
|
|
af9f46ba65 | ||
|
|
8bfd76bf71 |
1
.agents/skills
Symbolic link
1
.agents/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
1
.claude/skills/cf-temp-mail-agent-mail
Symbolic link
1
.claude/skills/cf-temp-mail-agent-mail
Symbolic link
@@ -0,0 +1 @@
|
||||
../../skills/cf-temp-mail-agent-mail
|
||||
3
.claude/skills/cf-temp-mail-release-notify/.gitignore
vendored
Normal file
3
.claude/skills/cf-temp-mail-release-notify/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
config.json
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
30
.claude/skills/cf-temp-mail-release-notify/SKILL.md
Normal file
30
.claude/skills/cf-temp-mail-release-notify/SKILL.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: cf-temp-mail-release-notify
|
||||
description: Announce a cloudflare_temp_email GitHub release to the project's Telegram channel topic. Use when the user asks to notify/announce/broadcast a release to Telegram, push release notes to the channel, or send a release to the topic after running cf-temp-mail-release. Posts bilingual (中文 + English) changelog excerpts plus the release URL.
|
||||
---
|
||||
|
||||
# Release Notify Workflow
|
||||
|
||||
Post an existing GitHub release's notes to the project's Telegram channel topic.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `config.json` exists in this skill directory with `token`, `chat_id`, `message_thread_id` (gitignored, never commit).
|
||||
- `gh` CLI authenticated.
|
||||
- `uv` installed (`brew install uv` / `curl -LsSf https://astral.sh/uv/install.sh | sh`). Script uses PEP 723 inline metadata; `uv` auto-installs deps.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Resolve tag**: If the user didn't give one, use the latest release: `gh release list --limit 1 --json tagName --jq '.[0].tagName'`.
|
||||
2. **Run the script**:
|
||||
```bash
|
||||
uv run scripts/send_release_to_telegram.py vX.Y.Z
|
||||
```
|
||||
The script fetches the release via `gh`, splits the body into zh/en sections, strips PR collapsibles and the cache-clearing link, truncates to fit Telegram's 4096-char limit, and posts to the configured `chat_id` + `message_thread_id`.
|
||||
3. **Verify**: The script prints `ok: message_id=<id>` on success. Report the message id.
|
||||
|
||||
## Notes
|
||||
|
||||
- Message uses `parse_mode: MarkdownV2`; all content is escaped (via `md_escape`) to avoid parse errors on reserved chars `_ * [ ] ( ) ~ \` > # + - = | { } . !`.
|
||||
- Only the zh/en changelog sections are posted. PRs list and the cache-clearing discussion link are stripped to keep the message concise.
|
||||
- For very long release bodies, zh and en are each truncated to ~half of the 3500-char body budget.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"token": "<telegram bot token>",
|
||||
"chat_id": "@cloudflare_temp_email",
|
||||
"message_thread_id": 82
|
||||
}
|
||||
221
.claude/skills/cf-temp-mail-release-notify/scripts/send_release_to_telegram.py
Executable file
221
.claude/skills/cf-temp-mail-release-notify/scripts/send_release_to_telegram.py
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["httpx>=0.27"]
|
||||
# ///
|
||||
"""Send a cloudflare_temp_email release announcement to a Telegram channel topic.
|
||||
|
||||
Usage:
|
||||
uv run scripts/send_release_to_telegram.py <tag>
|
||||
|
||||
Reads skill config from ../config.json (relative to this script):
|
||||
{
|
||||
"token": "...",
|
||||
"chat_id": "@channel_or_-100...",
|
||||
"message_thread_id": 82
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
TG_API = "https://api.telegram.org"
|
||||
TG_HARD_LIMIT = 4096
|
||||
BODY_BUDGET = 3500 # leave room for header + footer
|
||||
EN_MARKER_RE = re.compile(r"<details>\s*<summary>English</summary>", re.IGNORECASE)
|
||||
MDV2_ESCAPE_RE = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
|
||||
MDV2_CODE_ESCAPE_RE = re.compile(r"([`\\])")
|
||||
MD_INLINE_RE = re.compile(r"\*\*(.+?)\*\*|`([^`]+)`")
|
||||
MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
||||
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def md_escape(text: str) -> str:
|
||||
"""Escape all MarkdownV2 reserved characters."""
|
||||
return MDV2_ESCAPE_RE.sub(r"\\\1", text)
|
||||
|
||||
|
||||
def md_render(text: str) -> str:
|
||||
"""Convert source Markdown (changelog) to Telegram MarkdownV2.
|
||||
|
||||
Handles:
|
||||
- `### Heading` -> bold line
|
||||
- `**bold**` -> `*bold*`
|
||||
- `` `code` `` -> `` `code` `` (only ` and \\ escaped inside)
|
||||
- everything else: literal text with MDV2 specials escaped
|
||||
"""
|
||||
out: list[str] = []
|
||||
for raw in text.splitlines():
|
||||
m = MD_HEADING_RE.match(raw)
|
||||
if m:
|
||||
out.append(f"*{md_escape(m.group(2).strip())}*")
|
||||
continue
|
||||
segments: list[str] = []
|
||||
last = 0
|
||||
for im in MD_INLINE_RE.finditer(raw):
|
||||
segments.append(md_escape(raw[last:im.start()]))
|
||||
if im.group(1) is not None:
|
||||
segments.append(f"*{md_escape(im.group(1))}*")
|
||||
else:
|
||||
segments.append(f"`{MDV2_CODE_ESCAPE_RE.sub(r'\\\\\1', im.group(2))}`")
|
||||
last = im.end()
|
||||
segments.append(md_escape(raw[last:]))
|
||||
out.append("".join(segments))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg_path = Path(__file__).resolve().parent.parent / "config.json"
|
||||
if not cfg_path.exists():
|
||||
die(f"config missing: {cfg_path}")
|
||||
try:
|
||||
cfg = json.loads(cfg_path.read_text())
|
||||
except json.JSONDecodeError as e:
|
||||
die(f"config.json is not valid JSON: {e}")
|
||||
for k in ("token", "chat_id", "message_thread_id"):
|
||||
if k not in cfg:
|
||||
die(f"config.json missing key: {k}")
|
||||
return cfg
|
||||
|
||||
|
||||
def fetch_release(tag: str) -> dict:
|
||||
out = subprocess.run(
|
||||
["gh", "release", "view", tag, "--json", "tagName,name,body,url"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
die(f"gh release view failed: {out.stderr.strip()}")
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def extract_sections(body: str) -> tuple[str, str]:
|
||||
m = EN_MARKER_RE.search(body)
|
||||
if not m:
|
||||
return body.strip(), ""
|
||||
zh = body[: m.start()]
|
||||
rest = body[m.end():]
|
||||
close = rest.find("</details>")
|
||||
if close < 0:
|
||||
die("malformed release body: missing </details> after English marker")
|
||||
en = rest[:close]
|
||||
return zh.strip(), en.strip()
|
||||
|
||||
|
||||
def strip_noise(text: str) -> str:
|
||||
"""Drop PR collapsibles, cache-clearing link, and Full Changelog line."""
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
depth = 0
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("<details>"):
|
||||
depth += 1
|
||||
continue
|
||||
if stripped.startswith("</details>"):
|
||||
depth = max(0, depth - 1)
|
||||
continue
|
||||
if depth > 0:
|
||||
continue
|
||||
if "discussions/487" in stripped:
|
||||
continue
|
||||
if stripped.startswith("**Full Changelog**"):
|
||||
continue
|
||||
out.append(line)
|
||||
result: list[str] = []
|
||||
blanks = 0
|
||||
for line in out:
|
||||
if not line.strip():
|
||||
blanks += 1
|
||||
if blanks <= 1:
|
||||
result.append(line)
|
||||
else:
|
||||
blanks = 0
|
||||
result.append(line)
|
||||
return "\n".join(result).strip()
|
||||
|
||||
|
||||
def truncate(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 3].rstrip() + "..."
|
||||
|
||||
|
||||
def _budget(zh: str, en: str, total: int) -> tuple[int, int]:
|
||||
"""Split budget between zh and en based on actual length. Short side keeps full, long side absorbs the rest."""
|
||||
if not en:
|
||||
return total, 0
|
||||
if len(zh) + len(en) <= total:
|
||||
return len(zh), len(en)
|
||||
half = total // 2
|
||||
if len(zh) <= half:
|
||||
return len(zh), total - len(zh)
|
||||
if len(en) <= half:
|
||||
return total - len(en), len(en)
|
||||
return half, total - half
|
||||
|
||||
|
||||
def build_message(tag: str, name: str, url: str, body: str) -> str:
|
||||
zh, en = extract_sections(body)
|
||||
zh = strip_noise(zh)
|
||||
en = strip_noise(en)
|
||||
|
||||
zh_limit, en_limit = _budget(zh, en, BODY_BUDGET)
|
||||
zh = truncate(zh, zh_limit)
|
||||
en = truncate(en, en_limit) if en else ""
|
||||
|
||||
title = md_escape(name or tag)
|
||||
header = f"🚀 *{title} 已发布 / Released*"
|
||||
parts = [header, "", md_render(zh)]
|
||||
if en:
|
||||
parts.extend(["", "__English__", "", md_render(en)])
|
||||
parts.extend(["", f"🔗 {md_escape(url)}"])
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def send(cfg: dict, text: str) -> None:
|
||||
payload = {
|
||||
"chat_id": cfg["chat_id"],
|
||||
"message_thread_id": cfg["message_thread_id"],
|
||||
"text": text,
|
||||
"parse_mode": "MarkdownV2",
|
||||
"disable_web_page_preview": False,
|
||||
}
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{TG_API}/bot{cfg['token']}/sendMessage",
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
die(f"Telegram network error: {e}")
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
die(f"Telegram API returned non-JSON ({resp.status_code}): {resp.text[:200]!r}")
|
||||
if resp.status_code != 200 or not data.get("ok"):
|
||||
die(f"Telegram API rejected ({resp.status_code}): {data}")
|
||||
print(f"ok: message_id={data['result'].get('message_id')}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
die("usage: send_release_to_telegram.py <tag>")
|
||||
cfg = load_config()
|
||||
rel = fetch_release(sys.argv[1])
|
||||
text = build_message(rel["tagName"], rel.get("name", ""), rel["url"], rel.get("body", ""))
|
||||
send(cfg, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
28
.claude/skills/cf-temp-mail-release/SKILL.md
Normal file
28
.claude/skills/cf-temp-mail-release/SKILL.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: cf-temp-mail-release
|
||||
description: Create a GitHub release for cloudflare_temp_email project. Use when the user asks to create a release, publish a version, tag a release, or make a new release. Reads CHANGELOG.md for release content, collects merged PRs via `gh` CLI, and creates a properly formatted GitHub release.
|
||||
---
|
||||
|
||||
# Release Workflow
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read version**: Get current version from `worker/package.json` (`"version"` field) and the latest release tag via `gh release list --limit 1`.
|
||||
2. **Read CHANGELOG**: Read `CHANGELOG.md` for the current version section (e.g. `## v1.4.0(main)`). Verify content matches `CHANGELOG_EN.md`. If entries are missing from either file, notify the user.
|
||||
3. **Collect PRs**: Get the last release tag timestamp, then filter merged PRs by time:
|
||||
```bash
|
||||
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
|
||||
SINCE="$(git show -s --format=%cI "$TAG")"
|
||||
gh pr list --state merged --search "is:pr is:merged merged:>$SINCE base:main" --json number,title,author --limit 200
|
||||
```
|
||||
Sort by PR number ascending.
|
||||
4. **Compose release body**: Follow the template in [references/release-template.md](references/release-template.md). Key rules:
|
||||
- Write release body in **bilingual format**: Chinese sections first (from `CHANGELOG.md`), then wrap the English sections (from `CHANGELOG_EN.md`) in `<details><summary>English</summary>...</details>`.
|
||||
- Copy changelog sections verbatim from both files. Omit empty sections.
|
||||
- Wrap PRs list in `<details><summary>PRs</summary>...</details>`.
|
||||
- Always include the cache-clearing discussion link.
|
||||
- End with `**Full Changelog**` comparison link.
|
||||
5. **Create release**:
|
||||
- Write body to a temp file (e.g. `/tmp/release-notes.md`)
|
||||
- Run: `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes.md --target main`
|
||||
6. **Verify**: Confirm the release URL and ask the user to review.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Release Notes Template
|
||||
|
||||
Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本段落提取:
|
||||
|
||||
```markdown
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |模块| 描述
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |模块| 描述
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |模块| 描述
|
||||
|
||||
### Improvements
|
||||
|
||||
- style/refactor/perf/docs: |模块| 描述
|
||||
|
||||
### [更新或者部署网页不生效请如图勾选清理缓存](https://github.com/dreamhunter2333/cloudflare_temp_email/discussions/487)
|
||||
|
||||
<details>
|
||||
<summary>PRs</summary>
|
||||
|
||||
* PR title by @author in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/NUMBER
|
||||
|
||||
</details>
|
||||
|
||||
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/vOLD...vNEW
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Sections without entries should be omitted
|
||||
- PRs section uses `<details>` to collapse by default
|
||||
- PRs are sorted by PR number ascending
|
||||
- The cache clearing discussion link is always included
|
||||
- Release title and tag use format `vX.Y.Z`
|
||||
46
.claude/skills/cf-temp-mail-upgrade-dependencies/SKILL.md
Normal file
46
.claude/skills/cf-temp-mail-upgrade-dependencies/SKILL.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: cf-temp-mail-upgrade-dependencies
|
||||
description: Upgrade npm dependencies across all sub-packages of the project. Use when the user asks to upgrade/update dependencies, bump deps, refresh lockfiles, or update wrangler. Runs pnpm upgrades on frontend/, worker/, pages/, and vitepress-docs/, plus npm upgrades on e2e/.
|
||||
---
|
||||
|
||||
# Upgrade Dependencies
|
||||
|
||||
Upgrade npm dependencies for the cloudflare_temp_email sub-packages.
|
||||
|
||||
## How to run
|
||||
|
||||
Execute the project-root script:
|
||||
|
||||
```bash
|
||||
bash scripts/update-dependencies.sh
|
||||
```
|
||||
|
||||
The script runs the following in order:
|
||||
|
||||
| Directory | Commands |
|
||||
|-----------|----------|
|
||||
| `frontend/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||
| `worker/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||
| `pages/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||
| `vitepress-docs/` | `pnpm up --latest` + `pnpm add -D wrangler@latest` |
|
||||
| `e2e/` | `npx --yes npm-check-updates@23.0.0 --upgrade` + `npm install` + Playwright image validation |
|
||||
|
||||
Note: `vitepress-docs/` and `e2e/` upgrade to the latest versions and may cross semver ranges; other packages upgrade within ranges only. `e2e/` uses npm because it has a `package-lock.json`.
|
||||
|
||||
## Post-upgrade checklist
|
||||
|
||||
1. Inspect `git diff` on `package.json`, `pnpm-lock.yaml`, and `package-lock.json` files for reasonable changes.
|
||||
2. Verify builds in each sub-package:
|
||||
- `cd frontend && pnpm build`
|
||||
- `cd worker && pnpm build && pnpm lint`
|
||||
- `cd vitepress-docs && pnpm build`
|
||||
- `cd e2e && npm test`
|
||||
3. If Playwright changed, keep `e2e/Dockerfile.e2e` on the matching Playwright image version.
|
||||
4. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
|
||||
5. Commit with Conventional Commits format, e.g. `chore: upgrade dependencies`.
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Do not manually `pnpm add` each package instead of running the script.
|
||||
- Do not run `pnpm deploy` locally — deployments go through GitHub Actions.
|
||||
- Do not update CHANGELOG for routine dep bumps unless the user explicitly requests it.
|
||||
65
.claude/skills/cf-temp-mail-version-upgrade/SKILL.md
Normal file
65
.claude/skills/cf-temp-mail-version-upgrade/SKILL.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: cf-temp-mail-version-upgrade
|
||||
description: Upgrade the project version number. Use when the user asks to bump the version, upgrade the version, or prepare a new release version. Supports major, minor, and patch upgrades.
|
||||
---
|
||||
|
||||
# Version Upgrade
|
||||
|
||||
Upgrade the version number of the cloudflare_temp_email project.
|
||||
|
||||
## Files to modify
|
||||
|
||||
1. `frontend/package.json` — `version` field
|
||||
2. `worker/package.json` — `version` field
|
||||
3. `worker/src/constants.ts` — `VERSION` constant (format: `VERSION: 'v' + '1.4.0'`)
|
||||
4. `pages/package.json` — `version` field
|
||||
5. `vitepress-docs/package.json` — `version` field
|
||||
6. `CHANGELOG.md` — add new version placeholder
|
||||
7. `CHANGELOG_EN.md` — add new version placeholder (English)
|
||||
|
||||
## Upgrade workflow
|
||||
|
||||
1. Read `frontend/package.json` to get the current version.
|
||||
2. Compute the new version based on the upgrade type:
|
||||
- major: 1.3.0 → 2.0.0
|
||||
- minor: 1.3.0 → 1.4.0
|
||||
- patch: 1.3.0 → 1.3.1
|
||||
3. Update the `version` field in every `package.json` listed above.
|
||||
4. Update the `VERSION` constant in `worker/src/constants.ts`.
|
||||
5. Insert a new version placeholder at the top of `CHANGELOG.md`.
|
||||
6. Insert a new version placeholder at the top of `CHANGELOG_EN.md`.
|
||||
7. Rename the previous version heading in both changelogs from `## v{OLD_VERSION}(main)` to `## v{OLD_VERSION}` so only the new active development version keeps `(main)`.
|
||||
|
||||
## CHANGELOG format
|
||||
|
||||
In `CHANGELOG.md`, insert before the existing `## v{OLD_VERSION}(main)` line (i.e. right after the closing `</p>` of the language-switch link):
|
||||
|
||||
```markdown
|
||||
## v{VERSION}(main)
|
||||
|
||||
### Features
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
```
|
||||
|
||||
After inserting the new placeholder, update the old heading:
|
||||
|
||||
```diff
|
||||
-## v{OLD_VERSION}(main)
|
||||
+## v{OLD_VERSION}
|
||||
```
|
||||
|
||||
`CHANGELOG_EN.md` uses the same format and must receive the same old-heading update.
|
||||
|
||||
## Commit message format
|
||||
|
||||
```
|
||||
feat: upgrade version to v{VERSION}
|
||||
|
||||
- Update version number to {VERSION} in all package.json files
|
||||
- Add v{VERSION} placeholder in CHANGELOG.md
|
||||
- Move (main) marker from v{OLD_VERSION} to v{VERSION}
|
||||
```
|
||||
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
**/node_modules/
|
||||
.git/
|
||||
vitepress-docs/
|
||||
smtp_proxy_server/
|
||||
mail-parser-wasm/
|
||||
db/
|
||||
**/.wrangler/
|
||||
**/dist/
|
||||
**/test-results/
|
||||
**/playwright-report/
|
||||
.DS_Store
|
||||
3
.flake8
Normal file
3
.flake8
Normal file
@@ -0,0 +1,3 @@
|
||||
[flake8]
|
||||
max-line-length = 180
|
||||
exclude = .git,__pycache__,build,dist
|
||||
57
.github/config/mail-parser-wasm-worker.patch
vendored
Normal file
57
.github/config/mail-parser-wasm-worker.patch
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
diff --git a/worker/src/common.ts b/worker/src/common.ts
|
||||
index 9b758f0..e2150b5 100644
|
||||
--- a/worker/src/common.ts
|
||||
+++ b/worker/src/common.ts
|
||||
@@ -469,29 +469,29 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
}
|
||||
const raw_mail = parsedEmailContext.rawEmail;
|
||||
// NOTE: WASM parse email
|
||||
- // try {
|
||||
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
-
|
||||
- // const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
- // parsedEmailContext.parsedEmail = {
|
||||
- // sender: parsedEmail.sender || "",
|
||||
- // subject: parsedEmail.subject || "",
|
||||
- // text: parsedEmail.text || "",
|
||||
- // headers: parsedEmail.headers?.map(
|
||||
- // (header) => ({ key: header.key, value: header.value })
|
||||
- // ) || [],
|
||||
- // html: parsedEmail.body_html || "",
|
||||
- // attachments: (parsedEmail.attachments || []).map(att => ({
|
||||
- // filename: att.filename || "attachment",
|
||||
- // mimeType: att.content_type || "application/octet-stream",
|
||||
- // content: att.content,
|
||||
- // disposition: "attachment",
|
||||
- // })),
|
||||
- // };
|
||||
- // return parsedEmailContext.parsedEmail;
|
||||
- // } catch (e) {
|
||||
- // console.error("Failed use mail-parser-wasm-worker to parse email", e);
|
||||
- // }
|
||||
+ try {
|
||||
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
+
|
||||
+ const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
+ parsedEmailContext.parsedEmail = {
|
||||
+ sender: parsedEmail.sender || "",
|
||||
+ subject: parsedEmail.subject || "",
|
||||
+ text: parsedEmail.text || "",
|
||||
+ headers: parsedEmail.headers?.map(
|
||||
+ (header) => ({ key: header.key, value: header.value })
|
||||
+ ) || [],
|
||||
+ html: parsedEmail.body_html || "",
|
||||
+ attachments: (parsedEmail.attachments || []).map(att => ({
|
||||
+ filename: att.filename || "attachment",
|
||||
+ mimeType: att.content_type || "application/octet-stream",
|
||||
+ content: att.content,
|
||||
+ disposition: "attachment",
|
||||
+ })),
|
||||
+ };
|
||||
+ return parsedEmailContext.parsedEmail;
|
||||
+ } catch (e) {
|
||||
+ console.error("Failed use mail-parser-wasm-worker to parse email", e);
|
||||
+ }
|
||||
try {
|
||||
const { default: PostalMime } = await import('postal-mime');
|
||||
const parsedEmail = await PostalMime.parse(raw_mail);
|
||||
59
.github/workflows/backend_deploy.yaml
vendored
59
.github/workflows/backend_deploy.yaml
vendored
@@ -1,6 +1,9 @@
|
||||
name: Deploy Backend Production
|
||||
name: Deploy Backend
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
@@ -13,32 +16,68 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Backend for ${{ github.ref_name }}
|
||||
run: |
|
||||
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
|
||||
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
|
||||
|
||||
if [ -n "$use_worker_assets" ]; then
|
||||
cd frontend/
|
||||
pnpm install --no-frozen-lockfile
|
||||
if [ -n "$use_worker_assets_with_telegram" ]; then
|
||||
echo "Building with telegram pages"
|
||||
pnpm build:telegram:pages
|
||||
else
|
||||
echo "Building with normal pages"
|
||||
pnpm build:pages
|
||||
fi
|
||||
cd ..
|
||||
fi
|
||||
|
||||
export debug_mode=${{ secrets.DEBUG_MODE }}
|
||||
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
|
||||
|
||||
cd worker/
|
||||
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
|
||||
# ✅ 修复核心:使用环境变量写入,避免 Shell 解析特殊字符
|
||||
printf '%s\n' "$WRANGLER_TOML_CONTENT" > wrangler.toml
|
||||
|
||||
pnpm install --no-frozen-lockfile
|
||||
output=$(pnpm run deploy 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
|
||||
if [ -n "$use_mail_wasm_parser" ]; then
|
||||
echo "Using mail-parser-wasm-worker"
|
||||
pnpm add mail-parser-wasm-worker
|
||||
git apply ../.github/config/mail-parser-wasm-worker.patch
|
||||
echo "Applied mail-parser-wasm-worker patch"
|
||||
fi
|
||||
|
||||
if [ "$debug_mode" = "true" ]; then
|
||||
pnpm run deploy
|
||||
else
|
||||
if pnpm run deploy >/dev/null 2>&1; then
|
||||
echo "Deploy succeeded"
|
||||
else
|
||||
code=$?
|
||||
echo "Command failed with exit code $code"
|
||||
exit $code
|
||||
exit "$code"
|
||||
fi
|
||||
fi
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
# ✅ 将 secret 映射到环境变量中
|
||||
WRANGLER_TOML_CONTENT: ${{ secrets.BACKEND_TOML }}
|
||||
|
||||
68
.github/workflows/docs_deploy.yml
vendored
68
.github/workflows/docs_deploy.yml
vendored
@@ -1,64 +1,56 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "vitepress-docs/**"
|
||||
tags:
|
||||
- "*"
|
||||
workflow_run:
|
||||
workflows: ["Tag Build CI"]
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v'))
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: check github release done
|
||||
run: |
|
||||
for ((attempt=1; attempt<=10; attempt++)); do
|
||||
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then
|
||||
echo "frontend.zip found."
|
||||
break
|
||||
else
|
||||
if [ $attempt -eq 10 ]; then
|
||||
echo "Exceeded maximum retries. frontend.zip not found."
|
||||
else
|
||||
echo "frontend.zip not found. Retrying in 30 seconds..."
|
||||
sleep 30
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Deploy Docs for ${{github.ref_name}}
|
||||
run: |
|
||||
cd vitepress-docs/
|
||||
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
||||
pnpm install --no-frozen-lockfile
|
||||
if [[ ${{github.ref}} == refs/tags/* ]]; then
|
||||
export TAG_NAME=${{github.ref_name}}
|
||||
else
|
||||
export TAG_NAME=$(git describe --tags --abbrev=0)
|
||||
fi
|
||||
echo "Deploying docs for tag $TAG_NAME"
|
||||
pnpm run deploy
|
||||
- name: Deploy Docs
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
cd vitepress-docs/
|
||||
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
||||
pnpm install --no-frozen-lockfile
|
||||
if TAG_NAME=$(gh release view --json tagName --jq '.tagName' 2>/dev/null); then
|
||||
echo "Using release tag $TAG_NAME"
|
||||
else
|
||||
TAG_NAME="${WORKFLOW_RUN_HEAD_BRANCH:-${GITHUB_REF_NAME:-main}}"
|
||||
echo "No GitHub release found for this repo; fallback TAG_NAME=$TAG_NAME"
|
||||
fi
|
||||
echo "Deploying docs for tag $TAG_NAME"
|
||||
export TAG_NAME
|
||||
pnpm run deploy
|
||||
|
||||
34
.github/workflows/e2e.yml
vendored
Normal file
34
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: End-to-End Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
cd e2e
|
||||
docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
cd e2e
|
||||
docker compose down -v
|
||||
77
.github/workflows/frontend_deploy.yaml
vendored
77
.github/workflows/frontend_deploy.yaml
vendored
@@ -1,50 +1,85 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
deploy-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Frontend for ${{ github.ref_name }}
|
||||
if: ${{ env.FRONTEND_NAME != '' }}
|
||||
run: |
|
||||
cd frontend/
|
||||
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
|
||||
export project_name=${{ secrets.FRONTEND_NAME }}
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm run deploy --project-name=$project_name
|
||||
echo "Deploying prodcution for ${{ github.ref_name }}"
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
|
||||
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
|
||||
if [ -n "$tg_mini_app_project_name" ]; then
|
||||
echo "Deploying telegram mini app $tg_mini_app_project_name"
|
||||
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying branch $frontend_branch"
|
||||
pnpm run deploy:actions --project-name=$FRONTEND_NAME --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying branch production"
|
||||
pnpm run deploy --project-name=$FRONTEND_NAME
|
||||
fi
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FRONTEND_NAME: ${{ secrets.FRONTEND_NAME }}
|
||||
|
||||
deploy-telegram-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Telegram Frontend for ${{ github.ref_name }}
|
||||
if: ${{ env.TG_FRONTEND_NAME != '' }}
|
||||
run: |
|
||||
cd frontend/
|
||||
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
|
||||
pnpm install --no-frozen-lockfile
|
||||
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying telegram mini app branch $frontend_branch"
|
||||
pnpm run deploy:actions:telegram --project-name=$TG_FRONTEND_NAME --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying telegram mini app branch production"
|
||||
pnpm run deploy:telegram --project-name=$TG_FRONTEND_NAME
|
||||
fi
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
TG_FRONTEND_NAME: ${{ secrets.TG_FRONTEND_NAME }}
|
||||
|
||||
60
.github/workflows/frontend_pagefunction_deploy.yaml
vendored
Normal file
60
.github/workflows/frontend_pagefunction_deploy.yaml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Deploy Frontend with page function
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_config: ${{ steps.check.outputs.has_config }}
|
||||
steps:
|
||||
- name: Check PAGE_TOML
|
||||
id: check
|
||||
run: |
|
||||
if [ -n "$PAGE_TOML" ]; then
|
||||
echo "has_config=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_config=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
PAGE_TOML: ${{ secrets.PAGE_TOML }}
|
||||
|
||||
deploy:
|
||||
needs: check
|
||||
if: ${{ needs.check.outputs.has_config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Frontend for ${{ github.ref_name }}
|
||||
run: |
|
||||
cd frontend/
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm build:pages
|
||||
cd ../pages/
|
||||
echo '${{ secrets.PAGE_TOML }}' > wrangler.toml
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm run deploy
|
||||
echo "Deploying production for ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
27
.github/workflows/pr_agent.yml
vendored
Normal file
27
.github/workflows/pr_agent.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Codium PR Agent
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review]
|
||||
issue_comment:
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
if: ${{ github.event.sender.type != 'Bot' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: qodo-ai/pr-agent@main
|
||||
env:
|
||||
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
CONFIG.MODEL: "gpt-5.4-nano"
|
||||
CONFIG.MODEL_TURBO: "gpt-5.4-nano"
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
17
.github/workflows/smtp_proxy_server.yml
vendored
17
.github/workflows/smtp_proxy_server.yml
vendored
@@ -21,28 +21,31 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set lowercase repository name
|
||||
run: echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./smtp_proxy_server
|
||||
file: ./smtp_proxy_server/dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
18
.github/workflows/sync.yaml
vendored
Normal file
18
.github/workflows/sync.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Upstream Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: Sync latest commits from upstream repo
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- name: Sync upstream changes
|
||||
run: gh repo sync ${{ github.repository }} --source dreamhunter2333/cloudflare_temp_email --branch main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
122
.github/workflows/tag_build.yml
vendored
122
.github/workflows/tag_build.yml
vendored
@@ -6,31 +6,80 @@ on:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Build Frontend
|
||||
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:release
|
||||
|
||||
- name: Zip Frontend dist
|
||||
run: cd frontend/dist/ && zip -r frontend.zip *
|
||||
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend
|
||||
path: frontend/frontend.zip
|
||||
|
||||
build-telegram-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Build Telegram Frontend
|
||||
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:telegram:release
|
||||
|
||||
- name: Zip Telegram Frontend dist
|
||||
run: cd frontend/dist/ && zip -r telegram-frontend.zip * && mv telegram-frontend.zip ../
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: telegram-frontend
|
||||
path: frontend/telegram-frontend.zip
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: cp wrangler.toml
|
||||
run: cd worker && cp wrangler.toml.template wrangler.toml
|
||||
@@ -38,9 +87,52 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: cd worker && pnpm install --no-frozen-lockfile && pnpm build
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Move worker.js
|
||||
run: cd worker/dist && mv worker.js ../
|
||||
|
||||
- name: Build Worker with wasm mail parser
|
||||
run: |
|
||||
cd worker
|
||||
echo "Using mail-parser-wasm-worker"
|
||||
pnpm add mail-parser-wasm-worker
|
||||
git apply ../.github/config/mail-parser-wasm-worker.patch
|
||||
echo "Applied mail-parser-wasm-worker patch"
|
||||
pnpm build
|
||||
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
|
||||
|
||||
- name: Upload worker.js
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
files: |
|
||||
frontend/dist/frontend.zip
|
||||
worker/dist/worker.js
|
||||
name: worker-js
|
||||
path: worker/worker.js
|
||||
|
||||
- name: Upload wasm worker
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: worker-wasm
|
||||
path: worker/worker-with-wasm-mail-parser.zip
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-frontend, build-telegram-frontend, build-backend]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Upload Assets to Release
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" \
|
||||
artifacts/frontend/frontend.zip \
|
||||
artifacts/telegram-frontend/telegram-frontend.zip \
|
||||
artifacts/worker-js/worker.js \
|
||||
artifacts/worker-wasm/worker-with-wasm-mail-parser.zip \
|
||||
--clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
144
.gitignore
vendored
144
.gitignore
vendored
@@ -1,3 +1,147 @@
|
||||
.DS_Store
|
||||
dist/
|
||||
test/
|
||||
.vscode/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.wrangler
|
||||
wrangler.toml
|
||||
.dev.vars
|
||||
pnpm-lock.yaml
|
||||
|
||||
# E2E test artifacts
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
e2e/.e2e-pids
|
||||
|
||||
# SMTP/IMAP proxy persisted IMAP flags (local dev / bare-metal runs)
|
||||
smtp_proxy_server/data/
|
||||
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.vscode-pylance",
|
||||
"1yib.rust-bundle",
|
||||
"rust-lang.rust-analyzer",
|
||||
"vue.volar"
|
||||
]
|
||||
}
|
||||
533
CHANGELOG.md
533
CHANGELOG.md
@@ -1,6 +1,535 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.12.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
||||
- fix: |用户发信| 用户地址发信接口支持角色无限额度
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||
|
||||
## v1.11.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend| 在随机子域名允许范围内新增普通、随机和自定义子域名模式选择(issue #1108)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| 将邮箱主页的账户设置、删除账户等表述更正为邮箱地址操作,并明确发信权限与额度按当前邮箱地址独立管理
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |发送邮件| 补充用户账号、邮箱地址和发信权限的概念区别及按当前地址申请权限的操作说明
|
||||
|
||||
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103)
|
||||
|
||||
- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属,避免为大用户加载全部绑定地址(issue #1103)
|
||||
|
||||
- feat: |Worker| 邮件、发件箱及按创建/活跃时间清理地址时改为分批处理,默认每次最多 3000 条并支持通过 `CLEANUP_BATCH_SIZE` 调整(上限 5000),减少单次扫描和删除量(issue #1103)
|
||||
|
||||
### Testing
|
||||
|
||||
- fix: |E2E| 新增近期地址活跃时间不会被用户设置接口重复写入的回归测试
|
||||
- fix: |E2E| 新增清理批次上限、后续批次继续执行、保留未过期数据及地址关联数据清理测试
|
||||
|
||||
## v1.10.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 新增 `GET /admin/mails/:id` 接口,支持管理员按邮件 ID 跨邮箱读取单封邮件,并兼容 gzip 压缩存储(issue #1096)
|
||||
- feat: |Frontend| 邮箱新增「邮箱全宽列表视图」开关(在外观设置中控制),开启后默认全宽列表展示邮件标题与正文预览,点击单封邮件再展开为双栏,再次点击同一封邮件可回到列表视图;多选模式下点击邮件会同步切换勾选状态与右侧预览,并禁用同邮件点击收回列表,展开时双栏左侧列表宽度仍遵循「邮箱双栏视图左侧列表宽度占比」配置;默认关闭,保留原有双栏行为
|
||||
- feat: |Frontend| 邮箱全宽列表视图新增「正文预览行数」配置(在外观设置中控制),可设置邮件正文预览的最大行数,默认 2 行,0 表示关闭预览
|
||||
- feat: |Frontend| 外观设置新增「自动加载邮件正文中的外部图片」开关,关闭后邮件预览(含全屏视图)会先经 DOMPurify 消毒,并以白名单策略处理所有可能发起请求的位置:仅保留可证明为本地的引用(`cid:`、`data:image/`、`blob:` 与站内相对路径),其余一律阻断;`base`、`meta`、`script`、`link`、`iframe`、`object`、`embed`、`noscript` 等会自行取用资源或改变解析基准的元素在此模式下移除,`<style>` 保留但其中 `url()`、`image-set()`、`@import` 的远端引用会被替换。正文上方显示已阻断资源数量的提示条,可一键按封加载;默认保持开启,行为与此前一致(issue #1073)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| 关闭邮件外部图片自动加载时保留 `<a>` 与 `<area>` 的外部导航链接,并阻断通过 CSS 转义函数名或 at-rule 绕过远程资源过滤的情况
|
||||
- fix: |Frontend| 使用共享 DOMPurify 净化逻辑处理关于页面与启动通知中的 HTML 公告,避免 `ANNOUNCEMENT` 中的可执行标签或事件属性造成 XSS
|
||||
- fix: |Worker| 按邮件认证规范修复垃圾邮件检测:SPF、DKIM、DMARC 的 `none` 及 SPF/DKIM `neutral` 按认证方法不存在处理,并忽略未注册结果和不支持的方法版本;`JUNK_MAIL_FORCE_PASS_LIST` 仍要求明确返回受支持的 `pass`
|
||||
- fix: |Admin| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
|
||||
- fix: |AI 提取| 强化提示词,要求 AI 保持邮件原始链接域名,避免小模型改写验证链接域名导致错误跳转(issue #1072)
|
||||
- fix: |AI 提取| HTML-only 邮件在发送给 Workers AI 前会先压缩为可读文本,避免样式模板过长导致验证码位于 4000 字截断之后而无法识别
|
||||
- fix: |Frontend| 移动端 Header 增加页头内边距,避免标题、菜单按钮与屏幕边缘过近
|
||||
- fix: |IMAP 代理| 修复 IMAP `STORE` 无法真正标记邮件已读的问题:邮件不再硬编码为 `\Seen`,且 `SimpleMailbox` 的 flags 变更现持久化到本地 SQLite(新增 `imap_flag_db_path` 配置),使已读/未读状态可在客户端断线重连(如 Thunderbird 轮询)后保留,而非每次新建连接即丢失(issue #1074)
|
||||
- fix: |IMAP 代理| 修复 `SEARCH UNSEEN` 返回全部邮件的问题:`SimpleMailbox.search()` 现按持久化的 flags 计算 `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` 及其否定形式,多个条件按 AND 组合;无法识别的检索条件仍沿用原有行为返回全部邮件
|
||||
- fix: |IMAP 代理| 修复取信不会自动标记已读的问题:`BODY[...]`、`RFC822`、`RFC822.TEXT` 取信现按 RFC 3501 自动置 `\Seen`,而 `BODY.PEEK[...]`、`RFC822.HEADER` 及仅取元数据(如 `FLAGS`)不会
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |Worker| 新增 junk_mail_policy 回归测试(issue #1084):覆盖 SPF/DKIM/DMARC 的 `none`/`neutral` 按认证方法不存在处理、明确 `fail` 仍被拒收,以及 `JUNK_MAIL_FORCE_PASS_LIST` 仅接受明确 `pass`
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |README| 新增完整日文 README,并在中文和英文 README 中添加日文导航链接
|
||||
- feat: |Frontend| 「邮箱双栏视图左侧列表宽度占比」最小值由 0.25 放宽至 0,左侧列表可完全折叠使正文近乎全屏,刻度增加 0 点;收件箱与发件箱的双栏拆分同步生效,并优化外观设置文案以明确该比例控制左侧邮件列表宽度
|
||||
|
||||
## v1.9.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |AI 识别| 未配置 Workers AI 绑定时,自动回退到内置正则提取验证码(支持中英日韩,并排除年份与 `YYYYMMDD` 日期误判),让无 Workers AI 的自部署用户也能在 Telegram 推送与 Webhook 中拿到验证码
|
||||
- feat: |Telegram| Telegram 新邮件推送与 `/mails` 历史邮件查看支持展示 AI 提取结果,包含验证码、验证链接、服务链接、订阅链接等关键信息
|
||||
- feat: |Webhook| 邮件 Webhook 模板支持填充 AI 提取结果占位符,包括 `aiExtractType`、`aiExtractResult`、`aiExtractResultText`
|
||||
- feat: |Frontend| 新增 `DISABLE_SHOW_GITHUB_FOR_USER` 配置,可仅对普通用户隐藏 Header 的 GitHub/版本入口,admin 仍可见(issue #1041)
|
||||
- feat: |Frontend| 将邮箱地址凭证弹窗升级为“地址凭证与连接方式”,复用普通用户与 admin 创建邮箱结果弹窗;支持通过 `ENABLE_AGENT_EMAIL_INFO` 展示 AI Agent 接入信息,并通过 `SMTP_IMAP_PROXY_CONFIG` 展示 SMTP/IMAP 客户端连接信息
|
||||
- docs: |随机子域名| 在前端“启用随机子域名”提示与 `subdomain` / `worker-vars` 文档(中英)中明确说明:要让 `name@<随机>.abc.com` 真正收到邮件,必须在基础域名 DNS 中为 `*` 子域添加通配 MX 记录,Email Routing 子域不继承父域配置(issue #1035)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| 管理员重置邮箱地址密码时改为前端 SHA-256 后提交,后端只接受并存储哈希值,避免该接口继续接收明文密码
|
||||
- fix: |Address| 管理员邮箱地址列表与用户绑定地址列表不再返回已存储的地址密码哈希值,避免列表接口暴露敏感字段
|
||||
- fix: |Address| 统一规范化配置域名、收件地址域名与前缀的空白和大小写,覆盖 `DOMAINS`、`DEFAULT_DOMAINS`、`USER_ROLES.domains`、随机子域名、转发规则、SMTP 与 `SEND_MAIL` 域名匹配,保留转发规则空域名 catch-all 行为,并明确空 `DEFAULT_DOMAINS` / 角色域名回退到 `DOMAINS` 的行为,避免大小写配置或入站收件域名导致创建、收件、转发或发信失败(issue #926)
|
||||
- fix: |AI 提取| 将 AI 邮件识别默认 Workers AI 模型切换为支持 JSON Mode 且未弃用的 `@cf/meta/llama-3.1-8b-instruct-fast`,并在文档中补充 `@cf/zai-org/glm-4.7-flash` 结构化输出兼容性提示(issue #1029)
|
||||
- fix: |CI| 将 GitHub Actions 与 e2e Docker 镜像统一升级到 Node.js 24,适配 Wrangler 4.90.0 的运行时要求
|
||||
- fix: |Frontend| 修复 iOS Safari 点击输入框时因移动端表单控件字号过小导致页面自动放大的问题
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.8.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend| 前端新增 6 国语言支持(`zh` / `en` / `es` / `pt-BR` / `ja` / `de`),默认语言保持为 `zh`;无 locale 前缀路由(如 `/`、`/user`)默认使用中文渲染,同时会记录浏览器语言作为语言偏好。用户手动切换后会持久化语言偏好,并保持当前页面路径、查询参数与 canonical locale URL 一致
|
||||
- feat: |API| 新增服务端解析邮件接口 `/api/parsed_mails` 与 `/api/parsed_mail/:id`,直接返回 `sender` / `subject` / `text` / `html` / `attachments` 元信息(复用 `commonParseMail`),AI agent 侧不再需要引入 MIME 解析器
|
||||
- feat: |Skill| 新增仓库内置只读 skill `cf-temp-mail-agent-mail`(`skills/cf-temp-mail-agent-mail/`),让 OpenClaw / Codex / Cursor 等 AI agent 凭用户提供的 Address JWT + API 地址读取邮箱、轮询验证码,绕开创建邮箱时的 Turnstile 人机验证;可通过 `npx degit dreamhunter2333/cloudflare_temp_email/skills/cf-temp-mail-agent-mail` 安装
|
||||
- docs: |文档| 新增"AI Agent 使用邮箱"文档(`guide/feature/agent-email`),说明 `parsed_mail` API 用法,并在 parsed API 不可用时给出对齐前端的 `mail-parser-wasm` + `postal-mime` 本地解析回退方案
|
||||
- docs: |文档| 在 `quick-start` / `worker-vars` / `email-routing` 三个入口文档(中英文)显式补充"域名是部署前提条件"提示,强调需先在 Cloudflare 启用 Email Routing 并下发邮件 DNS 记录、Worker 部署后再绑定 Catch-all,子域名需单独启用,避免用户在没有可用域名时直接开始部署却收不到邮件(issue #1004)
|
||||
- docs: |部署排障| 优化近期 issue 暴露的 UI 部署与升级排障文档:补充 `nodejs_compat`、D1 绑定名必须为 `DB`、`/open_api/settings` 校验、后端 API 地址填写、Cloudflare 安全挑战导致 `Network Error`、D1 容量上限与 Cron Trigger 自动清理、GitHub OAuth 公开邮箱、admin 管理口令与用户账号区别、随机二级域名 API 需传 `enableRandomSubdomain` 等说明;同时将帮助/FAQ 菜单移动到核心配置之后,提升可见性
|
||||
- docs: |文档| 补充重新创建旧邮箱提示地址已存在时的处理方式,并完善 GitHub Actions 自动更新配合 Page Functions 转发后端请求的 workflow 说明(issues #947 #654)
|
||||
- docs: |OAuth2| 补充 GitHub 私密邮箱登录配置,说明可使用 `https://api.github.com/user/emails`、JSONPath 邮箱字段和 `user:email` scope 获取主邮箱(issue #655)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| 收窄地址管理相关弹窗宽度,并让地址表格在弹窗内部横向滚动,避免多地址场景撑宽弹窗
|
||||
- fix: |Frontend| 修复 `/open_api/settings` 未返回 `domains` 数组时前端设置初始化直接调用 `map()` 报 `undefined` 错误的问题,统一按空数组兜底处理
|
||||
- fix: |Frontend| 修复前端在 `jwt` / `auth` / `adminAuth` 等 localStorage 凭据为空字符串、字面量 `"undefined"` 或包含换行/控制符时,请求构造的 `Authorization` 等头部抛出 `Invalid character in header content` 导致前端所有接口报错的问题(issue #1000)。新增 `safeHeaderValue` / `safeBearerHeader` 工具,对全部认证头做 RFC 7230 校验,不安全的值直接跳过该头部,让 worker 走标准 401 而不是请求级崩溃
|
||||
- fix: |Frontend| 修复多语言菜单在移动端顶部显示语言与版本按钮导致 Header 横向拥挤或溢出的问题,移动端仅保留菜单按钮并将语言/版本入口放入抽屉
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: |Worker| 拆分 `mails_api/index.ts` 与 `admin_api/index.ts`,入口只负责挂路由,业务拆到各自的 `*_api.ts` 文件(`mails_crud.ts` / `new_address.ts` / `parsed_mail_api.ts` / `address_api.ts` / `address_sender_api.ts` / `sendbox_api.ts` / `statistics_api.ts` / `account_settings_api.ts`),保持路径与行为不变
|
||||
|
||||
## v1.7.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- breaking: |发信| `SEND_MAIL` 的语义已从“仅用于 `verifiedAddressList` 命中的兼容发信路径”调整为“常规兜底发信通道”。如果实例已绑定 `SEND_MAIL` 且未配置 Resend/SMTP,升级后未命中 `verifiedAddressList` 的收件人也会直接通过 Cloudflare binding 发出,发信行为与成本路径会发生变化
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |发信| 推荐使用 Cloudflare `send_email` binding 作为默认发信通道,已 onboard Email Routing 的域名未配置 Resend/SMTP 时自动走 binding 发至任意地址(Workers Paid 每月含 3000 封,超出 $0.35/1000 封);历史 `verifiedAddressList` / Resend / SMTP 配置完全兼容(#964)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |发送邮件| 当 `DEFAULT_SEND_BALANCE > 0` 时,首次访问发信设置或调用发信接口会为缺少 `address_sender` 记录的地址自动初始化默认额度(`ON CONFLICT DO NOTHING`),用户不再需要先手动申请发信权限;已存在的记录(包括管理员禁用或手动设置的行)一律保持原样,runtime 不会覆盖(#925 #985)
|
||||
- fix: |用户侧收件箱| 修复 `ENABLE_USER_DELETE_EMAIL` 关闭时用户中心仍显示删除按钮且仍可通过 `/user_api/mails/:id` 删除邮件的问题(#978)
|
||||
- fix: |Address| 创建邮箱时统一将配置的前缀转为小写,避免生成包含大写前缀的地址;历史数据需用户自行迁移为小写(#930)
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.6.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| IP 黑名单设置新增 **IP 白名单(严格模式)**:启用后仅允许匹配白名单的 IP 访问受限流保护的 API(创建邮箱、发送邮件、外部发送邮件、用户注册、验证码校验),其他所有 IP 一律拒绝(#920)
|
||||
- feat: |Address| 支持最大地址数量设置为 `0` 表示无限制(#968)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| 修复 `/admin/address` 与 `/admin/users` 在使用完整邮箱(query 长度超过 50 字节)作为搜索条件时报错 `D1_ERROR: LIKE or GLOB pattern too complex` 的问题,长查询自动改用 `instr()` 绕开 D1 的 LIKE pattern 长度限制(#956)
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |发送邮件 API| 明确 `/api/send_mail` 与 `/external/api/send_mail` 两个端点的认证方式差异,补充"地址 JWT"概念说明(#922)
|
||||
- docs: |Worker 变量| `JWT_SECRET` 补充生成方式说明(`openssl rand -hex 32`)(#932)
|
||||
- docs: |CLI 部署| `routes` 自定义域名配置增加用途说明(#932)
|
||||
- docs: |Admin API| `/admin/new_address` 返回值文档补充 `address_id` 字段(#912)
|
||||
- docs: |Admin| 补充管理后台账号列表排序功能说明(#918)
|
||||
- docs: |Pages 部署| 补充 SPA 模式说明,避免刷新页面或直接访问子路径时 404(#813)
|
||||
- docs: |侧边栏| 重组文档侧边栏结构,拆分为"核心配置"、"通知与集成"、"高级功能"、"管理后台"等分组
|
||||
- docs: |FAQ| 大幅扩充常见问题,新增 SPA 404、发信余额、SMTP_CONFIG 配置、邮件客户端登录等高频问题(#919, #925, #839, #715, #921, #609)
|
||||
- docs: |发送邮件| 增强 SMTP_CONFIG 字段说明和多域名示例,新增发信余额机制说明
|
||||
- docs: |Email Routing| 补充子域名需单独启用 Email Routing 的说明,避免仅在一级域名开启导致子域收不到邮件(#969)
|
||||
|
||||
## v1.5.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 管理后台账号列表支持按列排序(ID、名称、创建时间、更新时间、邮件数量、发送数量),搜索时自动重置分页到第1页(#918)
|
||||
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID(#912)
|
||||
- feat: |创建邮箱| 新增 `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 开关,并支持在管理后台单独控制创建邮箱 API 的子域名后缀匹配;开启后允许 `foo.example.com` 匹配基础域名 `example.com`
|
||||
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
||||
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
||||
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB),多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894)
|
||||
- feat: |邮件存储| 支持通过 `ENABLE_MAIL_GZIP` 变量启用 Gzip 压缩邮件存储(#823)
|
||||
- 启用前需先执行数据库迁移:`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema`,或调用接口 `POST /admin/db_migration`
|
||||
- 新邮件写入 `raw_blob`,兼容读取 `raw` / `raw_blob`;压缩与解压会增加 CPU 开销,建议付费 Worker Plan 再开启
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
|
||||
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900)
|
||||
- fix: |IMAP| 修复嵌套回复邮件乱码、Gmail 空 Content-Type 头解析失败、缺失 Date 头及 locale 依赖日期格式等问题
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 新增创建邮箱子域名匹配测试,覆盖默认精确匹配、后台开启后生效,以及 env=false 的硬禁用优先级
|
||||
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
|
||||
|
||||
### Docs
|
||||
|
||||
- docs: |创建邮箱| 补充创建邮箱 API / Worker 变量 / 子域名文档,说明“直接指定子域名”和“随机子域名”两种能力的区别
|
||||
- docs: |API| 新增地址 JWT 与用户 JWT 的区分说明,避免混淆两种认证方式;调整文档菜单结构,将 API 接口文档归类到独立分组(#910)
|
||||
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769)
|
||||
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
|
||||
- feat: |Webhook| 前端预设模板新增 Telegram Bot、企业微信、Discord 三个模板
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.4.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |用户注册| 新增用户注册邮箱正则校验功能,管理员可配置邮箱格式验证规则
|
||||
- feat: |前端| 新增可配置的 Status 菜单按钮,通过 `STATUS_URL` 环境变量配置状态监控页面链接
|
||||
- feat: |SMTP| SMTP 代理服务支持 STARTTLS,通过 `smtp_tls_cert` 和 `smtp_tls_key` 环境变量配置
|
||||
- feat: |Webhook| Webhook 设置页面新增预设模板下拉菜单,支持 Message Pusher、Bark、ntfy 一键填充配置
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Telegram| 修复 admin 用户通过 Telegram MiniApp 查看邮件时报 `Auth date expired` 的问题,支持 admin 密码认证查看邮件
|
||||
- fix: |Admin API| 修复 `/admin/account_settings` 在未配置 KV 且 `fromBlockList` 为空时触发 `Cannot read properties of undefined (reading 'put')` 的问题
|
||||
- fix: |数据库| 修复 `DB_INIT_QUERIES` 缺少 `idx_raw_mails_message_id` 索引导致 `UPDATE raw_mails ... WHERE message_id = ?` 全表扫描的问题,同步 `schema.sql` 与初始化代码,新增 v0.0.6 迁移逻辑
|
||||
- fix: |文档| 修复 User Mail API 文档中错误使用 `x-admin-auth` 的问题,改为正确的 `x-user-token`
|
||||
- fix: |前端| 修复暗色主题下邮件内容文字看不清的问题,优化纯文本邮件和 Shadow DOM 渲染的暗色模式样式
|
||||
- docs: |文档| 新增 Admin 删除邮件、删除邮箱地址、清空收件箱、清空发件箱 API 文档
|
||||
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
|
||||
- fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 HTML 内容进行白名单消毒,对纯文本内容进行 HTML 转义
|
||||
- fix: |API| 修复 `requset_send_mail_access` API 路径拼写错误,改为 `request_send_mail_access`
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
|
||||
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
|
||||
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
|
||||
|
||||
### Improvements
|
||||
|
||||
- style: |邮件列表| 优化收件箱和发件箱空状态显示,根据邮件数量显示不同提示信息,添加语义化图标
|
||||
- feat: |后台管理| 邮箱地址列表来源IP添加 ip.im 查询链接,点击可快速查看IP信息
|
||||
- docs: |文档| 修复 VitePress 中英文切换路径错误,改用双前缀 locale 配置
|
||||
- feat: |IMAP 代理| 重构 IMAP 服务端,拆分为独立模块(HTTP 客户端、邮箱、消息),使用 `deferToThread` 异步 HTTP 避免阻塞 Twisted reactor,使用后端 `id` 作为稳定 UID,新增 STARTTLS 支持、LRU 消息缓存、session 级 flags 管理、SEARCH 命令支持、JWT 凭证和地址+密码双登录方式,新增完整测试套件
|
||||
- fix: |IMAP 代理| 修复 `getHeaders()` 过滤逻辑、`store()` 崩溃问题
|
||||
- fix: |邮件解析| 修复 `parse_email.py` 中使用私有属性 `_payload` 导致编码错误的问题,改用 `get_payload(decode=True)` 正确解码邮件体
|
||||
|
||||
## v1.3.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |OAuth2| 新增 OAuth2 邮箱格式转换功能,支持通过正则表达式转换第三方登录返回的邮箱格式(如将 `user@domain` 转换为 `user@custom.domain`)
|
||||
- feat: |OAuth2| 新增 OAuth2 提供商 SVG 图标支持,管理员可为登录按钮配置自定义图标,预置 GitHub、Linux Do、Authentik 模板图标
|
||||
- feat: |发送邮件| 未配置发送邮件功能时自动隐藏发送邮件 tab、发件箱 tab 和回复按钮
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |用户地址| 修复禁止匿名创建时,已登录用户地址数量限制检查失效的问题,新增公共函数 `isAddressCountLimitReached` 统一处理地址数量限制逻辑
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: |代码重构| 提取地址数量限制检查为公共函数,优化代码复用性
|
||||
- perf: |性能优化| GET 请求中的地址活动时间更新改为异步执行,使用 `waitUntil` 不阻塞响应
|
||||
|
||||
## v1.2.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |定时任务| 修复定时任务清理报错 `e.get is not a function`,使用可选链安全访问 Context 方法
|
||||
|
||||
### Improvements
|
||||
|
||||
- style: |AI 提取| 暗色模式下 AI 提取信息使用更柔和的蓝色 (#A8C7FA),减少视觉疲劳
|
||||
|
||||
## v1.2.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- |数据库| 新增 `source_meta` 字段,需执行 `db/2025-12-27-source-meta.sql` 更新数据库或到 admin 维护页面点击数据库更新按钮
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 新增管理员账号页面,显示当前登录方式并支持退出登录(仅限密码登录方式)
|
||||
- fix: |GitHub Actions| 修复容器镜像名需要全部小写的问题
|
||||
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
|
||||
- feat: |地址来源| 新增地址来源追踪功能,记录地址创建来源(Web 记录 IP,Telegram 记录用户 ID,Admin 后台标记)
|
||||
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
|
||||
- feat: |前端| 地址切换统一为下拉组件,极简模式支持切换,主页提供地址管理入口
|
||||
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
|
||||
- feat: |Admin| 维护页面增加自定义 SQL 清理功能,支持定时任务执行自定义清理语句
|
||||
- feat: |国际化| 后端 API 错误消息全面支持中英文国际化
|
||||
- feat: |Telegram| 机器人支持中英文切换,新增 `/lang` 命令设置语言偏好
|
||||
|
||||
## v1.1.0
|
||||
|
||||
- feat: |AI 提取| 增加 AI 邮件识别功能,使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
|
||||
- 支持优先级提取:验证码 > 认证链接 > 服务链接 > 订阅链接 > 其他链接
|
||||
- 管理员可配置地址白名单(支持通配符,如 `*@example.com`)
|
||||
- 前端列表和详情页展示提取结果
|
||||
- 需要配置 `ENABLE_AI_EMAIL_EXTRACT` 环境变量和 AI 绑定
|
||||
- 需要执行 `db/2025-12-06-metadata.sql` 文件中的 SQL 更新 `D1` 数据库 或者到 admin维护页面点击数据库更新按钮
|
||||
- feat: |Admin| 维护页面增加清理 n 天前空邮件的邮箱地址功能
|
||||
- fix: 修复自定义认证密码功能异常的问题 (前端属性名错误 & /open_api 接口被拦截)
|
||||
|
||||
## v1.0.7
|
||||
|
||||
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
|
||||
- feat: |Admin| 新增 ASN 组织黑名单功能,支持基于 ASN 组织名称过滤请求(支持文本匹配和正则表达式)
|
||||
- feat: |Admin| 新增浏览器指纹黑名单功能,支持基于浏览器指纹过滤请求(支持精确匹配和正则表达式)
|
||||
|
||||
## v1.0.6
|
||||
|
||||
- feat: |DB| update db schema add index
|
||||
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
|
||||
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
|
||||
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
|
||||
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
|
||||
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
|
||||
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
|
||||
- feat: |UI| 主页增加进入极简模式按钮
|
||||
- feat: |Webhook| 增加白名单开关功能,支持灵活控制访问权限
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- feat: |UI| 优化极简模式主页, 增加全部邮件页面功能(删除/下载/附件/...), 可在 `外观` 中切换
|
||||
- feat: admin 账号设置页面增加 `邮件转发规则` 配置
|
||||
- feat: admin 账号设置页面增加 `禁止接收未知地址邮件` 配置
|
||||
- feat: 邮件页面增加 上一封/下一封 按钮
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- fix: 修复 github actions 部署问题
|
||||
- feat: telegram /new 不指定域名时, 使用随机地址
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- fix: 修复 oauth2 登录失败的问题
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- feat: |UI| 增加极简模式主页, 可在 `外观` 中切换
|
||||
- fix: 修复 oauth2 登录时,default role 不生效的问题
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- fix: |UI| 修复 User 查看收件箱,不选择地址时,关键词查询不生效
|
||||
- fix: 修复自动清理任务,时间为 0 时不生效的问题
|
||||
- feat: 清理功能增加 创建 n 天前地址清理,n 天前未活跃地址清理
|
||||
- fix: |IMAP Proxy| 修复 IMAP Proxy 服务器,无法查看新邮件的问题
|
||||
|
||||
## v0.10.0
|
||||
|
||||
- feat: 支持 User 查看收件箱,`/user_api/mails` 接口, 支持 `address` 和 `keyword` 过滤
|
||||
- fix: 修复 Oauth2 登录获取 Token 时,一些 Oauth2 需要 `redirect_uri` 参数的问题
|
||||
- feat: 用户访问网页时,如果 `user token` 在 7 天内过期,自动刷新
|
||||
- feat: admin portal 中增加初始化 db 的功能
|
||||
- feat: 增加 `ALWAYS_SHOW_ANNOUNCEMENT` 变量,用于配置是否总是显示公告
|
||||
|
||||
## v0.9.1
|
||||
|
||||
- feat: |UI| support google ads
|
||||
- feat: |UI| 使用 shadow DOM 防止样式污染
|
||||
- feat: |UI| 支持 URL jwt 参数自动登录邮箱,jwt 参数会覆盖浏览器中的 jwt
|
||||
- fix: |CleanUP| 修复清理邮件时,清理时间超过 30 天报错的 bug
|
||||
- feat: admin 用户管理页面: 增加 用户地址查看功能
|
||||
- feat: | S3 附件| 增加 S3 附件删除功能
|
||||
- feat: | Admin API| 增加 admin 绑定用户和地址的 api
|
||||
- feat: | Oauth2 | Oatuh2 获取用户信息时,支持 `JSONPATH` 表达式
|
||||
|
||||
## v0.9.0
|
||||
|
||||
- feat: | Worker | 支持多语言
|
||||
- feat: | Worker | `NO_LIMIT_SEND_ROLE` 配置支持多角色, 逗号分割
|
||||
- feat: | Actions | build 里增加 `worker-with-wasm-mail-parser.zip` 支持 UI 部署带 `wasm` 的 worker
|
||||
|
||||
## v0.8.7
|
||||
|
||||
- fix: |UI| 修复移动设备日期显示问题
|
||||
- feat: |Worker| 支持通过 `SMTP` 发送邮件, 使用 [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
|
||||
## v0.8.6
|
||||
|
||||
- feat: |UI| 公告支持 html 格式
|
||||
- feat: |UI| `COPYRIGHT` 支持 html 格式
|
||||
- feat: |Doc| 优化部署文档,补充了 `Github Actions 部署文档`,增加了 `Worker 变量说明`
|
||||
|
||||
## v0.8.5
|
||||
|
||||
- feat: |mail-parser-wasm-worker| 修复 `initSync` 函数调用时的 `deprecated` 参数警告
|
||||
- feat: rpc headers covert & typo (#559)
|
||||
- fix: telegram mail page use iframe show email (#561)
|
||||
- feat: |Worker| 增加 `REMOVE_ALL_ATTACHMENT` 和 `REMOVE_EXCEED_SIZE_ATTACHMENT` 用于移除邮件附件,由于是解析邮件的一些信息会丢失,比如图片等.
|
||||
|
||||
## v0.8.4
|
||||
|
||||
- fix: |UI| 修复 admin portal 无收件人邮箱删除调用api 错误
|
||||
- feat: |Telegram Bot| 增加 telegram bot 清理无效地址凭证命令
|
||||
- feat: 增加 worker 配置 `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` 禁用匿名用户创建邮箱地址,只允许登录用户创建邮箱地址
|
||||
- feat: 增加 worker 配置 `ENABLE_ANOTHER_WORKER` 及 `ANOTHER_WORKER_LIST` ,用于调用其他 worker 的 rpc 接口 (#547)
|
||||
- feat: |UI| 自动刷新配置保存到浏览器,可配置刷新间隔
|
||||
- feat: 垃圾邮件检测增加存在时才检查的列表 `JUNK_MAIL_CHECK_LIST` 配置
|
||||
- feat: | Worker | 增加 `ParsedEmailContext` 类用于缓存解析后的邮件内容,减少解析次数
|
||||
- feat: |Github Action| Worker 部署增加 `DEBUG_MODE` 输出日志, `BACKEND_USE_MAIL_WASM_PARSER` 配置是否使用 wasm 解析邮件
|
||||
|
||||
## v0.8.3
|
||||
|
||||
- feat: |Github Action| 增加自动更新并部署功能
|
||||
- feat: |UI| admin 用户设置,支持 oauth2 配置的删除
|
||||
- feat: 增加垃圾邮件检测必须通过的列表 `JUNK_MAIL_FORCE_PASS_LIST` 配置
|
||||
|
||||
## v0.8.2
|
||||
|
||||
- fix: |Doc| 修复文档中的一些错误
|
||||
- fix: |Github Action| 修复 frontend 部署分支错误的问题
|
||||
- feat: admin 发送邮件功能
|
||||
- feat: admin 后台,账号配置页面添加无限发送邮件的地址列表
|
||||
|
||||
## v0.8.1
|
||||
|
||||
- feat: |Doc| 更新 UI 安装的文档
|
||||
- feat: |UI| 对用户隐藏邮箱账号的 ID
|
||||
- feat: |UI| 增加邮件详情页的 `转发` 按钮
|
||||
|
||||
## v0.8.0
|
||||
|
||||
- feat: |UI| 随机生成地址时不超过最大长度
|
||||
- feat: |UI| 邮件时间显示浏览器时区,可在设置中切换显示为 UTC 时间
|
||||
- feat: 支持转移邮件到其他用户
|
||||
|
||||
## v0.7.6
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
UI 部署 worker 需要点击 Settings -> Runtime, 修改 Compatibility flags, 增加 `nodejs_compat`
|
||||
|
||||

|
||||
|
||||
### Changes
|
||||
|
||||
- feat: 支持提前设置 bot info, 降低 telegram 回调延迟 (#441)
|
||||
- feat: 增加 telegram mini app 的 build 压缩包
|
||||
- feat: 增加是否启用垃圾邮件检查 `ENABLE_CHECK_JUNK_MAIL` 配置
|
||||
|
||||
## v0.7.5
|
||||
|
||||
- fix: 修复 `name` 的校验检查
|
||||
|
||||
## v0.7.4
|
||||
|
||||
- feat: UI 列表页面增加最小宽度
|
||||
- fix: 修复 `name` 的校验检查
|
||||
- fix: 修复 `DEFAULT_DOMAINS` 配置为空不生效的问题
|
||||
|
||||
## v0.7.3
|
||||
|
||||
- feat: worker 增加 `ADDRESS_CHECK_REGEX`, address name 的正则表达式, 只用于检查,符合条件将通过检查
|
||||
- fix: UI 修复登录页面 tab 激活图标错位
|
||||
- fix: UI 修复 admin 页面刷新弹框输入密码的问题
|
||||
- feat: support `Oath2` 登录, 可以通过 `Github` `Authentik` 等第三方登录, 详情查看 [OAuth2 第三方登录](https://temp-mail-docs.awsl.uk/zh/guide/feature/user-oauth2.html)
|
||||
|
||||
## v0.7.2
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
`webhook` 的结构增加了 `enabled` 字段,已经配置了的需要重新在页面开启并保存。
|
||||
|
||||
### Changes
|
||||
|
||||
- fix: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 加载失败的问题
|
||||
- feat: worker 增加 `# ADDRESS_REGEX = "[^a-z.0-9]"` 配置, 替换非法符号的正则表达式,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
|
||||
- feat: worker 优化 webhook 逻辑, 支持 admin 配置全局 webhook, 添加 `message pusher` 集成示例
|
||||
|
||||
## v0.7.1
|
||||
|
||||
- fix: 修复用户角色加载失败的问题
|
||||
- feat: admin 账号设置增加来源邮件地址黑名单配置
|
||||
|
||||
## v0.7.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: Update new-address-api.md (#360)
|
||||
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
|
||||
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
|
||||
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
|
||||
- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
|
||||
- feat: worker 增加 `DISABLE_ADMIN_PASSWORD_CHECK` 配置, 用于配置是否禁用 admin 控制台密码检查, 若你的网站只可私人访问,可通过此禁用检查
|
||||
|
||||
## v0.6.1
|
||||
|
||||
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)
|
||||
- fix: imap proxy server 不支持 密码 by @dreamhunter2333 (#356)
|
||||
- worker 新增 `ANNOUNCEMENT` 配置, 用于配置公告信息 by @dreamhunter2333 (#357)
|
||||
- fix: telegram bot 新建地址默认选择第一个域名 by @dreamhunter2333 (#358)
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
DB changes: 增加用户角色表, 需要执行 `db/2024-07-14-patch.sql` 更新 `D1` 数据库
|
||||
|
||||
### Changes
|
||||
|
||||
worker 配置文件新增 `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, 具体查看文档 [worker配置](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html#%E4%BF%AE%E6%94%B9-wrangler-toml-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6)
|
||||
|
||||
- 移除 `apiV1` 相关代码和相关的数据库表
|
||||
- 更新 `admin/statistics` api, 添加用户统计信息
|
||||
- 更新地址的规则,只允许小写+数字,对于历史的地址在查询邮件时会进行 `lowercase` 处理
|
||||
- 增加用户角色功能,`admin` 可以设置用户角色(目前可配置每个角色域名和前缀)
|
||||
- admin 页面搜索优化, 回车自动搜索, 输入内容自动 trim
|
||||
|
||||
## v0.5.4
|
||||
|
||||
- 点击 logo 5 次进入 admin 页面
|
||||
- 修复 401 时无法跳转登录页面(admin 和 网站认证)
|
||||
|
||||
## v0.5.3
|
||||
|
||||
- 修复 smtp imap proxy sever 的一些 bug
|
||||
- 完善用户/admin 删除收件箱/发件箱的功能
|
||||
- admin 可以删除 发件权限记录
|
||||
- 添加中文邮件别名配置 `DOMAIN_LABELS` [文档](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html)
|
||||
- 移除 `mail channels` 相关代码
|
||||
- github actions 增加 `FRONTEND_BRANCH` 变量用于指定部署的分支 (#324)
|
||||
|
||||
## v0.5.1
|
||||
|
||||
- 添加 `mail-parser-wasm-worker` 用于 worker 解析邮件, [文档](https://temp-mail-docs.awsl.uk/zh/guide/feature/mail_parser_wasm_worker.html)
|
||||
@@ -283,7 +812,7 @@ The `mails` table will be discarded, and the `raw` text of the new `mail` will b
|
||||
```bash
|
||||
git checkout v0.2.0
|
||||
cd worker
|
||||
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql
|
||||
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
|
||||
pnpm run deploy
|
||||
cd ../frontend
|
||||
pnpm run deploy
|
||||
|
||||
843
CHANGELOG_EN.md
Normal file
843
CHANGELOG_EN.md
Normal file
@@ -0,0 +1,843 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.12.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
|
||||
- fix: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder
|
||||
- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |Send Mail| Improve the information hierarchy and responsive layout of the user and Admin composers, with a content-format toolbar, draft status, bottom send-action area, and isolated HTML preview
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
||||
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
|
||||
- fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
|
||||
|
||||
## v1.11.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend| Add Normal, Random, and Custom subdomain mode selection within the random-subdomain scope (issue #1108)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| Relabel mailbox settings and deletion actions as email-address operations, and clarify that send permission and balance are managed independently for the current email address
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |Send Mail| Document the differences between user accounts, email addresses, and send permission, including how to request permission for the currently selected address
|
||||
|
||||
- fix: |Worker| Throttle address-activity touches to one write per day so user settings and mailbox access do not repeatedly update recently active addresses, reducing D1 writes (issue #1103)
|
||||
|
||||
- feat: |User| Add server-side pagination for bound addresses, with totals queried only on the first page; validate user-mail list ownership with a JOIN and delete ownership with `EXISTS` instead of loading every bound address for large users (issue #1103)
|
||||
|
||||
- feat: |Worker| Process mail, sent-mail, and creation/activity-based address cleanup in batches of 3000 by default, configurable through `CLEANUP_BATCH_SIZE` up to 5000, reducing per-run scans and deletes (issue #1103)
|
||||
|
||||
### Testing
|
||||
|
||||
- fix: |E2E| Add regression coverage ensuring user settings do not rewrite recent address activity timestamps
|
||||
- fix: |E2E| Cover cleanup batch limits, continuation on later runs, preservation of recent data, and address-related data cleanup
|
||||
|
||||
## v1.10.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add `GET /admin/mails/:id` for administrators to fetch a single mail by ID across mailboxes, including gzip-compressed storage support (issue #1096)
|
||||
- feat: |Frontend| Add a "Full-width mailbox list view" toggle in Appearance settings. When enabled, the mailbox shows a full-width list of subjects and body previews by default; clicking a mail expands it into the two-pane split view, clicking the same mail again returns to the list view; in multi-select mode, clicking a mail updates both its checked state and the right-side preview while disabling same-mail collapse, and the split width still follows the "Left list width in two-column mailbox view" setting. Defaults to off, preserving the original two-pane behavior
|
||||
- feat: |Frontend| Add "Body Preview Lines" in Appearance settings for the full-width mailbox list view, allowing runtime control over the body-preview clamp. It defaults to 2 lines, and 0 disables previews
|
||||
- feat: |Frontend| Add an "Automatically load external images in mail body" toggle in Appearance settings. When disabled, the mail body (including fullscreen view) is run through DOMPurify and an allowlist policy: only references that can be *proven* local are kept (`cid:`, `data:image/`, `blob:` and same-origin relative paths), everything else is blocked. Elements that fetch on their own or change how relative URLs resolve — `base`, `meta`, `script`, `link`, `iframe`, `object`, `embed`, `noscript` — are removed in this mode, while `<style>` is kept with remote `url()`, `image-set()` and `@import` references substituted. A banner above the body reports how many resources were blocked and loads them for that mail on demand; defaults to on, preserving the previous behavior (issue #1073)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| Preserve external navigation links on `<a>` and `<area>` elements when automatic remote-image loading is disabled, and block remote CSS resources hidden behind escaped function or at-rule names
|
||||
- fix: |Frontend| Sanitize HTML announcements in both the About page and startup notification through a shared DOMPurify helper, preventing executable tags or event attributes in `ANNOUNCEMENT` from causing XSS
|
||||
- fix: |Worker| Align junk-mail checking with authentication standards: treat SPF, DKIM, and DMARC `none` plus SPF/DKIM `neutral` as absent, and ignore unregistered results and unsupported method versions; `JUNK_MAIL_FORCE_PASS_LIST` still requires an explicit supported `pass`
|
||||
- fix: |Admin| When deleting an address from the admin panel, delete its mails, sender records, sendbox and auto-reply entries before removing the address row itself; previously the address row was deleted first, so the name-based subqueries matched nothing and the mails were left orphaned in the database
|
||||
- fix: |AI Extract| Strengthen the prompt to keep original link domains from the email, preventing small models from rewriting verification-link domains (issue #1072)
|
||||
- fix: |AI Extract| Convert HTML-only mail bodies into compact readable text before sending them to Workers AI, preventing long templates from pushing verification codes past the 4000-character truncation window
|
||||
- fix: |Frontend| Add mobile Header page padding so the title and menu button no longer sit too close to the screen edge
|
||||
- fix: |IMAP Proxy| Fix IMAP `STORE` not actually marking mail as read: messages are no longer hardcoded to `\Seen`, and `SimpleMailbox` flag changes are now persisted to a local SQLite file (new `imap_flag_db_path` setting) so the read/unread state survives a client disconnect and reconnect (e.g. Thunderbird polling) instead of resetting on every new connection (issue #1074)
|
||||
- fix: |IMAP Proxy| Fix `SEARCH UNSEEN` returning every message: `SimpleMailbox.search()` now evaluates `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` and their negations against the persisted flags, combining multiple keys with AND; search keys it cannot evaluate keep the previous behaviour of matching everything
|
||||
- fix: |IMAP Proxy| Fix fetches never marking mail as read: `BODY[...]`, `RFC822` and `RFC822.TEXT` fetches now set `\Seen` per RFC 3501, while `BODY.PEEK[...]`, `RFC822.HEADER` and metadata-only fetches (e.g. `FLAGS`) do not
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |Worker| Add junk_mail_policy regression tests (issue #1084): `none`/`neutral` results for SPF/DKIM/DMARC are treated as the method being absent, explicit `fail` results are still rejected, and `JUNK_MAIL_FORCE_PASS_LIST` only accepts an explicit `pass`
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |README| Add a complete Japanese README and Japanese navigation links to the Chinese and English READMEs
|
||||
- feat: |Frontend| Lower the "Left list width in two-column mailbox view" minimum from 0.25 to 0 so the left list pane can fully collapse for a near-fullscreen content view, with a 0 mark added; applies to both the inbox and send-box two-pane splits, and clarifies the Appearance setting label so it is clear the value controls the left mail list width
|
||||
|
||||
## v1.9.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |AI Extract| Fall back to a built-in regex verification-code extractor (English / Chinese / Japanese / Korean, with year and `YYYYMMDD` date rejection) when no Workers AI binding is configured, so self-hosted deployments without Workers AI still surface codes in Telegram pushes and webhooks
|
||||
- feat: |Telegram| Show AI extraction results in Telegram new-mail notifications and `/mails` history views, including verification codes, auth links, service links, and subscription links
|
||||
- feat: |Webhook| Support AI extraction placeholders in mail webhook templates, including `aiExtractType`, `aiExtractResult`, and `aiExtractResultText`
|
||||
- feat: |Frontend| Add `DISABLE_SHOW_GITHUB_FOR_USER` to hide the Header GitHub/version entry from normal users while keeping it visible to admin users (issue #1041)
|
||||
- feat: |Frontend| Upgrade the address credential dialog to "Address Credentials & Connection Methods" and reuse it for both normal users and admin-created addresses; support showing AI Agent access via `ENABLE_AGENT_EMAIL_INFO` and SMTP/IMAP client settings via `SMTP_IMAP_PROXY_CONFIG`
|
||||
- docs: |Random Subdomain| Clarify in the "Use Random Subdomain" frontend tip and the `subdomain` / `worker-vars` docs (zh & en) that receiving mail on `name@<random>.abc.com` requires a wildcard `*` MX record under the base domain in DNS, because Cloudflare Email Routing does not inherit the apex configuration onto subdomains (issue #1035)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| Hash address passwords in the frontend before admin reset requests, and make the backend accept and store only the hash instead of plaintext
|
||||
- fix: |Address| Stop returning stored address password hashes from the admin address list and user bound-address list APIs to avoid exposing sensitive fields
|
||||
- fix: |Address| Normalize whitespace and casing for configured domains, inbound recipient domains, and prefixes across `DOMAINS`, `DEFAULT_DOMAINS`, `USER_ROLES.domains`, random subdomains, forwarding rules, SMTP, and `SEND_MAIL` domain matching, preserve blank-domain catch-all forwarding rules, and clarify that empty `DEFAULT_DOMAINS` / role domains fall back to `DOMAINS`, to avoid create, receive, forward, or send failures caused by mixed-case configuration or inbound recipient domains (issue #926)
|
||||
- fix: |AI Extract| Switch the default Workers AI model for AI email recognition to the JSON Mode-compatible, non-deprecated `@cf/meta/llama-3.1-8b-instruct-fast`, and document structured-output compatibility guidance for `@cf/zai-org/glm-4.7-flash` (issue #1029)
|
||||
- fix: |CI| Upgrade GitHub Actions and e2e Docker images to Node.js 24 to satisfy Wrangler 4.90.0 runtime requirements
|
||||
- fix: |Frontend| Prevent iOS Safari from auto-zooming the page when focusing mobile form controls with small font sizes
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.8.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend| Add six-language frontend support (`zh` / `en` / `es` / `pt-BR` / `ja` / `de`), keep `zh` as the default locale; locale-unprefixed routes (for example `/` and `/user`) render in Chinese by default while still recording browser language as the stored preference. Explicit locale switches are persisted, and the current route, query string, and canonical locale URL stay in sync during switching
|
||||
- feat: |API| Add server-side parsed-mail endpoints `/api/parsed_mails` and `/api/parsed_mail/:id` that return `sender` / `subject` / `text` / `html` / `attachments` metadata directly (reuses `commonParseMail`), so AI agents no longer need a client-side MIME parser
|
||||
- feat: |Skill| Bundle a read-only skill `cf-temp-mail-agent-mail` (`skills/cf-temp-mail-agent-mail/`) so AI agents like OpenClaw / Codex / Cursor can consume a mailbox with a user-supplied Address JWT + API base URL — list mails, poll verification codes, etc. — sidestepping the Turnstile challenge required to create a mailbox. Install via `npx degit dreamhunter2333/cloudflare_temp_email/skills/cf-temp-mail-agent-mail`
|
||||
- docs: |Docs| Add "AI Agent Mailbox Usage" doc (`guide/feature/agent-email`) covering the `parsed_mail` API and a local-parse fallback using `mail-parser-wasm` + `postal-mime` (mirrors the frontend) when parsed endpoints are unavailable
|
||||
- docs: |Docs| Make "a domain is a hard prerequisite" explicit at the top of `quick-start`, `worker-vars`, and `email-routing` (zh + en), spelling out that Cloudflare Email Routing must be enabled with email DNS records provisioned before deployment, the Catch-all rule must be bound after the Worker is deployed, and subdomains do not inherit the parent domain's Email Routing — so users no longer start deploying without a usable domain and end up unable to receive mail (issue #1004)
|
||||
- docs: |Deployment troubleshooting| Improve docs for recent UI-deployment and upgrade issues: document `nodejs_compat`, the required uppercase `DB` D1 binding, `/open_api/settings` verification, backend API URL entry, Cloudflare security challenges causing `Network Error`, D1 size limits and Cron Trigger cleanup, GitHub OAuth public email requirements, the difference between admin passwords and user accounts, and the `enableRandomSubdomain` API flag; move the Help/FAQ menu directly after Core Configuration so it is easier to find
|
||||
- docs: |Docs| Document how to handle the "address already exists" case when recreating an old mailbox, and clarify the GitHub Actions workflow for automatic updates with Page Functions forwarding backend requests (issues #947 #654)
|
||||
- docs: |OAuth2| Document GitHub private-email login configuration using `https://api.github.com/user/emails`, a JSONPath email field, and the `user:email` scope to read the primary email (issue #655)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| Narrow address-management modal widths and keep address tables horizontally scrollable inside the modal to prevent multi-address lists from stretching the dialog
|
||||
- fix: |Frontend| Fix the frontend settings bootstrap throwing an `undefined` error when `/open_api/settings` does not return a `domains` array by normalizing the field to an empty array before mapping it
|
||||
- fix: |Frontend| Fix every API call crashing client-side with `Invalid character in header content ["Authorization"]` when stale localStorage credentials (`jwt` / `auth` / `adminAuth` / `userJwt` / `access_token`) are empty, the literal string `"undefined"`, or contain a stray newline or other control character (issue #1000). Adds `safeHeaderValue` / `safeBearerHeader` helpers that validate every auth header against RFC 7230 and omit the header entirely when unsafe, so the worker returns a clean 401 instead of the request being rejected by axios/undici
|
||||
- fix: |Frontend| Fix the multilingual header on mobile by keeping only the menu button in the top bar and moving language/version actions into the drawer to avoid horizontal crowding or overflow
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: |Worker| Split `mails_api/index.ts` and `admin_api/index.ts` so the index files only wire routes. Business logic moved into dedicated `*_api.ts` files (`mails_crud.ts` / `new_address.ts` / `parsed_mail_api.ts` / `address_api.ts` / `address_sender_api.ts` / `sendbox_api.ts` / `statistics_api.ts` / `account_settings_api.ts`). Paths and behavior unchanged
|
||||
|
||||
## v1.7.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- breaking: |send mail| `SEND_MAIL` semantics changed from a verified-address-only compatibility path to a normal fallback send channel. If an instance already binds `SEND_MAIL` and does not configure Resend/SMTP, recipients outside `verifiedAddressList` will now also be sent through the Cloudflare binding after upgrade, changing runtime behavior and cost routing
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |send mail| Recommend Cloudflare `send_email` binding as the default send channel. Domains onboarded to Email Routing without Resend/SMTP now automatically use the binding to send to arbitrary addresses (Workers Paid includes 3,000 msgs/month, $0.35/1000 beyond); existing `verifiedAddressList` / Resend / SMTP configurations remain fully compatible (#964)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Send Mail| Auto-initialize the default send balance for addresses that have no `address_sender` row yet when `DEFAULT_SEND_BALANCE > 0`, on the first send-settings read or send API call (`ON CONFLICT DO NOTHING`). Existing rows — including admin-disabled or admin-edited ones — are never overwritten by the runtime path, so users no longer need to manually request send permission first (#925 #985)
|
||||
- fix: |User Mailbox| Fix an issue where the user center still showed delete actions and could still delete mail via `/user_api/mails/:id` when `ENABLE_USER_DELETE_EMAIL` was disabled (#978)
|
||||
- fix: |Address| Lowercase configured prefixes when creating addresses to avoid generating mixed-case mailbox names; existing data must be migrated to lowercase manually by the user (#930)
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.6.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add **IP Whitelist (strict mode)** to IP blacklist settings: when enabled, ONLY whitelisted IPs can access rate-limited APIs (create address, send mail, external send mail, user register, verify code); all other IPs are denied (#920)
|
||||
- feat: |Address| Support setting max address count to `0` for unlimited (#968)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| Fix `D1_ERROR: LIKE or GLOB pattern too complex` on `/admin/address` and `/admin/users` when searching by full email address (query length pushes the LIKE pattern over D1's 50-byte limit). Long queries now fall back to `instr()` to bypass the LIKE pattern length cap (#956)
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |Send Mail API| Clarify authentication differences between `/api/send_mail` and `/external/api/send_mail`, add "Address JWT" concept explanation (#922)
|
||||
- docs: |Worker Variables| Add generation instructions for `JWT_SECRET` (`openssl rand -hex 32`) (#932)
|
||||
- docs: |CLI Deployment| Add usage explanation for `routes` custom domain configuration (#932)
|
||||
- docs: |Admin API| Add `address_id` field to `/admin/new_address` response documentation (#912)
|
||||
- docs: |Admin| Add account list sorting feature documentation (#918)
|
||||
- docs: |Pages Deployment| Add SPA mode instructions to avoid 404 when refreshing or accessing sub-paths directly (#813)
|
||||
- docs: |Sidebar| Restructure documentation sidebar into "Core Configuration", "Notifications & Integrations", "Advanced Features", "Admin Console" groups
|
||||
- docs: |FAQ| Significantly expand FAQ with SPA 404, send balance, SMTP_CONFIG, mail client login and more (#919, #925, #839, #715, #921, #609)
|
||||
- docs: |Email Sending| Enhance SMTP_CONFIG field reference and multi-domain examples, add send balance mechanism documentation
|
||||
- docs: |Email Routing| Note that subdomains require Email Routing to be enabled separately; enabling it only on the apex domain does not cover subdomains (#969)
|
||||
|
||||
## v1.5.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Admin account list now supports column sorting (ID, name, created at, updated at, mail count, send count), search automatically resets pagination to page 1 (#918)
|
||||
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
|
||||
- feat: |Create Address| Add `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` switch and an admin-panel toggle for suffix-based subdomain matching in create-address APIs; when enabled, `foo.example.com` can match base domain `example.com`
|
||||
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
|
||||
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
|
||||
- feat: |Telegram| Support sending email attachments in Telegram push (50MB per file limit), multiple attachments sent via `sendMediaGroup`, controlled by `ENABLE_TG_PUSH_ATTACHMENT` env var (#894)
|
||||
- feat: |Mail Storage| Support enabling gzip-compressed email storage via `ENABLE_MAIL_GZIP` variable (#823)
|
||||
- Run database migration before enabling it: `Admin -> Quick Setup -> Database -> Migrate Database`, or call `POST /admin/db_migration`
|
||||
- New emails are stored in `raw_blob` and reads stay compatible with `raw` / `raw_blob`; compression and decompression add CPU overhead, so a paid Worker plan is recommended
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Auto Reply| Fix auto-reply not triggering when `source_prefix` is empty string (#459), empty value now correctly matches all senders
|
||||
- fix: |OAuth2| Fix OAuth2 login callback failure on Android via browser and other mobile browsers due to sessionStorage loss during redirect, add localStorage fallback (#900)
|
||||
- fix: |IMAP| Fix nested reply email mojibake, Gmail empty Content-Type header parsing failure, missing Date header, and locale-dependent date formatting issues
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| Add create-address subdomain matching tests covering default exact-match behavior, admin-enabled matching, and env=false hard-disable precedence
|
||||
- test: |E2E| Add auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
|
||||
|
||||
### Docs
|
||||
|
||||
- docs: |Create Address| Update create-address API, worker variables, and subdomain docs to clarify the difference between explicitly specified subdomains and random subdomains
|
||||
- docs: |API| Add clarification between Address JWT and User JWT to avoid confusion; reorganize documentation menu structure with dedicated API Endpoints section (#910)
|
||||
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
|
||||
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
|
||||
- feat: |Webhook| Add Telegram Bot, WeChat Work, Discord preset templates to frontend webhook settings
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.4.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |User Registration| Add email regex validation for user registration, admins can configure email format validation rules
|
||||
- feat: |Frontend| Add configurable Status menu button via `STATUS_URL` environment variable for status monitoring page link
|
||||
- feat: |SMTP| Add STARTTLS support for SMTP proxy server via `smtp_tls_cert` and `smtp_tls_key` environment variables
|
||||
- feat: |Webhook| Add preset templates dropdown to Webhook settings page, supporting one-click fill for Message Pusher, Bark, and ntfy
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Telegram| Fix admin users unable to view emails via Telegram MiniApp due to `Auth date expired` error, support admin password auth for viewing emails
|
||||
- fix: |Admin API| Fix `/admin/account_settings` throwing `Cannot read properties of undefined (reading 'put')` when KV is not configured and `fromBlockList` is empty
|
||||
- fix: |Database| Fix missing `idx_raw_mails_message_id` index in `DB_INIT_QUERIES` causing full table scan on `UPDATE raw_mails ... WHERE message_id = ?`, sync `schema.sql` with init code, add v0.0.6 migration
|
||||
- fix: |Docs| Fix User Mail API documentation incorrectly using `x-admin-auth`, changed to correct `x-user-token`
|
||||
- fix: |Frontend| Fix email content text being unreadable in dark theme, improve dark mode styles for plain text mail and Shadow DOM rendering
|
||||
- docs: |Docs| Add Admin API documentation for delete mail, delete address, clear inbox, and clear sent items
|
||||
- fix: |Frontend| Fix reply to HTML email losing original HTML content, prefer HTML message over plain text
|
||||
- fix: |Security| Fix XSS vulnerability in reply/forward mail content, sanitize HTML with DOMPurify whitelist and escape plain text
|
||||
- fix: |API| Fix typo in `requset_send_mail_access` API path, renamed to `request_send_mail_access`
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
|
||||
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
|
||||
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
|
||||
|
||||
### Improvements
|
||||
|
||||
- style: |Mail List| Improve empty state display for inbox and sent box, show different messages based on mail count, add semantic icons
|
||||
- feat: |Admin| Add ip.im lookup link for source IP in address list, click to quickly view IP information
|
||||
- docs: |Docs| Fix VitePress i18n language switch path error, use dual-prefix locale configuration
|
||||
- feat: |IMAP Proxy| Refactor IMAP server into separate modules (HTTP client, mailbox, message), use `deferToThread` for async HTTP to avoid blocking Twisted reactor, use backend `id` as stable UID, add STARTTLS support, LRU message cache, session-local flags management, SEARCH command support, JWT credential and address+password dual login methods, and comprehensive test suite
|
||||
- fix: |IMAP Proxy| Fix `getHeaders()` filtering and `store()` crash
|
||||
- fix: |Email Parser| Fix `parse_email.py` using private `_payload` attribute causing encoding errors, use `get_payload(decode=True)` for proper email body decoding
|
||||
|
||||
## v1.3.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |OAuth2| Add email format transformation support for OAuth2, allowing regex-based email format conversion from third-party login providers (e.g., transform `user@domain` to `user@custom.domain`)
|
||||
- feat: |OAuth2| Add SVG icon support for OAuth2 providers, admins can configure custom icons for login buttons, preset icons for GitHub, Linux Do, Authentik templates
|
||||
- feat: |Send Mail| Auto-hide sendmail tab, sendbox tab, and reply button when send mail is not configured
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |User Address| Fix address count limit check failure when anonymous creation is disabled for logged-in users, add public function `isAddressCountLimitReached` to unify address count limit logic
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: |Code Refactoring| Extract address count limit check as a public function to improve code reusability
|
||||
- perf: |Performance| Change address activity time update in GET requests to async execution using `waitUntil`, non-blocking response
|
||||
|
||||
## v1.2.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Scheduled Tasks| Fix scheduled task cleanup error `e.get is not a function`, use optional chaining for safe access to Context methods
|
||||
|
||||
### Improvements
|
||||
|
||||
- style: |AI Extraction| Use softer blue color (#A8C7FA) for AI extraction info in dark mode to reduce eye strain
|
||||
|
||||
## v1.2.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- |Database| Add `source_meta` field, need to execute `db/2025-12-27-source-meta.sql` to update database or click database update button on admin maintenance page
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add admin account page, display current login method and support logout (password login only)
|
||||
- fix: |GitHub Actions| Fix container image name must be lowercase
|
||||
- feat: |Email Forwarding| Add source address regex forwarding, filter by sender address, fully backward compatible
|
||||
- feat: |Address Source| Add address source tracking feature, record address creation source (Web records IP, Telegram records user ID, Admin panel marked)
|
||||
- feat: |Email Filtering| Remove backend keyword parameter, switch to frontend filtering of current page emails, optimize query performance
|
||||
- feat: |Frontend| Unify address switching into a dropdown component, support switching in simple mode, add address management entry on the homepage
|
||||
- feat: |Database| Add index for `message_id` field to optimize email update operations, need to execute `db/2025-12-15-message-id-index.sql` to update database
|
||||
- feat: |Admin| Add custom SQL cleanup feature to maintenance page, support scheduled task execution of custom cleanup statements
|
||||
- feat: |i18n| Backend API error messages now fully support Chinese and English internationalization
|
||||
- feat: |Telegram| Bot supports Chinese/English switching, add `/lang` command to set language preference
|
||||
|
||||
## v1.1.0
|
||||
|
||||
- feat: |AI Extraction| Add AI email recognition feature, use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
|
||||
- Support priority extraction: verification codes > authentication links > service links > subscription links > other links
|
||||
- Admin can configure address whitelist (supports wildcards, e.g. `*@example.com`)
|
||||
- Frontend list and detail pages display extraction results
|
||||
- Need to configure `ENABLE_AI_EMAIL_EXTRACT` environment variable and AI binding
|
||||
- Need to execute SQL in `db/2025-12-06-metadata.sql` file to update `D1` database or click database update button on admin maintenance page
|
||||
- feat: |Admin| Add feature to cleanup addresses with empty mailboxes older than n days on maintenance page
|
||||
- fix: Fix custom authentication password function issue (frontend property name error & /open_api interface blocked)
|
||||
|
||||
## v1.0.7
|
||||
|
||||
- feat: |Admin| Add IP blacklist feature for limiting high-frequency API access
|
||||
- feat: |Admin| Add ASN organization blacklist feature, support filtering requests based on ASN organization name (supports text matching and regex)
|
||||
- feat: |Admin| Add browser fingerprint blacklist feature, support filtering requests based on browser fingerprint (supports exact matching and regex)
|
||||
|
||||
## v1.0.6
|
||||
|
||||
- feat: |DB| Update db schema add index
|
||||
- feat: |Address Password| Add address password login feature, enabled via `ENABLE_ADDRESS_PASSWORD` configuration, need to execute SQL in `db/2025-09-23-patch.sql` file to update `D1` database
|
||||
- fix: |GitHub Actions| Fix debug mode configuration, only enable debug mode when DEBUG_MODE is 'true'
|
||||
- feat: |Admin| Account management page adds multi-select batch operations (batch delete, batch clear inbox, batch clear outbox)
|
||||
- feat: |Admin| Maintenance page adds feature to cleanup unbound user addresses
|
||||
- feat: Support configuring different bound address quantity limits for different roles, configurable in admin page
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- feat: Add `DISABLE_CUSTOM_ADDRESS_NAME` configuration: disable custom email address name feature
|
||||
- feat: Add `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` configuration: prioritize first domain when creating addresses
|
||||
- feat: |UI| Add button to enter minimalist mode on homepage
|
||||
- feat: |Webhook| Add whitelist switch feature, support flexible access control
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- feat: |UI| Optimize minimalist mode homepage, add all emails page functionality (delete/download/attachments/...), switchable in `Appearance`
|
||||
- feat: Admin account settings page adds `Email Forwarding Rules` configuration
|
||||
- feat: Admin account settings page adds `Reject Unknown Address Emails` configuration
|
||||
- feat: Email page adds Previous/Next buttons
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- fix: Fix github actions deployment issue
|
||||
- feat: telegram /new when domain not specified, use random address
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- fix: Fix oauth2 login failure issue
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- feat: |UI| Add minimalist mode homepage, switchable in `Appearance`
|
||||
- fix: Fix oauth2 login default role not taking effect issue
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- fix: |UI| Fix User inbox viewing, when address not selected, keyword query not working
|
||||
- fix: Fix auto cleanup task, time 0 not taking effect issue
|
||||
- feat: Cleanup feature adds cleanup of addresses created n days ago, cleanup of addresses inactive for n days
|
||||
- fix: |IMAP Proxy| Fix IMAP Proxy server unable to view new emails issue
|
||||
|
||||
## v0.10.0
|
||||
|
||||
- feat: Support User inbox viewing, `/user_api/mails` interface, support `address` and `keyword` filtering
|
||||
- fix: Fix Oauth2 login token retrieval, some Oauth2 require `redirect_uri` parameter issue
|
||||
- feat: When user accesses webpage, if `user token` expires within 7 days, auto refresh
|
||||
- feat: Add db initialization feature to admin portal
|
||||
- feat: Add `ALWAYS_SHOW_ANNOUNCEMENT` variable to configure whether to always show announcements
|
||||
|
||||
## v0.9.1
|
||||
|
||||
- feat: |UI| Support google ads
|
||||
- feat: |UI| Use shadow DOM to prevent style pollution
|
||||
- feat: |UI| Support URL jwt parameter auto-login to mailbox, jwt parameter overrides browser jwt
|
||||
- fix: |CleanUP| Fix cleanup emails when cleanup time exceeds 30 days error bug
|
||||
- feat: Admin user management page: add user address viewing feature
|
||||
- feat: | S3 Attachments| Add S3 attachment deletion feature
|
||||
- feat: | Admin API| Add admin bind user and address api
|
||||
- feat: | Oauth2 | When Oauth2 gets user info, support `JSONPATH` expressions
|
||||
|
||||
## v0.9.0
|
||||
|
||||
- feat: | Worker | Support multi-language
|
||||
- feat: | Worker | `NO_LIMIT_SEND_ROLE` configuration supports multiple roles, comma separated
|
||||
- feat: | Actions | Add `worker-with-wasm-mail-parser.zip` in build to support UI deployment with `wasm` worker
|
||||
|
||||
## v0.8.7
|
||||
|
||||
- fix: |UI| Fix mobile device date display issue
|
||||
- feat: |Worker| Support sending emails via `SMTP`, using [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
|
||||
## v0.8.6
|
||||
|
||||
- feat: |UI| Announcements support html format
|
||||
- feat: |UI| `COPYRIGHT` supports html format
|
||||
- feat: |Doc| Optimize deployment documentation, supplement `Github Actions Deployment Documentation`, add `Worker Variable Description`
|
||||
|
||||
## v0.8.5
|
||||
|
||||
- feat: |mail-parser-wasm-worker| Fix `deprecated` parameter warning when calling `initSync` function
|
||||
- feat: rpc headers convert & typo (#559)
|
||||
- fix: telegram mail page use iframe show email (#561)
|
||||
- feat: |Worker| Add `REMOVE_ALL_ATTACHMENT` and `REMOVE_EXCEED_SIZE_ATTACHMENT` for removing email attachments, due to parsing emails some information will be lost, such as images.
|
||||
|
||||
## v0.8.4
|
||||
|
||||
- fix: |UI| Fix admin portal delete call api error when no recipient email
|
||||
- feat: |Telegram Bot| Add telegram bot cleanup invalid address credentials command
|
||||
- feat: Add worker configuration `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` to disable anonymous user email creation, only allow logged-in users to create email addresses
|
||||
- feat: Add worker configuration `ENABLE_ANOTHER_WORKER` and `ANOTHER_WORKER_LIST`, for calling other worker rpc interfaces (#547)
|
||||
- feat: |UI| Auto refresh configuration saved to browser, configurable refresh interval
|
||||
- feat: Spam detection adds check-when-exists list `JUNK_MAIL_CHECK_LIST` configuration
|
||||
- feat: | Worker | Add `ParsedEmailContext` class for caching parsed email content, reduce parsing times
|
||||
- feat: |Github Action| Worker deployment adds `DEBUG_MODE` output logging, `BACKEND_USE_MAIL_WASM_PARSER` configuration for whether to use wasm to parse emails
|
||||
|
||||
## v0.8.3
|
||||
|
||||
- feat: |Github Action| Add auto update and deploy feature
|
||||
- feat: |UI| Admin user settings, support oauth2 configuration deletion
|
||||
- feat: Add spam detection must-pass list `JUNK_MAIL_FORCE_PASS_LIST` configuration
|
||||
|
||||
## v0.8.2
|
||||
|
||||
- fix: |Doc| Fix some documentation errors
|
||||
- fix: |Github Action| Fix frontend deployment branch error issue
|
||||
- feat: Admin send email feature
|
||||
- feat: Admin backend, account configuration page adds unlimited send email address list
|
||||
|
||||
## v0.8.1
|
||||
|
||||
- feat: |Doc| Update UI installation documentation
|
||||
- feat: |UI| Hide mailbox account ID from users
|
||||
- feat: |UI| Add `Forward` button to email detail page
|
||||
|
||||
## v0.8.0
|
||||
|
||||
- feat: |UI| Random address generation doesn't exceed max length
|
||||
- feat: |UI| Email time display in browser timezone, can switch to display UTC time in settings
|
||||
- feat: Support transferring emails to other users
|
||||
|
||||
## v0.7.6
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
UI deployment worker needs to click Settings -> Runtime, modify Compatibility flags, add `nodejs_compat`
|
||||
|
||||

|
||||
|
||||
### Changes
|
||||
|
||||
- feat: Support pre-setting bot info to reduce telegram callback latency (#441)
|
||||
- feat: Add telegram mini app build archive
|
||||
- feat: Add whether to enable spam check `ENABLE_CHECK_JUNK_MAIL` configuration
|
||||
|
||||
## v0.7.5
|
||||
|
||||
- fix: Fix `name` validation check
|
||||
|
||||
## v0.7.4
|
||||
|
||||
- feat: UI list page adds minimum width
|
||||
- fix: Fix `name` validation check
|
||||
- fix: Fix `DEFAULT_DOMAINS` configuration empty not taking effect issue
|
||||
|
||||
## v0.7.3
|
||||
|
||||
- feat: Worker adds `ADDRESS_CHECK_REGEX`, address name regex, only for checking, matching will pass check
|
||||
- fix: UI fix login page tab active icon misalignment
|
||||
- fix: UI fix admin page refresh popup password input issue
|
||||
- feat: Support `OAuth2` login, can login via `Github` `Authentik` and other third parties, see details [OAuth2 Third-party Login](https://temp-mail-docs.awsl.uk/en/guide/feature/user-oauth2.html)
|
||||
|
||||
## v0.7.2
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
`webhook` structure adds `enabled` field, existing configurations need to be re-enabled and saved on the page.
|
||||
|
||||
### Changes
|
||||
|
||||
- fix: Worker adds `NO_LIMIT_SEND_ROLE` configuration, loading failure issue
|
||||
- feat: Worker adds `# ADDRESS_REGEX = "[^a-z.0-9]"` configuration, regex for replacing illegal symbols, if not set, defaults to [^a-z0-9], use with caution, some symbols may cause receiving issues
|
||||
- feat: Worker optimizes webhook logic, supports admin configuring global webhook, adds `message pusher` integration example
|
||||
|
||||
## v0.7.1
|
||||
|
||||
- fix: Fix user role loading failure issue
|
||||
- feat: Admin account settings adds source email address blacklist configuration
|
||||
|
||||
## v0.7.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
DB changes: Add user `passkey` table, need to execute `db/2024-08-10-patch.sql` to update `D1` database
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: Update new-address-api.md (#360)
|
||||
- feat: Worker adds `ADMIN_USER_ROLE` configuration, for configuring admin user role, users with this role can access admin management page (#363)
|
||||
- feat: Worker adds `DISABLE_SHOW_GITHUB` configuration, for configuring whether to show github link
|
||||
- feat: Worker adds `NO_LIMIT_SEND_ROLE` configuration, for configuring roles that can send unlimited emails
|
||||
- feat: User adds `passkey` login method, for user login, no password required
|
||||
- feat: Worker adds `DISABLE_ADMIN_PASSWORD_CHECK` configuration, for configuring whether to disable admin console password check, if your site is only privately accessible, you can disable the check
|
||||
|
||||
## v0.6.1
|
||||
|
||||
- pages github actions && fix cleanup emails days 0 not taking effect by @tqjason (#355)
|
||||
- fix: imap proxy server doesn't support password by @dreamhunter2333 (#356)
|
||||
- worker adds `ANNOUNCEMENT` configuration, for configuring announcement info by @dreamhunter2333 (#357)
|
||||
- fix: telegram bot create new address defaults to first domain by @dreamhunter2333 (#358)
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
DB changes: Add user role table, need to execute `db/2024-07-14-patch.sql` to update `D1` database
|
||||
|
||||
### Changes
|
||||
|
||||
Worker configuration file adds `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, see documentation [worker configuration](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
|
||||
|
||||
- Remove `apiV1` related code and related database tables
|
||||
- Update `admin/statistics` api, add user statistics info
|
||||
- Update address rules, only allow lowercase+numbers, for historical addresses `lowercase` processing will be performed when querying emails
|
||||
- Add user role feature, `admin` can set user roles (currently can configure domain and prefix for each role)
|
||||
- Admin page search optimization, enter key auto search, input content auto trim
|
||||
|
||||
## v0.5.4
|
||||
|
||||
- Click logo 5 times to enter admin page
|
||||
- Fix 401 cannot redirect to login page (admin and site authentication)
|
||||
|
||||
## v0.5.3
|
||||
|
||||
- Fix some bugs in smtp imap proxy server
|
||||
- Improve user/admin delete inbox/outbox functionality
|
||||
- Admin can delete send permission records
|
||||
- Add Chinese email alias configuration `DOMAIN_LABELS` [documentation](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
|
||||
- Remove `mail channels` related code
|
||||
- github actions adds `FRONTEND_BRANCH` variable to specify deployment branch (#324)
|
||||
|
||||
## v0.5.1
|
||||
|
||||
- Add `mail-parser-wasm-worker` for worker email parsing, [documentation](https://temp-mail-docs.awsl.uk/en/guide/feature/mail_parser_wasm_worker.html)
|
||||
- Add user email length validation configuration `MIN_ADDRESS_LEN` and `MAX_ADDRESS_LEN`
|
||||
- Fix `pages function` not forwarding `telegram` api issue
|
||||
|
||||
## v0.5.0
|
||||
|
||||
- UI: Add local cache for address management
|
||||
- worker: Add `FORWARD_ADDRESS_LIST` global email forwarding address (equivalent to `catch all`)
|
||||
- UI: Multi-language uses routing for switching
|
||||
- Add save attachments to S3 feature
|
||||
- UI: Add received email list `batch delete` and `batch download`
|
||||
|
||||
## v0.4.6
|
||||
|
||||
- Worker configuration file adds `TITLE = "Custom Title"`, can customize website title
|
||||
- Fix KV not bound unable to delete address issue
|
||||
|
||||
## v0.4.5
|
||||
|
||||
- UI lazy load
|
||||
- telegram bot adds user global push feature (admin users)
|
||||
- Add support for cloudflare verified user sending emails
|
||||
- Add using `resend` to send emails, `resend` provides http and smtp api, easier to use, documentation: https://temp-mail-docs.awsl.uk/en/guide/config-send-mail.html
|
||||
|
||||
## v0.4.4
|
||||
|
||||
- Add telegram mini app
|
||||
- telegram bot adds `unbind`, `delete` commands
|
||||
- Fix webhook multiline text issue
|
||||
|
||||
## v0.4.3
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Configuration file `main = "src/worker.js"` changed to `main = "src/worker.ts"`
|
||||
|
||||
### Changes
|
||||
|
||||
- `telegram bot` whitelist configuration
|
||||
- `ENABLE_WEBHOOK` add webhook
|
||||
- UI: admin page uses two-level tabs
|
||||
- UI: can directly switch addresses on homepage after login
|
||||
- UI: outbox also uses split view display (similar to inbox)
|
||||
- `SMTP IMAP Proxy` add outbox viewing
|
||||
|
||||
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
|
||||
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
|
||||
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
|
||||
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
|
||||
|
||||
## v0.4.2
|
||||
|
||||
- Fix some bugs in smtp imap proxy server
|
||||
- Fix UI interface text errors, interface adds version number
|
||||
- Add telegram bot documentation https://temp-mail-docs.awsl.uk/en/guide/feature/telegram.html
|
||||
|
||||
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
|
||||
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
|
||||
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
|
||||
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
|
||||
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
|
||||
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
|
||||
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
|
||||
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
|
||||
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
|
||||
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
|
||||
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
|
||||
|
||||
### v0.4.1
|
||||
|
||||
- Username limited to max 30 characters
|
||||
- Fix `/external/api/send_mail` not returning bug (#222)
|
||||
- Add `IMAP proxy` service, support `IMAP` viewing emails
|
||||
- UI interface adds version number display
|
||||
|
||||
* feat: use common function handleListQuery when query by page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/220
|
||||
* fix: typos by @lwd-temp in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
|
||||
* fix: name max 30 && /external/api/send_mail not return result by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/222
|
||||
* fix: smtp_proxy_server support decode from mail charset by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/223
|
||||
* feat: add imap proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/225
|
||||
* feat: UI show version by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/226
|
||||
|
||||
### New Contributors
|
||||
|
||||
* @lwd-temp made their first contribution in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
|
||||
|
||||
## v0.4.0
|
||||
|
||||
### DB Changes/Breaking changes
|
||||
|
||||
Added user related tables for storing user information
|
||||
|
||||
- `db/2024-05-08-patch.sql`
|
||||
|
||||
### config changes
|
||||
|
||||
Enable user registration email verification requires `KV`
|
||||
|
||||
```toml
|
||||
# kv config for send email verification code
|
||||
# [[kv_namespaces]]
|
||||
# binding = "KV"
|
||||
# id = "xxxx"
|
||||
```
|
||||
|
||||
### function changes
|
||||
|
||||
- Add user registration feature, can bind email addresses, automatically obtain email JWT credentials after binding
|
||||
- Add default text display for emails, text and HTML email display mode switch button
|
||||
- Fix `BUG` randomly generated email names are invalid #211
|
||||
- `admin` email page supports email content search #210
|
||||
- Fix bug where emails weren't deleted when deleting addresses #213
|
||||
- UI adds global tab position configuration, side margin configuration
|
||||
|
||||
* feat: update docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/204
|
||||
* feat: add Deploy to Cloudflare Workers button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/205
|
||||
* feat: add Deploy to Cloudflare Workers docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/206
|
||||
* feat: add UserLogin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/209
|
||||
* feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/214
|
||||
* feat: UI check openSettings in Login page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/215
|
||||
* feat: UI move AdminContact to common by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/217
|
||||
* feat: docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/218
|
||||
|
||||
## v0.3.3
|
||||
|
||||
- Fix Admin delete email error
|
||||
- UI: Reply email button, quote original email text #186
|
||||
- Add send email address blacklist
|
||||
- Add `CF Turnstile` CAPTCHA configuration
|
||||
- Add `/external/api/send_mail` send email api, use body verification #194
|
||||
|
||||
## v0.3.2
|
||||
|
||||
## What's Changed
|
||||
|
||||
- UI: Add reply email button
|
||||
- Add scheduled cleanup feature, configurable in admin page (need to enable scheduled task in config file)
|
||||
- Fix delete account no response issue
|
||||
|
||||
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
|
||||
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
|
||||
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
|
||||
|
||||
## v0.3.1
|
||||
|
||||
### DB Changes
|
||||
|
||||
Added `settings` table for storing general configuration information
|
||||
|
||||
- `db/2024-05-01-patch.sql`
|
||||
|
||||
### Changes
|
||||
|
||||
- `ENABLE_USER_CREATE_EMAIL` whether to allow users to create emails
|
||||
- Allow admin to create emails without prefix
|
||||
- Add `SMTP proxy server`, support SMTP sending emails
|
||||
- Fix some cases where browsers can't load `wasm` use js to parse emails
|
||||
- Footer adds `COPYRIGHT`
|
||||
- UI allows users to switch email display mode `v-html` / `iframe`
|
||||
- Add `admin` account configuration page, support configuring user registration name blacklist
|
||||
|
||||
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
|
||||
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
|
||||
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
|
||||
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
|
||||
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
|
||||
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
|
||||
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
|
||||
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
|
||||
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
The prefix of the `address` table will migrate from code to db, please replace `tmp` in the sql below with your prefix, then execute.
|
||||
If your data is important, please backup your database first.
|
||||
|
||||
**Note: Replace prefix**
|
||||
|
||||
```sql
|
||||
update
|
||||
address
|
||||
set
|
||||
name = 'tmp' || name;
|
||||
```
|
||||
|
||||
### Changes
|
||||
|
||||
- Migrate the prefix of the `address` table from code to db
|
||||
- `admin` account page adds send/receive email counts
|
||||
- `admin` outbox page defaults to show all
|
||||
- `admin` send permission page supports search by address
|
||||
- `admin` email page uses split view UI
|
||||
|
||||
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
|
||||
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
|
||||
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
|
||||
|
||||
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
|
||||
|
||||
## v0.2.10
|
||||
|
||||
- `ENABLE_USER_DELETE_EMAIL` whether to allow users to delete account and emails
|
||||
- `ENABLE_AUTO_REPLY` whether to enable auto reply
|
||||
- fetchAddressError prompt improvement
|
||||
- Auto refresh shows countdown
|
||||
|
||||
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
|
||||
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
|
||||
|
||||
## v0.2.9
|
||||
|
||||
- Add rich text editor
|
||||
- Admin contact info, won't show if not configured, can configure any string `ADMIN_CONTACT = "xx@xx.xxx"`
|
||||
- Default send email balance, if not set, will be 0 `DEFAULT_SEND_BALANCE = 1`
|
||||
|
||||
## v0.2.8
|
||||
|
||||
- Allow users to delete emails
|
||||
- Admin notifies user by email when modifying send permissions
|
||||
- Send permission defaults to 1
|
||||
- Add RATE_LIMITER rate limiting for sending emails and creating new addresses
|
||||
- Some bug fixes
|
||||
|
||||
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
|
||||
- feat: request_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
|
||||
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
|
||||
- fix: delete_address not delete address_sender by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/153
|
||||
- fix: send_balance not update when click sendmail by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/155
|
||||
|
||||
## v0.2.7
|
||||
|
||||
- Added user interface installation documentation
|
||||
- Support email DKIM
|
||||
- Rate limiting configuration for `/api/new_address`
|
||||
|
||||
## v0.2.6
|
||||
|
||||
- Added admin query outbox page
|
||||
- Add admin data cleaning page
|
||||
|
||||
## 2024-04-12 v0.2.5
|
||||
|
||||
- Support send email
|
||||
|
||||
DB changes:
|
||||
|
||||
- `db/2024-04-12-patch.sql`
|
||||
|
||||
## 2024-04-10 v0.2.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- remove `ENABLE_ATTACHMENT` config
|
||||
- use rust wasm to parse email in frontend
|
||||
- deprecated api moved to `/api/v1`
|
||||
|
||||
### Rust Mail Parser
|
||||
|
||||
Due to some problems with nodejs' email parsing library, this version switches to using rust wasm to call rust's mail parsing library.
|
||||
|
||||
- Faster speed, good attachment support, can display attachment images of emails
|
||||
- Parsing supports more rfc specifications
|
||||
|
||||
### DB changes
|
||||
|
||||
The `mails` table will be discarded, and the `raw` text of the new `mail` will be directly stored in the `raw_mails` table
|
||||
|
||||
## Upgrade Step
|
||||
|
||||
```bash
|
||||
git checkout v0.2.0
|
||||
cd worker
|
||||
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
|
||||
pnpm run deploy
|
||||
cd ../frontend
|
||||
pnpm run deploy
|
||||
```
|
||||
|
||||
Note: For historical messages, use the Deploy New web page to view old data.
|
||||
|
||||
```bash
|
||||
git checkout feature/backup
|
||||
cd frontend
|
||||
# Create a new pages for accessing old data
|
||||
pnpm run deploy --project-name temp-email-v1
|
||||
```
|
||||
|
||||
## 2024-04-09 v0.0.0
|
||||
|
||||
release v0.0.0
|
||||
|
||||
## 2024-04-03
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-04-03-patch.sql`
|
||||
|
||||
Changes:
|
||||
|
||||
- add delete account
|
||||
- add admin panel search
|
||||
|
||||
## 2024-01-13
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-01-13-patch.sql`
|
||||
103
CLAUDE.md
Normal file
103
CLAUDE.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **Backend**: `worker/` — Cloudflare Workers API using Hono framework. Entry: `worker/src/worker.ts`, APIs under `worker/src/*_api/`.
|
||||
- **Frontend**: `frontend/` — Vue 3 + Naive UI app deployed to Cloudflare Pages. Routes in `frontend/src/router/`.
|
||||
- **Pages middleware**: `pages/functions/_middleware.js` — Routes API calls to Worker backend.
|
||||
- **Mail parser**: `mail-parser-wasm/` — Rust WASM email parser.
|
||||
- **SMTP/IMAP proxy**: `smtp_proxy_server/` — Python proxy server.
|
||||
- **DB schema/migrations**: `db/` — SQLite via Cloudflare D1, dated migration patches.
|
||||
- **Docs**: `vitepress-docs/` — VitePress documentation site (zh + en).
|
||||
- **E2E tests**: `e2e/` — Playwright tests in Docker Compose (API, browser, SMTP proxy).
|
||||
- **Changelogs**: `CHANGELOG.md` (中文) + `CHANGELOG_EN.md` (English).
|
||||
|
||||
## Build & Dev Commands
|
||||
|
||||
Run inside each subfolder with `pnpm`:
|
||||
|
||||
| Folder | Dev | Build | Lint | Deploy |
|
||||
|--------|-----|-------|------|--------|
|
||||
| `worker/` | `pnpm dev` | `pnpm build` | `pnpm lint` | `pnpm deploy` |
|
||||
| `frontend/` | `pnpm dev` | `pnpm build` | — | `pnpm deploy` |
|
||||
| `vitepress-docs/` | `pnpm dev` | `pnpm build` | — | — |
|
||||
| `mail-parser-wasm/` | — | `wasm-pack build --release` | — | — |
|
||||
|
||||
SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
Tests run in Docker Compose with Playwright. From `e2e/`:
|
||||
|
||||
```bash
|
||||
npm test # Build, run all tests, exit
|
||||
npm run test:down # Clean up containers
|
||||
```
|
||||
|
||||
Test categories: `tests/api/` (API tests), `tests/browser/` (UI tests with Chromium), `tests/smtp-proxy/` (SMTP/IMAP proxy tests).
|
||||
|
||||
The Docker frontend serves over **HTTPS** (self-signed cert) with Vite proxy to worker — required for WebAuthn (`navigator.credentials`) and `crypto.subtle` which need a secure context. Browser tests use `ignoreHTTPSErrors: true`.
|
||||
|
||||
Key patterns for browser tests:
|
||||
- Frontend hashes passwords with SHA-256 (`crypto.subtle`) before sending — API test registration must use pre-hashed passwords if UI login is needed.
|
||||
- VueUse `useStorage('key', '')` with string default uses **raw string** serialization — set localStorage with raw value, not `JSON.stringify()`.
|
||||
- WebAuthn browser tests use CDP virtual authenticator (`WebAuthn.enable` + `WebAuthn.addVirtualAuthenticator`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Worker Auth Flow (`worker/src/worker.ts`)
|
||||
|
||||
Three auth layers applied via Hono middleware, each using different headers:
|
||||
|
||||
| Path prefix | Header | Purpose |
|
||||
|-------------|--------|---------|
|
||||
| `/api/*` | `Authorization: Bearer <jwt>` | Address (mailbox) credential |
|
||||
| `/user_api/*` | `x-user-token` | User account JWT |
|
||||
| `/admin/*` | `x-admin-auth` | Admin password |
|
||||
| (any) | `x-user-access-token` | User role-based access token |
|
||||
| (any) | `x-custom-auth` | Optional global access password |
|
||||
| (any) | `x-lang` | Language preference (`en`/`zh`) |
|
||||
|
||||
Public endpoints (no auth): `/open_api/*`, `/user_api/login`, `/user_api/register`, `/user_api/passkey/authenticate_*`, `/user_api/oauth2/*`.
|
||||
|
||||
### Worker Email Flow (`worker/src/email/`)
|
||||
|
||||
Cloudflare Email Worker entry: `email()` in `worker/src/email/index.ts`. Processing pipeline:
|
||||
1. Parse raw email → check junk → check address exists
|
||||
2. Auto-reply if configured → forward if configured → webhook if enabled
|
||||
3. Store in D1 database
|
||||
|
||||
### Frontend State (`frontend/src/store/index.js`, `frontend/src/api/index.js`)
|
||||
|
||||
Global state via VueUse `useStorage` for persistence. The `api` module wraps axios with auto-attached auth headers and fingerprinting. API base URL comes from `VITE_API_BASE` env var (empty = same origin).
|
||||
|
||||
## Coding Style
|
||||
|
||||
- `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
|
||||
- Keep existing naming patterns: `*_api/` folders, `utils/`, `models/`.
|
||||
- ESM imports only (`type: module`).
|
||||
|
||||
## Commits & PRs
|
||||
|
||||
- Use Conventional Commits: `feat:`, `fix:`, `docs:`, `style:`, `refactor:`, `perf:`.
|
||||
- PRs should explain scope; add screenshots for UI changes.
|
||||
- Use squash merge for PRs.
|
||||
|
||||
## Post-Task Checklist (IMPORTANT)
|
||||
|
||||
After completing any feature, bug fix, or improvement, **always check**:
|
||||
|
||||
1. **CHANGELOG.md** (中文) and **CHANGELOG_EN.md** (English) — both must be updated under the current `(main)` version section with the change entry. Follow the existing format: `- feat/fix/docs: |模块| 描述`.
|
||||
2. **Documentation** — if the change involves new environment variables, new API endpoints, or configuration changes, update the corresponding docs in `vitepress-docs/docs/zh/` and `vitepress-docs/docs/en/`. Key files:
|
||||
- `guide/worker-vars.md` — Worker environment variables
|
||||
- `guide/ui/` — Frontend deployment docs
|
||||
- `guide/feature/` — Feature-specific docs
|
||||
- `api/` — API reference docs
|
||||
3. **Both languages** — docs and changelogs exist in Chinese and English; always update both.
|
||||
|
||||
## Config
|
||||
|
||||
- Worker settings in `worker/wrangler.toml` (see `wrangler.toml.template` for bindings).
|
||||
- Frontend uses `VITE_*` env vars. Don't commit secrets.
|
||||
208
README.md
208
README.md
@@ -1,77 +1,203 @@
|
||||
# 使用 cloudflare 免费服务,搭建临时邮箱
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare 临时邮箱 - 免费搭建临时邮件服务
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE">
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors">
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://discord.gg/dQEwTWhA6Q">
|
||||
<img alt="Join Discord Chat" src="https://img.shields.io/discord/1238705663623036939.svg?label=discord&logo=discord">
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub" height="30"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
</p>
|
||||
|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
|
||||
## [查看部署文档](https://temp-mail-docs.awsl.uk)
|
||||
**一个功能完整的临时邮箱服务!**
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email)
|
||||
- **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
||||
- **高性能** - Rust WASM 邮件解析,响应速度极快
|
||||
- **现代化界面** - 响应式设计,支持多语言,操作简便
|
||||
- **地址密码** - 支持为邮箱地址设置独立密码,增强安全性
|
||||
- **Agent 友好** - 内置邮箱 [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md),方便 AI agent 使用邮箱
|
||||
- **移动端管理** - 社区客户端 [CloudMail](https://github.com/Lur1N77777/CloudMail),支持 Android 管理后台和邮箱管理
|
||||
|
||||
[Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/github-action.html)
|
||||
## 部署文档 - 快速开始
|
||||
|
||||
[English Docs](https://temp-mail-docs.awsl.uk/en/)
|
||||
[部署文档](https://temp-mail-docs.awsl.uk) | [Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
|
||||
|
||||
## [CHANGELOG](CHANGELOG.md)
|
||||
<a href="https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||
</a>
|
||||
|
||||
## [在线演示](https://mail.awsl.uk/)
|
||||
## 更新日志
|
||||
|
||||
查看 [CHANGELOG](CHANGELOG.md) 了解最新更新内容。
|
||||
|
||||
## 在线体验
|
||||
|
||||
立即体验 → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>服务状态监控(点击收缩/展开)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml)       |
|
||||
| [Frontend](https://mail.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml)       |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Star History(点击收缩/展开)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
|
||||
- [查看部署文档](#查看部署文档)
|
||||
- [CHANGELOG](#changelog)
|
||||
- [在线演示](#在线演示)
|
||||
- [功能/TODO](#功能todo)
|
||||
- [Reference](#reference)
|
||||
- [Join Community](#join-community)
|
||||
</details>
|
||||
|
||||
## 功能/TODO
|
||||
<details open>
|
||||
<summary>目录(点击收缩/展开)</summary>
|
||||
|
||||
- [x] 使用 `password` 重新登录之前的邮箱
|
||||
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
|
||||
- [x] 支持多语言
|
||||
- [x] 增加访问密码,可作为私人站点
|
||||
- [x] 增加自动回复功能
|
||||
- [x] 增加查看 `附件` 功能
|
||||
- [x] 使用 `rust wasm` 解析邮件
|
||||
- [x] 支持发送邮件
|
||||
- [x] 支持 `DKIM`
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件
|
||||
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
|
||||
- [部署文档 - 快速开始](#部署文档---快速开始)
|
||||
- [更新日志](#更新日志)
|
||||
- [在线体验](#在线体验)
|
||||
- [核心功能](#核心功能)
|
||||
- [邮件处理](#邮件处理)
|
||||
- [用户管理](#用户管理)
|
||||
- [管理功能](#管理功能)
|
||||
- [多语言与界面](#多语言与界面)
|
||||
- [集成与扩展](#集成与扩展)
|
||||
- [技术架构](#技术架构)
|
||||
- [系统架构](#系统架构)
|
||||
- [技术栈](#技术栈)
|
||||
- [主要组件](#主要组件)
|
||||
- [加入社区](#加入社区)
|
||||
|
||||
</details>
|
||||
|
||||
## 核心功能
|
||||
|
||||
<details open>
|
||||
<summary>核心功能详情(点击收缩/展开)</summary>
|
||||
|
||||
### 邮件处理
|
||||
|
||||
- [x] 使用 `rust wasm` 解析邮件,解析速度快,几乎所有邮件都能解析,node 的解析模块解析邮件失败的邮件,rust wasm 也能解析成功
|
||||
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
|
||||
- [x] 支持为指定基础域名创建随机二级域名邮箱地址,更适合收件隔离场景
|
||||
- [x] 支持发送邮件,支持 `DKIM` 验证
|
||||
- [x] 支持 `SMTP` 和 `Resend` 等多种发送方式
|
||||
- [x] 增加查看 `附件` 功能,支持附件图片显示
|
||||
- [x] 支持 S3 附件存储和删除功能
|
||||
- [x] 垃圾邮件检测和黑白名单配置
|
||||
- [x] 邮件转发功能,支持全局转发地址
|
||||
|
||||
### 用户管理
|
||||
|
||||
- [x] 使用 `凭证` 重新登录之前的邮箱
|
||||
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
|
||||
- [x] `Telegram Bot` 使用,以及 `Telegram` 推送
|
||||
- [x] 支持 `OAuth2` 第三方登录(Github、Authentik 等)
|
||||
- [x] 支持 `Passkey` 无密码登录
|
||||
- [x] 用户角色管理,支持多角色域名和前缀配置
|
||||
- [x] 用户收件箱查看,支持地址和关键词过滤
|
||||
|
||||
## Reference
|
||||
### 管理功能
|
||||
|
||||
- Cloudflare D1 作为数据库
|
||||
- 使用 Cloudflare Pages 部署前端
|
||||
- 使用 Cloudflare Workers 部署后端
|
||||
- email 转发使用 Cloudflare Email Routing
|
||||
- [x] 完整的 admin 控制台
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
- [x] admin 用户管理页面,增加用户地址查看功能
|
||||
- [x] 定时清理功能,支持多种清理策略
|
||||
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
|
||||
- [x] 增加访问密码,可作为私人站点
|
||||
|
||||
## Join Community
|
||||
### 多语言与界面
|
||||
|
||||
- [x] 前后台均支持多语言
|
||||
- [x] 现代化 UI 设计,支持响应式布局
|
||||
- [x] 支持 Google Ads 集成
|
||||
- [x] 使用 shadow DOM 防止样式污染
|
||||
- [x] 支持 URL JWT 参数自动登录
|
||||
|
||||
### 集成与扩展
|
||||
|
||||
- [x] 完整的 `Telegram Bot` 支持,以及 `Telegram` 推送,Telegram Bot 小程序
|
||||
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件,`IMAP` 查看邮件
|
||||
- [x] Webhook 支持,消息推送集成
|
||||
- [x] 支持 `CF Turnstile` 人机验证
|
||||
- [x] 限流配置,防止滥用
|
||||
- [x] **Agent 友好**:内置 [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill,AI agent 可直接消费邮箱,详见 [文档](vitepress-docs/docs/zh/guide/feature/agent-email.md)
|
||||
- [x] 社区移动端管理客户端:[CloudMail](https://github.com/Lur1N77777/CloudMail) 基于 Expo / React Native,面向本项目兼容 API,提供 Android 管理员后台、地址管理、收件/发件/未知邮件、验证码快捷复制、OLED 黑主题和本地分组。
|
||||
|
||||
</details>
|
||||
|
||||
## 技术架构
|
||||
|
||||
<details>
|
||||
<summary>技术架构详情(点击收缩/展开)</summary>
|
||||
|
||||
### 系统架构
|
||||
|
||||
- **数据库**: Cloudflare D1 作为主数据库
|
||||
- **前端部署**: 使用 Cloudflare Pages 部署前端
|
||||
- **后端部署**: 使用 Cloudflare Workers 部署后端
|
||||
- **邮件转发**: 使用 Cloudflare Email Routing
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **前端**: Vue 3 + Vite + TypeScript
|
||||
- **后端**: TypeScript + Cloudflare Workers
|
||||
- **邮件解析**: Rust WASM (mail-parser-wasm)
|
||||
- **数据库**: Cloudflare D1 (SQLite)
|
||||
- **存储**: Cloudflare KV + R2 (可选 S3)
|
||||
- **代理服务**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 主要组件
|
||||
|
||||
- **Worker**: 核心后端服务
|
||||
- **Frontend**: Vue 3 用户界面
|
||||
- **Mail Parser WASM**: Rust 邮件解析模块
|
||||
- **SMTP Proxy Server**: Python 邮件代理服务
|
||||
- **Pages Functions**: Cloudflare Pages 中间件
|
||||
- **Documentation**: VitePress 文档站点
|
||||
|
||||
</details>
|
||||
|
||||
### 提醒
|
||||
|
||||
- 在Resend添加域名记录时,如果您域名解析服务商正在托管您的3级域名a.b.com,请删除Resend生成的默认name中二级域名前缀b,否则将会添加a.b.b.com,导致验证失败。添加记录后,可通过
|
||||
```bash
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
进行验证。
|
||||
|
||||
## 加入社区
|
||||
|
||||
- [Discord](https://discord.gg/dQEwTWhA6Q)
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
202
README_EN.md
Normal file
202
README_EN.md
Normal file
@@ -0,0 +1,202 @@
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare Temp Email - Free Temporary Email Service
|
||||
|
||||
<p align="center">
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub" height="30"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
</p>
|
||||
|
||||
> This project is for learning and personal use only. Please do not use it for any illegal activities, or you will be responsible for the consequences.
|
||||
|
||||
**A fully-featured temporary email service!**
|
||||
|
||||
- **Completely Free** - Built on Cloudflare's free services with zero cost
|
||||
- **High Performance** - Rust WASM email parsing for extremely fast response
|
||||
- **Modern UI** - Responsive design with multi-language support and easy operation
|
||||
- **Address Password** - Support setting individual passwords for email addresses to enhance security
|
||||
- **Agent-friendly** - Built-in mailbox [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md) for AI agents
|
||||
- **Mobile admin** - Community client [CloudMail](https://github.com/Lur1N77777/CloudMail) for Android admin and mailbox management
|
||||
|
||||
## Deployment Documentation - Quick Start
|
||||
|
||||
[Documentation](https://temp-mail-docs.awsl.uk) | [Github Action Deployment Guide](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
|
||||
|
||||
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||
</a>
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG](CHANGELOG.md) for the latest updates.
|
||||
|
||||
## Live Demo
|
||||
|
||||
Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>Service Status Monitoring (Click to expand/collapse)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml)       |
|
||||
| [Frontend](https://mail.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml)       |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Star History (Click to expand/collapse)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>Table of Contents (Click to expand/collapse)</summary>
|
||||
|
||||
- [Cloudflare Temp Email - Free Temporary Email Service](#cloudflare-temp-email---free-temporary-email-service)
|
||||
- [Deployment Documentation - Quick Start](#deployment-documentation---quick-start)
|
||||
- [Changelog](#changelog)
|
||||
- [Live Demo](#live-demo)
|
||||
- [Core Features](#core-features)
|
||||
- [Email Processing](#email-processing)
|
||||
- [User Management](#user-management)
|
||||
- [Admin Features](#admin-features)
|
||||
- [Multi-language \& Interface](#multi-language--interface)
|
||||
- [Integration \& Extensions](#integration--extensions)
|
||||
- [Technical Architecture](#technical-architecture)
|
||||
- [System Architecture](#system-architecture)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Main Components](#main-components)
|
||||
- [Join the Community](#join-the-community)
|
||||
|
||||
</details>
|
||||
|
||||
## Core Features
|
||||
|
||||
<details open>
|
||||
<summary>Core Features Details (Click to expand/collapse)</summary>
|
||||
|
||||
### Email Processing
|
||||
|
||||
- [x] Use `rust wasm` to parse emails, with fast parsing speed. Almost all emails can be parsed. Even emails that Node.js parsing modules fail to parse can be successfully parsed by rust wasm
|
||||
- [x] **AI Email Recognition** - Use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
|
||||
- [x] Support optional random second-level subdomain mailbox creation for selected base domains
|
||||
- [x] Support sending emails with `DKIM` verification
|
||||
- [x] Support multiple sending methods such as `SMTP` and `Resend`
|
||||
- [x] Add attachment viewing feature with support for displaying attachment images
|
||||
- [x] Support S3 attachment storage and deletion
|
||||
- [x] Spam detection and blacklist/whitelist configuration
|
||||
- [x] Email forwarding feature with global forwarding address support
|
||||
|
||||
### User Management
|
||||
|
||||
- [x] Use `credentials` to log in to previously used mailboxes
|
||||
- [x] Add complete user registration and login functionality. Users can bind email addresses and automatically obtain email JWT credentials to switch between different mailboxes after binding
|
||||
- [x] Support `OAuth2` third-party login (Github, Authentik, etc.)
|
||||
- [x] Support `Passkey` passwordless login
|
||||
- [x] User role management with support for multi-role domain and prefix configuration
|
||||
- [x] User inbox viewing with address and keyword filtering support
|
||||
|
||||
### Admin Features
|
||||
|
||||
- [x] Complete admin console
|
||||
- [x] Create mailboxes without prefix in `admin` backend
|
||||
- [x] Admin user management page with user address viewing feature
|
||||
- [x] Scheduled cleanup function with support for multiple cleanup strategies
|
||||
- [x] Get mailboxes with custom names, `admin` can configure blacklist
|
||||
- [x] Add access password for use as a private site
|
||||
|
||||
### Multi-language & Interface
|
||||
|
||||
- [x] Both frontend and backend support multi-language
|
||||
- [x] Modern UI design with responsive layout
|
||||
- [x] Google Ads integration support
|
||||
- [x] Use shadow DOM to prevent style pollution
|
||||
- [x] Support URL JWT parameter auto-login
|
||||
|
||||
### Integration & Extensions
|
||||
|
||||
- [x] Complete `Telegram Bot` support, `Telegram` push notifications, and Telegram Bot mini app
|
||||
- [x] Add `SMTP proxy server` supporting `SMTP` for sending emails and `IMAP` for viewing emails
|
||||
- [x] Webhook support and message push integration
|
||||
- [x] Support `CF Turnstile` CAPTCHA verification
|
||||
- [x] Rate limiting configuration to prevent abuse
|
||||
- [x] **Agent-friendly**: bundled [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill lets AI agents consume a mailbox directly, see [docs](vitepress-docs/docs/en/guide/feature/agent-email.md)
|
||||
- [x] Community mobile admin client: [CloudMail](https://github.com/Lur1N77777/CloudMail) is built with Expo / React Native for this project's compatible API, providing an Android admin console, address management, inbox/sent/unknown mail, quick verification-code copy, OLED black theme, and local grouping.
|
||||
|
||||
</details>
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
<details>
|
||||
<summary>Technical Architecture Details (Click to expand/collapse)</summary>
|
||||
|
||||
### System Architecture
|
||||
|
||||
- **Database**: Cloudflare D1 as the main database
|
||||
- **Frontend Deployment**: Deploy frontend using Cloudflare Pages
|
||||
- **Backend Deployment**: Deploy backend using Cloudflare Workers
|
||||
- **Email Routing**: Use Cloudflare Email Routing
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Frontend**: Vue 3 + Vite + TypeScript
|
||||
- **Backend**: TypeScript + Cloudflare Workers
|
||||
- **Email Parsing**: Rust WASM (mail-parser-wasm)
|
||||
- **Database**: Cloudflare D1 (SQLite)
|
||||
- **Storage**: Cloudflare KV + R2 (optional S3)
|
||||
- **Proxy Service**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
### Main Components
|
||||
|
||||
- **Worker**: Core backend service
|
||||
- **Frontend**: Vue 3 user interface
|
||||
- **Mail Parser WASM**: Rust email parsing module
|
||||
- **SMTP Proxy Server**: Python email proxy service
|
||||
- **Pages Functions**: Cloudflare Pages middleware
|
||||
- **Documentation**: VitePress documentation site
|
||||
|
||||
</details>
|
||||
|
||||
### Important Notes
|
||||
|
||||
- When adding domain records in Resend, if your DNS provider is hosting your 3rd level domain a.b.com, please remove the 2nd level domain prefix b from the default name generated by Resend, otherwise it will add a.b.b.com, causing verification to fail. After adding the record, you can verify it using:
|
||||
```bash
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
|
||||
## Join the Community
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
202
README_JA.md
Normal file
202
README_JA.md
Normal file
@@ -0,0 +1,202 @@
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare 一時メール - 無料で構築できる一時メールサービス
|
||||
|
||||
<p align="center">
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub" height="30"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
</p>
|
||||
|
||||
> 本プロジェクトは学習および個人利用のみを目的としています。違法行為には使用しないでください。使用した場合、その結果については利用者自身が責任を負うものとします。
|
||||
|
||||
**機能の充実した一時メールサービスです!**
|
||||
|
||||
- **完全無料** - Cloudflare の無料サービス上に構築され、運用コストはかかりません
|
||||
- **高性能** - Rust WASM によるメール解析で極めて高速に応答します
|
||||
- **モダンな UI** - 多言語対応のレスポンシブデザインで、簡単に操作できます
|
||||
- **アドレスパスワード** - メールアドレスごとに個別のパスワードを設定し、セキュリティを強化できます
|
||||
- **Agent フレンドリー** - AI agent がメールボックスを利用できる組み込みの [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md) を提供します
|
||||
- **モバイル管理** - Android の管理画面とメールボックス管理に対応したコミュニティクライアント [CloudMail](https://github.com/Lur1N77777/CloudMail) を利用できます
|
||||
|
||||
## デプロイドキュメント - クイックスタート
|
||||
|
||||
[ドキュメント](https://temp-mail-docs.awsl.uk) | [GitHub Actions デプロイガイド](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
|
||||
|
||||
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||
</a>
|
||||
|
||||
## 変更履歴
|
||||
|
||||
最新の更新内容については [CHANGELOG](CHANGELOG.md) を参照してください。
|
||||
|
||||
## ライブデモ
|
||||
|
||||
今すぐ試す → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>サービス稼働状況(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml)       |
|
||||
| [Frontend](https://mail.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml)       |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Star History(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>目次(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
- [Cloudflare 一時メール - 無料で構築できる一時メールサービス](#cloudflare-一時メール---無料で構築できる一時メールサービス)
|
||||
- [デプロイドキュメント - クイックスタート](#デプロイドキュメント---クイックスタート)
|
||||
- [変更履歴](#変更履歴)
|
||||
- [ライブデモ](#ライブデモ)
|
||||
- [主な機能](#主な機能)
|
||||
- [メール処理](#メール処理)
|
||||
- [ユーザー管理](#ユーザー管理)
|
||||
- [管理機能](#管理機能)
|
||||
- [多言語対応とインターフェース](#多言語対応とインターフェース)
|
||||
- [連携と拡張](#連携と拡張)
|
||||
- [技術アーキテクチャ](#技術アーキテクチャ)
|
||||
- [システム構成](#システム構成)
|
||||
- [技術スタック](#技術スタック)
|
||||
- [主要コンポーネント](#主要コンポーネント)
|
||||
- [コミュニティに参加](#コミュニティに参加)
|
||||
|
||||
</details>
|
||||
|
||||
## 主な機能
|
||||
|
||||
<details open>
|
||||
<summary>主な機能の詳細(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
### メール処理
|
||||
|
||||
- [x] `rust wasm` でメールを高速に解析します。ほぼすべてのメールを解析でき、Node.js の解析モジュールで失敗するメールも rust wasm なら正常に解析できます
|
||||
- [x] **AI メール認識** - Cloudflare Workers AI を使用して、メール内の確認コード、認証リンク、サービスリンクなどの重要情報を自動抽出します
|
||||
- [x] 指定したベースドメインに対して、ランダムな第 2 レベルサブドメインのメールボックスを任意で作成できます
|
||||
- [x] `DKIM` 検証付きのメール送信に対応します
|
||||
- [x] `SMTP` や `Resend` など、複数の送信方法に対応します
|
||||
- [x] 添付ファイルの表示機能を備え、添付画像も表示できます
|
||||
- [x] S3 での添付ファイルの保存と削除に対応します
|
||||
- [x] スパム検出とブラックリスト/ホワイトリストの設定に対応します
|
||||
- [x] グローバル転送先アドレスを指定できるメール転送機能を備えています
|
||||
|
||||
### ユーザー管理
|
||||
|
||||
- [x] `credentials` を使用して、以前利用したメールボックスへ再ログインできます
|
||||
- [x] 完全なユーザー登録・ログイン機能を備えています。メールアドレスを紐付けると、そのアドレスのメール JWT 資格情報を自動取得し、複数のメールボックスを切り替えられます
|
||||
- [x] `OAuth2` によるサードパーティログイン(Github、Authentik など)に対応します
|
||||
- [x] `Passkey` によるパスワードレスログインに対応します
|
||||
- [x] 複数ロールのドメインおよびプレフィックスを設定できるユーザーロール管理に対応します
|
||||
- [x] アドレスやキーワードで絞り込めるユーザー受信箱を提供します
|
||||
|
||||
### 管理機能
|
||||
|
||||
- [x] 完全な admin コンソールを備えています
|
||||
- [x] `admin` バックエンドからプレフィックスのないメールボックスを作成できます
|
||||
- [x] admin ユーザー管理画面でユーザーのアドレスを確認できます
|
||||
- [x] 複数のクリーンアップ戦略を選べる定期クリーンアップ機能を備えています
|
||||
- [x] カスタム名のメールボックスを取得でき、`admin` でブラックリストを設定できます
|
||||
- [x] アクセスパスワードを追加し、プライベートサイトとして利用できます
|
||||
|
||||
### 多言語対応とインターフェース
|
||||
|
||||
- [x] フロントエンドとバックエンドの両方が多言語に対応しています
|
||||
- [x] レスポンシブレイアウトのモダンな UI デザインを採用しています
|
||||
- [x] Google Ads の連携に対応します
|
||||
- [x] shadow DOM を使用してスタイルの干渉を防ぎます
|
||||
- [x] URL の JWT パラメーターによる自動ログインに対応します
|
||||
|
||||
### 連携と拡張
|
||||
|
||||
- [x] 完全な `Telegram Bot`、`Telegram` プッシュ通知、Telegram Bot ミニアプリに対応します
|
||||
- [x] `SMTP proxy server` を追加し、`SMTP` によるメール送信と `IMAP` によるメール閲覧に対応します
|
||||
- [x] Webhook とメッセージプッシュ連携に対応します
|
||||
- [x] `CF Turnstile` CAPTCHA 検証に対応します
|
||||
- [x] 悪用を防ぐためのレート制限を設定できます
|
||||
- [x] **Agent フレンドリー**:組み込みの [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill により、AI agent がメールボックスを直接利用できます。詳しくは[ドキュメント](vitepress-docs/docs/en/guide/feature/agent-email.md)を参照してください
|
||||
- [x] コミュニティのモバイル管理クライアント:[CloudMail](https://github.com/Lur1N77777/CloudMail) は本プロジェクト互換の API 向けに Expo / React Native で構築されており、Android 管理コンソール、アドレス管理、受信済み/送信済み/不明メール、確認コードのクイックコピー、OLED ブラックテーマ、ローカルグループ分けを提供します
|
||||
|
||||
</details>
|
||||
|
||||
## 技術アーキテクチャ
|
||||
|
||||
<details>
|
||||
<summary>技術アーキテクチャの詳細(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
### システム構成
|
||||
|
||||
- **データベース**:メインデータベースとして Cloudflare D1 を使用
|
||||
- **フロントエンドのデプロイ**:Cloudflare Pages を使用してフロントエンドをデプロイ
|
||||
- **バックエンドのデプロイ**:Cloudflare Workers を使用してバックエンドをデプロイ
|
||||
- **メールルーティング**:Cloudflare Email Routing を使用
|
||||
|
||||
### 技術スタック
|
||||
|
||||
- **フロントエンド**:Vue 3 + Vite + TypeScript
|
||||
- **バックエンド**:TypeScript + Cloudflare Workers
|
||||
- **メール解析**:Rust WASM(mail-parser-wasm)
|
||||
- **データベース**:Cloudflare D1(SQLite)
|
||||
- **ストレージ**:Cloudflare KV + R2(任意で S3)
|
||||
- **プロキシサービス**:Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 主要コンポーネント
|
||||
|
||||
- **Worker**:中核となるバックエンドサービス
|
||||
- **Frontend**:Vue 3 ユーザーインターフェース
|
||||
- **Mail Parser WASM**:Rust メール解析モジュール
|
||||
- **SMTP Proxy Server**:Python メールプロキシサービス
|
||||
- **Pages Functions**:Cloudflare Pages ミドルウェア
|
||||
- **Documentation**:VitePress ドキュメントサイト
|
||||
|
||||
</details>
|
||||
|
||||
### 重要な注意事項
|
||||
|
||||
- Resend でドメインレコードを追加する際、DNS プロバイダーが第 3 レベルドメイン a.b.com をホストしている場合は、Resend が生成したデフォルト名から第 2 レベルドメインのプレフィックス b を削除してください。削除しないと a.b.b.com が追加され、検証に失敗します。レコードを追加した後、次のコマンドで確認できます。
|
||||
```bash
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
|
||||
## コミュニティに参加
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
9
db/2024-07-14-patch.sql
Normal file
9
db/2024-07-14-patch.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER UNIQUE NOT NULL,
|
||||
role_text TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
|
||||
14
db/2024-08-10-patch.sql
Normal file
14
db/2024-08-10-patch.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS user_passkeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
passkey_name TEXT NOT NULL,
|
||||
passkey_id TEXT NOT NULL,
|
||||
passkey TEXT NOT NULL,
|
||||
counter INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
||||
4
db/2025-09-23-patch.sql
Normal file
4
db/2025-09-23-patch.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE
|
||||
address
|
||||
ADD
|
||||
password TEXT;
|
||||
4
db/2025-12-06-metadata.sql
Normal file
4
db/2025-12-06-metadata.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add metadata column to raw_mails table for storing AI extraction results and other metadata
|
||||
-- This column stores JSON data with flexible schema for various analysis results
|
||||
|
||||
ALTER TABLE raw_mails ADD COLUMN metadata TEXT;
|
||||
4
db/2025-12-15-message-id-index.sql
Normal file
4
db/2025-12-15-message-id-index.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add index on message_id column in raw_mails table
|
||||
-- This index improves performance for queries filtering/updating by message_id
|
||||
-- Example: UPDATE raw_mails SET metadata = ? WHERE message_id = ?
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
8
db/2025-12-27-source-meta.sql
Normal file
8
db/2025-12-27-source-meta.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Add source_meta column to address table for tracking address creation source
|
||||
-- For web: stores IP address (e.g., "192.168.1.1") or "web:unknown" as fallback
|
||||
-- For telegram: stores "tg:{userId}" (e.g., "tg:123456789")
|
||||
-- For admin: stores "admin"
|
||||
|
||||
ALTER TABLE address ADD COLUMN source_meta TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
|
||||
2
db/2026-04-03-raw-blob.sql
Normal file
2
db/2026-04-03-raw-blob.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add raw_blob BLOB column for gzip-compressed email storage
|
||||
ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;
|
||||
@@ -1,35 +1,37 @@
|
||||
CREATE TABLE IF NOT EXISTS mails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
message_id TEXT,
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
subject TEXT,
|
||||
message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mails_address ON mails(address);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
message_id TEXT,
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
raw TEXT,
|
||||
raw_blob BLOB,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
password TEXT,
|
||||
source_meta TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auto_reply_mails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_prefix TEXT,
|
||||
@@ -43,15 +45,6 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auto_reply_mails_address ON auto_reply_mails(address);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
message_id TEXT,
|
||||
data TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address_sender (
|
||||
id INTEGER PRIMARY KEY,
|
||||
address TEXT UNIQUE,
|
||||
@@ -71,6 +64,8 @@ CREATE TABLE IF NOT EXISTS sendbox (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
@@ -99,3 +94,28 @@ CREATE TABLE IF NOT EXISTS users_address (
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER UNIQUE NOT NULL,
|
||||
role_text TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_passkeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
passkey_name TEXT NOT NULL,
|
||||
passkey_id TEXT NOT NULL,
|
||||
passkey TEXT NOT NULL,
|
||||
counter INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
||||
|
||||
13
e2e/Dockerfile.e2e
Normal file
13
e2e/Dockerfile.e2e
Normal file
@@ -0,0 +1,13 @@
|
||||
# Keep this version in sync with @playwright/test in package.json
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
|
||||
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app/e2e
|
||||
|
||||
COPY e2e/package.json e2e/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY e2e/ .
|
||||
|
||||
ENTRYPOINT ["/app/e2e/scripts/docker-entrypoint.sh"]
|
||||
47
e2e/Dockerfile.frontend
Normal file
47
e2e/Dockerfile.frontend
Normal file
@@ -0,0 +1,47 @@
|
||||
FROM node:24-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
||||
|
||||
COPY frontend/ .
|
||||
|
||||
# Generate self-signed cert for HTTPS (required for WebAuthn/crypto.subtle)
|
||||
RUN openssl req -x509 -newkey rsa:2048 -keyout /tmp/key.pem -out /tmp/cert.pem \
|
||||
-days 365 -nodes -subj '/CN=frontend'
|
||||
|
||||
# Allow Docker internal hostnames (e.g. "frontend") to pass Vite's host check.
|
||||
# Configure HTTPS with self-signed cert for secure context (WebAuthn/crypto.subtle).
|
||||
# Proxy API paths to the worker to avoid mixed-content (HTTPS->HTTP) blocking.
|
||||
RUN mv vite.config.js vite.config.original.js && \
|
||||
echo 'import config from "./vite.config.original.js";\
|
||||
import fs from "fs";\
|
||||
const workerTarget = process.env.VITE_WORKER_URL || "http://worker:8787";\
|
||||
config.server = {\
|
||||
...config.server,\
|
||||
allowedHosts: true,\
|
||||
https: {\
|
||||
key: fs.readFileSync("/tmp/key.pem"),\
|
||||
cert: fs.readFileSync("/tmp/cert.pem"),\
|
||||
},\
|
||||
proxy: {\
|
||||
"/api": { target: workerTarget, changeOrigin: true },\
|
||||
"/admin": { target: workerTarget, changeOrigin: true },\
|
||||
"/user_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/open_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/external": { target: workerTarget, changeOrigin: true },\
|
||||
"/health_check": { target: workerTarget, changeOrigin: true },\
|
||||
},\
|
||||
};\
|
||||
export default config;' > vite.config.js
|
||||
|
||||
# Empty VITE_API_BASE so frontend uses same-origin (proxied through Vite)
|
||||
ENV VITE_API_BASE=
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["pnpm", "exec", "vite", "--port", "5173", "--host", "0.0.0.0"]
|
||||
19
e2e/Dockerfile.worker
Normal file
19
e2e/Dockerfile.worker
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:24-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
WORKDIR /app/worker
|
||||
|
||||
COPY worker/package.json worker/pnpm-lock.yaml ./
|
||||
COPY worker/patches/ patches/
|
||||
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
||||
|
||||
COPY worker/src/ src/
|
||||
COPY worker/tsconfig.json ./
|
||||
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
||||
COPY ${WRANGLER_TOML} wrangler.toml
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
|
||||
57
e2e/README.md
Normal file
57
e2e/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# E2E Tests
|
||||
|
||||
End-to-end tests for Cloudflare Temp Email using [Playwright](https://playwright.dev/) and [Mailpit](https://mailpit.axllent.org/), fully containerized with Docker Compose.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** and **Docker Compose**
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
|
||||
# Build, start all services, run tests, and exit
|
||||
npm test
|
||||
|
||||
# Clean up containers and volumes
|
||||
npm run test:down
|
||||
```
|
||||
|
||||
`npm test` runs `docker compose up --build`, which:
|
||||
1. Starts **Mailpit** (SMTP on :1025, HTTP API on :8025)
|
||||
2. Builds and starts the **Worker** (wrangler dev on :8787)
|
||||
3. Builds and starts the **Frontend** (vite dev on :5173)
|
||||
4. Builds and runs the **E2E runner** (Playwright), which waits for services, initializes the DB, and runs all tests
|
||||
|
||||
The exit code reflects the test result.
|
||||
|
||||
## Test Structure
|
||||
|
||||
| Project | Directory | What it tests |
|
||||
|---------|-----------|---------------|
|
||||
| `api` | `tests/api/` | Worker API endpoints — health check, address CRUD, send mail via SMTP |
|
||||
| `browser` | `tests/browser/` | Frontend UI — login, inbox view, reply with HTML, XSS sanitization |
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Container | Port | Purpose |
|
||||
|---------|-----------|------|---------|
|
||||
| Mailpit SMTP | `mailpit` | 1025 | Captures outgoing emails |
|
||||
| Mailpit HTTP | `mailpit` | 8025 | API to verify captured emails |
|
||||
| Worker | `worker` | 8787 | Backend API with E2E config |
|
||||
| Frontend | `frontend` | 5173 | Vue frontend dev server |
|
||||
|
||||
## Test Results
|
||||
|
||||
Test results and HTML reports are exported via volumes:
|
||||
- `e2e/test-results/` — test artifacts
|
||||
- `e2e/playwright-report/` — HTML report
|
||||
|
||||
## Configuration
|
||||
|
||||
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
|
||||
- `E2E_TEST_MODE = true` — enables test seed endpoint
|
||||
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
|
||||
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
|
||||
- SMTP pointed at Mailpit container (`mailpit:1025`)
|
||||
178
e2e/docker-compose.yml
Normal file
178
e2e/docker-compose.yml
Normal file
@@ -0,0 +1,178 @@
|
||||
services:
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.29
|
||||
ports:
|
||||
- "1025:1025"
|
||||
- "8025:8025"
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8787:8787"
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8787/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-subdomain:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8789:8789"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8789", "--ip", "0.0.0.0"]
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8789/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-env-off:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8790:8790"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
|
||||
volumes:
|
||||
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8790/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-gzip:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
args:
|
||||
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.gzip
|
||||
ports:
|
||||
- "8788:8788"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8788", "--ip", "0.0.0.0"]
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8788/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-send-mail-domain:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
args:
|
||||
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.send-mail-domain
|
||||
ports:
|
||||
- "8791:8791"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8791", "--ip", "0.0.0.0"]
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8791/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
|
||||
smtp-proxy:
|
||||
build:
|
||||
context: ../smtp_proxy_server
|
||||
dockerfile: dockerfile
|
||||
ports:
|
||||
- "11025:8025"
|
||||
- "11143:11143"
|
||||
environment:
|
||||
PROXY_URL: http://worker:8787
|
||||
PORT: "8025"
|
||||
IMAP_PORT: "11143"
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
|
||||
smtp-proxy-tls:
|
||||
build:
|
||||
context: ../smtp_proxy_server
|
||||
dockerfile: dockerfile
|
||||
ports:
|
||||
- "11026:8026"
|
||||
- "11144:11144"
|
||||
environment:
|
||||
PROXY_URL: http://worker:8787
|
||||
PORT: "8026"
|
||||
IMAP_PORT: "11144"
|
||||
smtp_tls_cert: /certs/cert.pem
|
||||
smtp_tls_key: /certs/key.pem
|
||||
imap_tls_cert: /certs/cert.pem
|
||||
imap_tls_key: /certs/key.pem
|
||||
entrypoint: ["/bin/bash", "/e2e-scripts/smtp-tls-entrypoint.sh"]
|
||||
volumes:
|
||||
- ./scripts:/e2e-scripts:ro
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
|
||||
e2e-runner:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.e2e
|
||||
environment:
|
||||
WORKER_URL: http://worker:8787
|
||||
WORKER_URL_SUBDOMAIN: http://worker-subdomain:8789
|
||||
WORKER_URL_ENV_OFF: http://worker-env-off:8790
|
||||
WORKER_GZIP_URL: http://worker-gzip:8788
|
||||
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
|
||||
FRONTEND_URL: https://frontend:5173
|
||||
MAILPIT_API: http://mailpit:8025/api
|
||||
SMTP_PROXY_HOST: smtp-proxy
|
||||
SMTP_PROXY_SMTP_PORT: "8025"
|
||||
SMTP_PROXY_IMAP_PORT: "11143"
|
||||
SMTP_PROXY_TLS_HOST: smtp-proxy-tls
|
||||
SMTP_PROXY_TLS_SMTP_PORT: "8026"
|
||||
SMTP_PROXY_TLS_IMAP_PORT: "11144"
|
||||
CI: "true"
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
worker-subdomain:
|
||||
condition: service_healthy
|
||||
worker-env-off:
|
||||
condition: service_healthy
|
||||
worker-gzip:
|
||||
condition: service_healthy
|
||||
worker-send-mail-domain:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
smtp-proxy:
|
||||
condition: service_started
|
||||
smtp-proxy-tls:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./test-results:/app/e2e/test-results
|
||||
- ./playwright-report:/app/e2e/playwright-report
|
||||
279
e2e/fixtures/test-helpers.ts
Normal file
279
e2e/fixtures/test-helpers.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { createHash } from 'crypto';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export const WORKER_URL = process.env.WORKER_URL!;
|
||||
export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
|
||||
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
|
||||
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
|
||||
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || '';
|
||||
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||
export const TEST_DOMAIN = 'test.example.com';
|
||||
|
||||
/**
|
||||
* SHA-256 hash matching the frontend hashPassword utility.
|
||||
*/
|
||||
export function hashPassword(password: string): string {
|
||||
return createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new email address via the worker API.
|
||||
* Appends a timestamp suffix to avoid UNIQUE constraint collisions
|
||||
* with persistent D1 data from previous test runs.
|
||||
* Returns the JWT and full address string.
|
||||
*/
|
||||
export async function createTestAddress(
|
||||
ctx: APIRequestContext,
|
||||
name: string,
|
||||
domain: string = TEST_DOMAIN
|
||||
): Promise<{ jwt: string; address: string; address_id: number }> {
|
||||
const uniqueName = `${name}${Date.now()}`;
|
||||
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: uniqueName, domain },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a test email by exercising the real worker email() handler
|
||||
* via the admin test endpoint.
|
||||
*/
|
||||
export async function seedTestMail(
|
||||
ctx: APIRequestContext,
|
||||
address: string,
|
||||
opts: { subject?: string; html?: string; text?: string; from?: string }
|
||||
): Promise<void> {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Test Email';
|
||||
const boundary = `----E2E${Date.now()}`;
|
||||
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
|
||||
const textPart = opts.text || 'Hello from E2E';
|
||||
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
textPart,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/html; charset=utf-8`,
|
||||
``,
|
||||
htmlPart,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (!body.success) {
|
||||
throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a mail via admin/send_mail, which saves to sendbox.
|
||||
*/
|
||||
export async function sendTestMail(
|
||||
ctx: APIRequestContext,
|
||||
fromAddress: string,
|
||||
opts: { to_mail: string; subject?: string; content?: string; is_html?: boolean }
|
||||
): Promise<void> {
|
||||
const res = await ctx.post(`${WORKER_URL}/admin/send_mail`, {
|
||||
data: {
|
||||
from_name: '',
|
||||
from_mail: fromAddress,
|
||||
to_name: '',
|
||||
to_mail: opts.to_mail,
|
||||
subject: opts.subject || 'Test Sent Mail',
|
||||
content: opts.content || 'Sent mail body from E2E',
|
||||
is_html: opts.is_html ?? false,
|
||||
},
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to send mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all messages in Mailpit.
|
||||
*/
|
||||
export async function deleteAllMailpitMessages(ctx: APIRequestContext) {
|
||||
const res = await ctx.delete(`${MAILPIT_API}/v1/messages`);
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to delete Mailpit messages: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Mailpit WebSocket URL from the REST API URL.
|
||||
* MAILPIT_API is like "http://mailpit:8025/api" → ws://mailpit:8025/api/events
|
||||
*/
|
||||
function mailpitWsUrl(): string {
|
||||
return MAILPIT_API.replace(/^http/, 'ws') + '/events';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a message matching `predicate` to arrive in Mailpit.
|
||||
*
|
||||
* Connects to Mailpit's WebSocket `/api/events` and listens for
|
||||
* `Type: "new"` events. When a matching message arrives, resolves
|
||||
* immediately — no polling, no arbitrary sleeps.
|
||||
*
|
||||
* Returns `{ ready, message }`:
|
||||
* - `ready` resolves when the WebSocket connection is open
|
||||
* - `message` resolves with the matched message summary
|
||||
*
|
||||
* Usage: await ready before triggering the send to avoid race conditions.
|
||||
*/
|
||||
export function onMailpitMessage(
|
||||
predicate: (msg: any) => boolean,
|
||||
{ timeout = 10_000 }: { timeout?: number } = {}
|
||||
): { ready: Promise<void>; message: Promise<any> } {
|
||||
let readyResolve: () => void;
|
||||
let readyReject: (err: Error) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
readyResolve = resolve;
|
||||
readyReject = reject;
|
||||
});
|
||||
|
||||
const message = new Promise<any>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const ws = new WebSocket(mailpitWsUrl());
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
if (!settled) { settled = true; reject(new Error('Mailpit message not received within timeout')); }
|
||||
}, timeout);
|
||||
|
||||
ws.on('open', () => readyResolve());
|
||||
|
||||
ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const event = JSON.parse(data.toString());
|
||||
if (event.Type === 'new' && predicate(event.Data)) {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
if (!settled) { settled = true; resolve(event.Data); }
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
if (!settled) { settled = true; reject(new Error('Mailpit WebSocket closed before matching message')); }
|
||||
});
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
readyReject(err);
|
||||
if (!settled) { settled = true; reject(err); }
|
||||
});
|
||||
});
|
||||
|
||||
return { ready, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Request send mail access for an address.
|
||||
* Kept for backward compatibility and manual-request flows. When
|
||||
* DEFAULT_SEND_BALANCE > 0, send balance may already be auto-initialized
|
||||
* before this endpoint is called.
|
||||
*/
|
||||
export async function requestSendAccess(
|
||||
ctx: APIRequestContext,
|
||||
jwt: string
|
||||
): Promise<void> {
|
||||
const res = await ctx.post(`${WORKER_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to request send access: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the sender access row for an address from the admin API.
|
||||
*/
|
||||
export async function getAddressSender(
|
||||
ctx: APIRequestContext,
|
||||
address: string,
|
||||
workerUrl: string = WORKER_URL
|
||||
): Promise<any> {
|
||||
const res = await ctx.get(
|
||||
`${workerUrl}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address)}`,
|
||||
);
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to get address sender: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (!Array.isArray(body.results) || body.results.length < 1) {
|
||||
throw new Error(`address_sender row not found for ${address}`);
|
||||
}
|
||||
return body.results[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a sender access row through the admin API.
|
||||
*/
|
||||
export async function updateAddressSender(
|
||||
ctx: APIRequestContext,
|
||||
opts: {
|
||||
address: string;
|
||||
address_id: number;
|
||||
balance: number;
|
||||
enabled: boolean;
|
||||
},
|
||||
workerUrl: string = WORKER_URL
|
||||
): Promise<void> {
|
||||
const res = await ctx.post(`${workerUrl}/admin/address_sender`, {
|
||||
data: opts,
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to update address sender: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a sender access row through the admin API by its id.
|
||||
*/
|
||||
export async function deleteAddressSender(
|
||||
ctx: APIRequestContext,
|
||||
id: number,
|
||||
workerUrl: string = WORKER_URL
|
||||
): Promise<void> {
|
||||
const res = await ctx.delete(`${workerUrl}/admin/address_sender/${id}`);
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to delete address sender: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a test address via its JWT.
|
||||
*/
|
||||
export async function deleteAddress(
|
||||
ctx: APIRequestContext,
|
||||
jwt: string
|
||||
): Promise<void> {
|
||||
const res = await ctx.delete(`${WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to delete address: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
41
e2e/fixtures/wrangler.toml.e2e
Normal file
41
e2e/fixtures/wrangler.toml.e2e
Normal file
@@ -0,0 +1,41 @@
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "TMP"
|
||||
DEFAULT_DOMAINS = []
|
||||
DOMAINS = ["TEST.EXAMPLE.COM", "MANUAL.EXAMPLE.COM"]
|
||||
RANDOM_SUBDOMAIN_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = true
|
||||
USER_ROLES = [
|
||||
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
|
||||
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
||||
]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
NO_LIMIT_SEND_ROLE = "case-role"
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
CLEANUP_BATCH_SIZE = 10
|
||||
SMTP_CONFIG = """
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email"
|
||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||
34
e2e/fixtures/wrangler.toml.e2e.env-off
Normal file
34
e2e/fixtures/wrangler.toml.e2e.env-off
Normal file
@@ -0,0 +1,34 @@
|
||||
name = "cloudflare_temp_email_env_off"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false
|
||||
JWT_SECRET = "e2e-test-secret-key-env-off"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = false
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-env-off"
|
||||
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
||||
34
e2e/fixtures/wrangler.toml.e2e.gzip
Normal file
34
e2e/fixtures/wrangler.toml.e2e.gzip
Normal file
@@ -0,0 +1,34 @@
|
||||
name = "cloudflare_temp_email_gzip"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
ENABLE_MAIL_GZIP = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-gzip"
|
||||
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
|
||||
38
e2e/fixtures/wrangler.toml.e2e.send-mail-domain
Normal file
38
e2e/fixtures/wrangler.toml.e2e.send-mail-domain
Normal file
@@ -0,0 +1,38 @@
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
send_email = [
|
||||
{ name = "SEND_MAIL" },
|
||||
]
|
||||
|
||||
[vars]
|
||||
PREFIX = "TMP"
|
||||
DEFAULT_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
SEND_MAIL_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email"
|
||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||
403
e2e/package-lock.json
generated
Normal file
403
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,403 @@
|
||||
{
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.7.1",
|
||||
"nodemailer": "^9.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.15.tgz",
|
||||
"integrity": "sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.4.2",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.1.tgz",
|
||||
"integrity": "sha512-etpyRH7wOPQGawjl/5ChgaaX5NRK2fxJAi9EFFNwv0N3FXsvjsL3Qu5dJ4i/myH61Cq2RSTb6vI5T9rXDbXD+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.15",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.4.2",
|
||||
"libqp": "2.1.1",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.9"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.2.tgz",
|
||||
"integrity": "sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
|
||||
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream/node_modules/real-require": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
e2e/package.json
Normal file
19
e2e/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
|
||||
"test:down": "docker compose down -v"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.7.1",
|
||||
"nodemailer": "^9.0.5"
|
||||
}
|
||||
}
|
||||
45
e2e/playwright.config.ts
Normal file
45
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const WORKER_BASE = process.env.WORKER_URL!;
|
||||
const WORKER_GZIP_BASE = process.env.WORKER_GZIP_URL || '';
|
||||
const FRONTEND_BASE = process.env.FRONTEND_URL!;
|
||||
|
||||
export default defineConfig({
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['html', { open: 'never' }]],
|
||||
projects: [
|
||||
{
|
||||
name: 'api',
|
||||
testDir: './tests/api',
|
||||
use: {
|
||||
baseURL: WORKER_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'api-gzip',
|
||||
testDir: './tests/api-gzip',
|
||||
use: {
|
||||
baseURL: WORKER_GZIP_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtp-proxy',
|
||||
testDir: './tests/smtp-proxy',
|
||||
use: {
|
||||
baseURL: WORKER_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser',
|
||||
testDir: './tests/browser',
|
||||
use: {
|
||||
baseURL: FRONTEND_BASE,
|
||||
...devices['Desktop Chrome'],
|
||||
// Accept self-signed cert from Docker frontend (HTTPS for WebAuthn)
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
114
e2e/scripts/docker-entrypoint.sh
Executable file
114
e2e/scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "==> Waiting for worker at $WORKER_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_URL/health_check" > /dev/null 2>&1; then
|
||||
echo " Worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
|
||||
echo "==> Waiting for subdomain worker at $WORKER_URL_SUBDOMAIN ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_URL_SUBDOMAIN/health_check" > /dev/null 2>&1; then
|
||||
echo " Subdomain worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Subdomain worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
|
||||
echo "==> Waiting for env-off worker at $WORKER_URL_ENV_OFF ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_URL_ENV_OFF/health_check" > /dev/null 2>&1; then
|
||||
echo " Env-off worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Env-off worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
||||
echo "==> Waiting for worker-gzip at $WORKER_GZIP_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_GZIP_URL/health_check" > /dev/null 2>&1; then
|
||||
echo " Worker-gzip ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Worker-gzip not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
||||
echo " Frontend ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Frontend not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
|
||||
for i in $(seq 1 30); do
|
||||
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
|
||||
echo " smtp-proxy-tls SMTP ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "WARNING: smtp-proxy-tls SMTP not ready after 30s, continuing anyway"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> Initializing database"
|
||||
curl -sf -X POST "$WORKER_URL/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_URL/admin/db_migration" > /dev/null
|
||||
echo " Database initialized"
|
||||
|
||||
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
|
||||
echo "==> Initializing subdomain worker database"
|
||||
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_migration" > /dev/null
|
||||
echo " Subdomain worker database initialized"
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
|
||||
echo "==> Initializing env-off worker database"
|
||||
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_migration" > /dev/null
|
||||
echo " Env-off database initialized"
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
||||
echo "==> Initializing gzip worker database"
|
||||
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_migration" > /dev/null
|
||||
echo " Gzip worker database initialized"
|
||||
fi
|
||||
|
||||
echo "==> Running Playwright tests"
|
||||
exec npx playwright test "$@"
|
||||
15
e2e/scripts/smtp-tls-entrypoint.sh
Executable file
15
e2e/scripts/smtp-tls-entrypoint.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CERT_DIR="/certs"
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
if [ ! -f "$CERT_DIR/cert.pem" ] || [ ! -f "$CERT_DIR/key.pem" ]; then
|
||||
echo "==> Generating self-signed TLS certificate"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" \
|
||||
-days 1 -subj "/CN=smtp-proxy-tls"
|
||||
echo " Certificate generated"
|
||||
fi
|
||||
|
||||
exec python3 main.py
|
||||
270
e2e/tests/api-gzip/mail-gzip.spec.ts
Normal file
270
e2e/tests/api-gzip/mail-gzip.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_GZIP_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
/**
|
||||
* These tests run against a worker instance with ENABLE_MAIL_GZIP=true.
|
||||
* They verify gzip-compressed storage and backward-compatible reading.
|
||||
*/
|
||||
|
||||
// Helper: create address on the gzip worker
|
||||
async function createGzipAddress(ctx: any, name: string) {
|
||||
const uniqueName = `${name}${Date.now()}`;
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/api/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
|
||||
}
|
||||
|
||||
// Helper: seed mail via receiveMail (goes through email() handler → gzip compression)
|
||||
async function receiveGzipMail(
|
||||
ctx: any, address: string,
|
||||
opts: { subject?: string; html?: string; text?: string; from?: string }
|
||||
) {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Test Email';
|
||||
const boundary = `----E2E${Date.now()}`;
|
||||
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
|
||||
const textPart = opts.text || 'Hello from E2E';
|
||||
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
textPart,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/html; charset=utf-8`,
|
||||
``,
|
||||
htmlPart,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
if (!body.success) throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
|
||||
}
|
||||
|
||||
// Helper: seed mail via seedMail (direct INSERT → plaintext raw, no gzip)
|
||||
async function seedPlaintextMail(
|
||||
ctx: any, address: string,
|
||||
opts: { subject?: string; text?: string; from?: string }
|
||||
) {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Plaintext Mail';
|
||||
const messageId = `<e2e-plain-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
opts.text || 'Hello plaintext from E2E',
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
|
||||
data: { address, source: from, raw, message_id: messageId },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Helper: delete address on gzip worker
|
||||
async function deleteGzipAddress(ctx: any, jwt: string) {
|
||||
await ctx.delete(`${WORKER_GZIP_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Mail Gzip Storage', () => {
|
||||
test.beforeEach(() => {
|
||||
test.skip(!WORKER_GZIP_URL, 'WORKER_GZIP_URL not set — skipping gzip tests');
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable in list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-list');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip List Test',
|
||||
text: 'compressed content here',
|
||||
});
|
||||
|
||||
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].raw).toContain('Gzip List Test');
|
||||
expect(results[0].raw).toContain('compressed content here');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable in detail', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-detail');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip Detail Test',
|
||||
html: '<b>bold gzip</b>',
|
||||
});
|
||||
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${mailId}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.raw).toContain('Gzip Detail Test');
|
||||
expect(mail.raw).toContain('<b>bold gzip</b>');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable through admin detail API', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-detail');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip Admin Detail Test',
|
||||
text: 'admin compressed content',
|
||||
});
|
||||
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/admin/mails/${mailId}`, {
|
||||
headers: { 'x-admin-auth': 'e2e-admin-pass' },
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.raw).toContain('Gzip Admin Detail Test');
|
||||
expect(mail.raw).toContain('admin compressed content');
|
||||
expect(mail.raw_blob).toBeUndefined();
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
|
||||
try {
|
||||
// 1. Direct INSERT plaintext (simulates pre-gzip data)
|
||||
await seedPlaintextMail(request, address, {
|
||||
subject: 'Old Plaintext Mail',
|
||||
text: 'legacy plain content',
|
||||
});
|
||||
|
||||
// 2. receiveMail → goes through email() handler → gzip compressed
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'New Gzip Mail',
|
||||
text: 'new compressed content',
|
||||
});
|
||||
|
||||
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
// Both mails should have readable raw content
|
||||
const subjects = results.map((r: any) => r.raw);
|
||||
expect(subjects.some((r: string) => r.includes('Old Plaintext Mail'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('New Gzip Mail'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('legacy plain content'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('new compressed content'))).toBe(true);
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin internal mail (sendAdminInternalMail) is gzip-compressed and readable', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-mail');
|
||||
try {
|
||||
// 1. Request send access → creates address_sender row
|
||||
const reqAccessRes = await request.post(`${WORKER_GZIP_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(reqAccessRes.ok()).toBe(true);
|
||||
|
||||
// 2. Get address_sender id
|
||||
const senderListRes = await request.get(
|
||||
`${WORKER_GZIP_URL}/admin/address_sender?limit=10&offset=0&address=${encodeURIComponent(address)}`,
|
||||
);
|
||||
expect(senderListRes.ok()).toBe(true);
|
||||
const senderList = await senderListRes.json();
|
||||
expect(senderList.results.length).toBeGreaterThanOrEqual(1);
|
||||
const senderId = senderList.results[0].id;
|
||||
|
||||
// 3. Update send access via admin API → triggers sendAdminInternalMail
|
||||
const updateRes = await request.post(`${WORKER_GZIP_URL}/admin/address_sender`, {
|
||||
data: { address, address_id: senderId, balance: 99, enabled: true },
|
||||
});
|
||||
expect(updateRes.ok()).toBe(true);
|
||||
|
||||
// 4. Verify the internal mail is readable
|
||||
const mailsRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const { results } = await mailsRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// mimetext base64-encodes the Subject header, so match on body content instead
|
||||
const internalMail = results.find((m: any) => m.raw?.includes('balance: 99'));
|
||||
expect(internalMail).toBeDefined();
|
||||
expect(internalMail.raw).toContain('admin@internal');
|
||||
expect(internalMail.raw).toContain('balance: 99');
|
||||
expect(internalMail).not.toHaveProperty('raw_blob');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('raw_blob field is not exposed in API response', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-noblob');
|
||||
try {
|
||||
await receiveGzipMail(request, address, { subject: 'No Blob Leak' });
|
||||
|
||||
// Check list response
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0]).not.toHaveProperty('raw_blob');
|
||||
|
||||
// Check detail response
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${results[0].id}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const mail = await detailRes.json();
|
||||
expect(mail).not.toHaveProperty('raw_blob');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
79
e2e/tests/api/address-activity-throttle.spec.ts
Normal file
79
e2e/tests/api/address-activity-throttle.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const waitForNextTimestamp = () => new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
|
||||
test.describe('Address activity throttling', () => {
|
||||
test('does not rewrite recently active addresses from user settings', async ({ request }) => {
|
||||
const email = `activity-throttle-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const address = await createTestAddress(request, 'activity-throttle');
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { enable: true, enableMailVerify: false },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
userId = payload.user_id;
|
||||
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${address.jwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
|
||||
const beforeRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(beforeRes.ok()).toBe(true);
|
||||
const before = await beforeRes.json();
|
||||
const initialUpdatedAt = before.results.find(
|
||||
(row: { name: string }) => row.name === address.address,
|
||||
)?.updated_at;
|
||||
expect(initialUpdatedAt).toBeTruthy();
|
||||
|
||||
await waitForNextTimestamp();
|
||||
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(userSettingsRes.ok()).toBe(true);
|
||||
await waitForNextTimestamp();
|
||||
|
||||
const afterRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
const updatedAt = after.results.find(
|
||||
(row: { name: string }) => row.name === address.address,
|
||||
)?.updated_at;
|
||||
expect(updatedAt).toBe(initialUpdatedAt);
|
||||
} finally {
|
||||
await deleteAddress(request, address.jwt);
|
||||
if (userId) {
|
||||
const deleteUserRes = await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
expect(deleteUserRes.ok()).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
29
e2e/tests/api/address-lifecycle.spec.ts
Normal file
29
e2e/tests/api/address-lifecycle.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Address Lifecycle', () => {
|
||||
test('create address, auto-init send balance via settings, then delete', async ({ request }) => {
|
||||
// Create address
|
||||
const { jwt, address, address_id } = await createTestAddress(request, 'lifecycle-test');
|
||||
expect(address).toContain('@' + TEST_DOMAIN);
|
||||
expect(jwt).toBeTruthy();
|
||||
expect(address_id).toBeGreaterThan(0);
|
||||
|
||||
// Fetch address settings — balance should auto-initialize from DEFAULT_SEND_BALANCE=10
|
||||
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
const settings = await settingsRes.json();
|
||||
expect(settings.send_balance).toBe(10);
|
||||
|
||||
// Delete address
|
||||
await deleteAddress(request, jwt);
|
||||
|
||||
// Verify address is gone — settings should fail
|
||||
const afterDelete = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(afterDelete.ok()).toBe(false);
|
||||
});
|
||||
});
|
||||
167
e2e/tests/api/address-password.spec.ts
Normal file
167
e2e/tests/api/address-password.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Address Password Login', () => {
|
||||
test('set password then login with it', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-login');
|
||||
const passwordHash = hashPassword('test-password-123');
|
||||
|
||||
try {
|
||||
// Set a password on the address
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: passwordHash },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
const changePwdBody = await changePwdRes.json();
|
||||
expect(changePwdBody.success).toBe(true);
|
||||
|
||||
// Login with the correct password
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: passwordHash },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const loginBody = await loginRes.json();
|
||||
expect(loginBody.jwt).toBeTruthy();
|
||||
expect(loginBody.address).toBe(address);
|
||||
|
||||
// The new JWT should work — verify by fetching settings
|
||||
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${loginBody.jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('login with wrong password returns 401', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-wrong');
|
||||
const passwordHash = hashPassword('correct-password');
|
||||
|
||||
try {
|
||||
// Set a password
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: passwordHash },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
const changePwdBody = await changePwdRes.json();
|
||||
expect(changePwdBody.success).toBe(true);
|
||||
|
||||
// Login with wrong password
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: hashPassword('wrong-password') },
|
||||
});
|
||||
expect(loginRes.status()).toBe(401);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin reset stores frontend-hashed address password', async ({ request }) => {
|
||||
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-admin-reset');
|
||||
const plainPassword = `admin-reset-${Date.now()}`;
|
||||
const passwordHash = hashPassword(plainPassword);
|
||||
|
||||
try {
|
||||
const resetRes = await request.post(`${WORKER_URL}/admin/address/${address_id}/reset_password`, {
|
||||
data: { password: passwordHash },
|
||||
});
|
||||
expect(resetRes.ok()).toBe(true);
|
||||
await expect(resetRes.json()).resolves.toMatchObject({ success: true });
|
||||
|
||||
const plaintextLoginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: plainPassword },
|
||||
});
|
||||
expect(plaintextLoginRes.status()).toBe(401);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: passwordHash },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const loginBody = await loginRes.json();
|
||||
expect(loginBody.jwt).toBeTruthy();
|
||||
expect(loginBody.address).toBe(address);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin address list does not expose stored password hash', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-list-hidden');
|
||||
const passwordHash = hashPassword('list-hidden-password');
|
||||
|
||||
try {
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: passwordHash },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
|
||||
const listRes = await request.get(
|
||||
`${WORKER_URL}/admin/address?limit=10&offset=0&query=${encodeURIComponent(address)}`
|
||||
);
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const listBody = await listRes.json();
|
||||
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
|
||||
expect(listedAddress).toBeTruthy();
|
||||
expect(listedAddress).not.toHaveProperty('password');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('user bind address list does not expose stored password hash', async ({ request }) => {
|
||||
const userEmail = `pwd-bind-hidden-${Date.now()}@test.example.com`;
|
||||
const userPasswordHash = hashPassword('bind-hidden-user-password');
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-bind-hidden');
|
||||
const addressPasswordHash = hashPassword('bind-hidden-address-password');
|
||||
|
||||
try {
|
||||
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
},
|
||||
});
|
||||
expect(enableRes.ok()).toBe(true);
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email: userEmail, password: userPasswordHash },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email: userEmail, password: userPasswordHash },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: addressPasswordHash },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const listBody = await listRes.json();
|
||||
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
|
||||
expect(listedAddress).toBeTruthy();
|
||||
expect(listedAddress).not.toHaveProperty('password');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
48
e2e/tests/api/admin-address-query.spec.ts
Normal file
48
e2e/tests/api/admin-address-query.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN, createTestAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
// Regression tests for #956: long admin search queries must not trigger
|
||||
// D1's "LIKE or GLOB pattern too complex" error.
|
||||
test.describe('Admin Address Query (#956)', () => {
|
||||
test('short query (subdomain fragment) returns matching address via LIKE', async ({ request }) => {
|
||||
const created = await createTestAddress(request, 'q956short');
|
||||
const fragment = created.address.split('@')[0].slice(0, 8);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { limit: '20', offset: '0', query: fragment },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
const names: string[] = body.results.map((r: any) => r.name);
|
||||
expect(names).toContain(created.address);
|
||||
});
|
||||
|
||||
test('long query (>50-byte pattern) does not crash with D1 LIKE error', async ({ request }) => {
|
||||
const longQuery = 'a48r893s@5hx7zb.nationalgeographic.algomindtrade.com';
|
||||
expect(new TextEncoder().encode(`%${longQuery}%`).length).toBeGreaterThan(50);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { limit: '20', offset: '0', query: longQuery },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
expect(body.results.length).toBe(0);
|
||||
expect(body.count).toBe(0);
|
||||
});
|
||||
|
||||
test('long query also works for /admin/users', async ({ request }) => {
|
||||
const longQuery = 'no-such-user-' + 'x'.repeat(40) + `@${TEST_DOMAIN}`;
|
||||
expect(new TextEncoder().encode(`%${longQuery}%`).length).toBeGreaterThan(50);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/users`, {
|
||||
params: { limit: '20', offset: '0', query: longQuery },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
expect(body.results.length).toBe(0);
|
||||
expect(body.count).toBe(0);
|
||||
});
|
||||
});
|
||||
73
e2e/tests/api/admin-config.spec.ts
Normal file
73
e2e/tests/api/admin-config.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
test.describe('Admin Config and D1 Storage', () => {
|
||||
test('reports database size in the database status response', async ({ request }) => {
|
||||
const response = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
expect(body.database_size).toEqual(expect.any(Number));
|
||||
expect(body.database_size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('saves and retrieves a namespaced config value', async ({ request }) => {
|
||||
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'd1_storage_plan', value: 'free' },
|
||||
});
|
||||
|
||||
expect(saveResponse.ok()).toBe(true);
|
||||
expect(await saveResponse.json()).toEqual({
|
||||
success: true,
|
||||
key: 'd1_storage_plan',
|
||||
value: 'free',
|
||||
});
|
||||
|
||||
const getResponse = await request.get(`${WORKER_URL}/admin/config/d1_storage_plan`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
|
||||
expect(getResponse.ok()).toBe(true);
|
||||
expect(await getResponse.json()).toEqual({
|
||||
key: 'd1_storage_plan',
|
||||
value: 'free',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps config values isolated from internal settings', async ({ request }) => {
|
||||
const versionBeforeResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
const versionBefore = (await versionBeforeResponse.json()).current_db_version;
|
||||
|
||||
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'db_version', value: 'shadow-version' },
|
||||
});
|
||||
expect(saveResponse.ok()).toBe(true);
|
||||
|
||||
const versionAfterResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect((await versionAfterResponse.json()).current_db_version).toBe(versionBefore);
|
||||
});
|
||||
|
||||
test('rejects invalid keys and non-string values', async ({ request }) => {
|
||||
const invalidKeyResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'invalid key', value: 'value' },
|
||||
});
|
||||
expect(invalidKeyResponse.status()).toBe(400);
|
||||
|
||||
const invalidValueResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'valid_key', value: 1 },
|
||||
});
|
||||
expect(invalidValueResponse.status()).toBe(400);
|
||||
});
|
||||
});
|
||||
131
e2e/tests/api/admin-new-address.spec.ts
Normal file
131
e2e/tests/api/admin-new-address.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Admin New Address', () => {
|
||||
test('should return address_id in response', async ({ request }) => {
|
||||
const uniqueName = `admin-test${Date.now()}`;
|
||||
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.address).toContain('@' + TEST_DOMAIN);
|
||||
expect(body.jwt).toBeTruthy();
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
expect(typeof body.address_id).toBe('number');
|
||||
});
|
||||
|
||||
test('normalizes uppercase configured prefix and domain', async ({ request }) => {
|
||||
const uniqueName = `admincase${Date.now()}`;
|
||||
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN.toUpperCase(), enablePrefix: true },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`);
|
||||
expect(body.jwt).toBeTruthy();
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('falls back to domains when default domains is empty', async ({ request }) => {
|
||||
const uniqueName = `fallback${Date.now().toString(36)}`;
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: uniqueName },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`);
|
||||
expect(body.jwt).toBeTruthy();
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('normalizes user role domains and prefix', async ({ request }) => {
|
||||
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `role-case-${suffix}@${TEST_DOMAIN}`;
|
||||
const password = `role-pass-${suffix}`;
|
||||
const name = `rolecase${suffix}`;
|
||||
|
||||
const createUserRes = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(createUserRes.ok()).toBe(true);
|
||||
|
||||
const usersRes = await request.get(`${WORKER_URL}/admin/users`, {
|
||||
params: { limit: '20', offset: '0', query: email },
|
||||
});
|
||||
expect(usersRes.ok()).toBe(true);
|
||||
const usersBody = await usersRes.json();
|
||||
const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email);
|
||||
expect(user).toBeTruthy();
|
||||
|
||||
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
data: { user_id: user.id, role_text: 'case-role' },
|
||||
});
|
||||
expect(updateRoleRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
|
||||
const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { name },
|
||||
});
|
||||
expect(createAddressRes.ok()).toBe(true);
|
||||
const addressBody = await createAddressRes.json();
|
||||
|
||||
expect(addressBody.address).toBe(`role${name}@${TEST_DOMAIN}`);
|
||||
expect(addressBody.jwt).toBeTruthy();
|
||||
expect(addressBody.address_id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('falls back to default domains when user role domains is empty', async ({ request }) => {
|
||||
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `empty-role-${suffix}@${TEST_DOMAIN}`;
|
||||
const password = `empty-role-pass-${suffix}`;
|
||||
const name = `emptyrole${suffix}`;
|
||||
|
||||
const createUserRes = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(createUserRes.ok()).toBe(true);
|
||||
|
||||
const usersRes = await request.get(`${WORKER_URL}/admin/users`, {
|
||||
params: { limit: '20', offset: '0', query: email },
|
||||
});
|
||||
expect(usersRes.ok()).toBe(true);
|
||||
const usersBody = await usersRes.json();
|
||||
const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email);
|
||||
expect(user).toBeTruthy();
|
||||
|
||||
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
data: { user_id: user.id, role_text: 'empty-role' },
|
||||
});
|
||||
expect(updateRoleRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
|
||||
const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { name },
|
||||
});
|
||||
expect(createAddressRes.ok()).toBe(true);
|
||||
const addressBody = await createAddressRes.json();
|
||||
|
||||
expect(addressBody.address).toBe(`empty${name}@${TEST_DOMAIN}`);
|
||||
expect(addressBody.jwt).toBeTruthy();
|
||||
expect(addressBody.address_id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
185
e2e/tests/api/auto-reply-trigger.spec.ts
Normal file
185
e2e/tests/api/auto-reply-trigger.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Auto Reply Trigger (#459)', () => {
|
||||
/**
|
||||
* Bug #459: source_prefix empty string causes auto-reply to never trigger.
|
||||
* The old condition `results.source_prefix && ...` short-circuits when
|
||||
* source_prefix is "" (falsy). Fix: empty source_prefix should match all.
|
||||
*/
|
||||
test('empty source_prefix triggers auto-reply for any sender', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-trigger');
|
||||
|
||||
try {
|
||||
// Configure auto-reply with empty source_prefix (match all senders)
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Auto Bot',
|
||||
subject: 'Auto Reply',
|
||||
source_prefix: '',
|
||||
message: 'Thanks for your email!',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Send a mail to the address — should trigger auto-reply
|
||||
const receiveRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'anyone@other.com',
|
||||
subject: 'Hello',
|
||||
text: 'Test message',
|
||||
});
|
||||
expect(receiveRes.success).toBe(true);
|
||||
expect(receiveRes.replyCalled).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('source_prefix startsWith still works (backward compat)', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-prefix');
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Prefix Bot',
|
||||
subject: 'Prefix Reply',
|
||||
source_prefix: 'vip@',
|
||||
message: 'VIP auto-reply',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Matching sender — should trigger
|
||||
const matchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'vip@example.com',
|
||||
subject: 'VIP mail',
|
||||
});
|
||||
expect(matchRes.replyCalled).toBe(true);
|
||||
|
||||
// Non-matching sender — should NOT trigger
|
||||
const noMatchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'random@example.com',
|
||||
subject: 'Random mail',
|
||||
});
|
||||
expect(noMatchRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('source_prefix regex match', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-regex');
|
||||
|
||||
try {
|
||||
// Configure regex: match senders from example.com or example.org
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Regex Bot',
|
||||
subject: 'Regex Reply',
|
||||
source_prefix: '/@example\\.(com|org)$/',
|
||||
message: 'Regex auto-reply',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Matching sender
|
||||
const matchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@example.com',
|
||||
subject: 'Match test',
|
||||
});
|
||||
expect(matchRes.replyCalled).toBe(true);
|
||||
|
||||
// Another matching sender
|
||||
const matchRes2 = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@example.org',
|
||||
subject: 'Match test 2',
|
||||
});
|
||||
expect(matchRes2.replyCalled).toBe(true);
|
||||
|
||||
// Non-matching sender
|
||||
const noMatchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@other.com',
|
||||
subject: 'No match test',
|
||||
});
|
||||
expect(noMatchRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled auto-reply does not trigger', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-disabled');
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Disabled Bot',
|
||||
subject: 'Should not reply',
|
||||
source_prefix: '',
|
||||
message: 'This should never be sent',
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const receiveRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'anyone@other.com',
|
||||
subject: 'Test disabled',
|
||||
});
|
||||
expect(receiveRes.success).toBe(true);
|
||||
expect(receiveRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Send a mail via receive_mail endpoint and return the response
|
||||
* including replyCalled field.
|
||||
*/
|
||||
async function seedTestMailWithReply(
|
||||
ctx: APIRequestContext,
|
||||
address: string,
|
||||
opts: { from?: string; subject?: string; text?: string }
|
||||
): Promise<{ success: boolean; replyCalled: boolean }> {
|
||||
const from = opts.from || 'sender@test.example.com';
|
||||
const subject = opts.subject || 'Test Email';
|
||||
const text = opts.text || 'Hello from E2E';
|
||||
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
text,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
73
e2e/tests/api/auto-reply.spec.ts
Normal file
73
e2e/tests/api/auto-reply.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Auto Reply Settings', () => {
|
||||
test('get empty, save, then verify saved settings', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'auto-reply');
|
||||
|
||||
try {
|
||||
// GET auto_reply — should return empty object for new address
|
||||
const emptyRes = await request.get(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(emptyRes.ok()).toBe(true);
|
||||
const empty = await emptyRes.json();
|
||||
expect(Object.keys(empty)).toHaveLength(0);
|
||||
|
||||
// POST save auto_reply settings
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Test Bot',
|
||||
subject: 'Auto Reply',
|
||||
source_prefix: 'Re:',
|
||||
message: 'Thanks for your email!',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
const saveBody = await saveRes.json();
|
||||
expect(saveBody.success).toBe(true);
|
||||
|
||||
// GET auto_reply — should return saved settings
|
||||
const savedRes = await request.get(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(savedRes.ok()).toBe(true);
|
||||
const saved = await savedRes.json();
|
||||
expect(saved.name).toBe('Test Bot');
|
||||
expect(saved.subject).toBe('Auto Reply');
|
||||
expect(saved.source_prefix).toBe('Re:');
|
||||
expect(saved.message).toBe('Thanks for your email!');
|
||||
expect(saved.enabled).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('save with too long subject returns 400', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'auto-reply-long');
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Bot',
|
||||
subject: 'x'.repeat(256),
|
||||
source_prefix: '',
|
||||
message: 'Hello',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.status()).toBe(400);
|
||||
const body = await saveRes.text();
|
||||
expect(body).toContain('too long');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
104
e2e/tests/api/cleanup.spec.ts
Normal file
104
e2e/tests/api/cleanup.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const listMails = async (request: APIRequestContext, address: string) => {
|
||||
const response = await request.get(`${WORKER_URL}/admin/mails`, {
|
||||
params: { address, limit: '100', offset: '0' },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
test.describe('Bounded cleanup', () => {
|
||||
test('cleans at most one batch and continues on the next run', async ({ request }) => {
|
||||
const address = `cleanup-batch-${Date.now()}@test.example.com`;
|
||||
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
|
||||
request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: {
|
||||
address,
|
||||
raw: 'old cleanup mail',
|
||||
message_id: `<cleanup-old-${Date.now()}-${index}@test>`,
|
||||
},
|
||||
})
|
||||
));
|
||||
expect(seedResponses.every((response) => response.ok())).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
const firstCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'mails', cleanDays: 0 },
|
||||
});
|
||||
expect(firstCleanup.ok()).toBe(true);
|
||||
|
||||
const afterFirstCleanup = await listMails(request, address);
|
||||
expect(afterFirstCleanup.count).toBe(1);
|
||||
|
||||
const secondCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'mails', cleanDays: 0 },
|
||||
});
|
||||
expect(secondCleanup.ok()).toBe(true);
|
||||
|
||||
const afterSecondCleanup = await listMails(request, address);
|
||||
expect(afterSecondCleanup.count).toBe(0);
|
||||
|
||||
const recentMailResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: {
|
||||
address,
|
||||
raw: 'recent cleanup mail',
|
||||
message_id: `<cleanup-recent-${Date.now()}@test>`,
|
||||
},
|
||||
});
|
||||
expect(recentMailResponse.ok()).toBe(true);
|
||||
|
||||
const recentCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'mails', cleanDays: 1 },
|
||||
});
|
||||
expect(recentCleanup.ok()).toBe(true);
|
||||
|
||||
const recentMails = await listMails(request, address);
|
||||
expect(recentMails.count).toBe(1);
|
||||
expect(recentMails.results[0].raw).toBe('recent cleanup mail');
|
||||
await request.delete(`${WORKER_URL}/admin/mails/${recentMails.results[0].id}`);
|
||||
});
|
||||
|
||||
test('deletes one address batch and its related data', async ({ request }) => {
|
||||
const oldAddress = await createTestAddress(request, 'cleanup-old');
|
||||
|
||||
const seedResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: {
|
||||
address: oldAddress.address,
|
||||
raw: 'address cleanup mail',
|
||||
message_id: `<cleanup-address-${Date.now()}@test>`,
|
||||
},
|
||||
});
|
||||
expect(seedResponse.ok()).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
const cleanupResponse = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'addressCreated', cleanDays: 0 },
|
||||
});
|
||||
expect(cleanupResponse.ok()).toBe(true);
|
||||
|
||||
const oldAddressResponse = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { query: oldAddress.address, limit: '20', offset: '0' },
|
||||
});
|
||||
expect(oldAddressResponse.ok()).toBe(true);
|
||||
expect((await oldAddressResponse.json()).count).toBe(0);
|
||||
expect((await listMails(request, oldAddress.address)).count).toBe(0);
|
||||
|
||||
const recentAddress = await createTestAddress(request, 'cleanup-recent');
|
||||
try {
|
||||
const recentCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'addressCreated', cleanDays: 1 },
|
||||
});
|
||||
expect(recentCleanup.ok()).toBe(true);
|
||||
|
||||
const recentAddressResponse = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { query: recentAddress.address, limit: '20', offset: '0' },
|
||||
});
|
||||
expect(recentAddressResponse.ok()).toBe(true);
|
||||
expect((await recentAddressResponse.json()).count).toBe(1);
|
||||
} finally {
|
||||
await deleteAddress(request, recentAddress.jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
69
e2e/tests/api/clear-sent.spec.ts
Normal file
69
e2e/tests/api/clear-sent.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Clear Sent Items', () => {
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
});
|
||||
|
||||
test('send mail then clear sent items', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'clear-sent');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
try {
|
||||
const subject = `Clear Sent Test ${Date.now()}`;
|
||||
|
||||
// Listen before sending
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
// Send a mail
|
||||
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: 'Sender',
|
||||
to_name: 'Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: '<p>test</p>',
|
||||
is_html: true,
|
||||
},
|
||||
});
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
await listener.message;
|
||||
|
||||
// Verify sendbox has 1 item
|
||||
const listRes = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Clear sent items
|
||||
const clearRes = await request.delete(`${WORKER_URL}/api/clear_sent_items`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(clearRes.ok()).toBe(true);
|
||||
const clearBody = await clearRes.json();
|
||||
expect(clearBody.success).toBe(true);
|
||||
|
||||
// Verify sendbox is empty
|
||||
const afterRes = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
expect(after.results).toHaveLength(0);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
175
e2e/tests/api/email-forward-domain-normalization.spec.ts
Normal file
175
e2e/tests/api/email-forward-domain-normalization.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
TEST_DOMAIN,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD };
|
||||
|
||||
const DEFAULT_ACCOUNT_SETTINGS = {
|
||||
blockList: [],
|
||||
sendBlockList: [],
|
||||
verifiedAddressList: [],
|
||||
fromBlockList: [],
|
||||
noLimitSendAddressList: [],
|
||||
emailRuleSettings: {},
|
||||
addressCreationSettings: {},
|
||||
};
|
||||
|
||||
async function resetAccountSettings(request: APIRequestContext) {
|
||||
const res = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: DEFAULT_ACCOUNT_SETTINGS,
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Email forward domain normalization', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
await resetAccountSettings(request);
|
||||
});
|
||||
|
||||
test('normalizes uppercase forwarding rule and recipient domains', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'forward-case');
|
||||
const forwardAddress = 'forward-target@test.example.com';
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
...DEFAULT_ACCOUNT_SETTINGS,
|
||||
emailRuleSettings: {
|
||||
emailForwardingList: [{
|
||||
domains: [TEST_DOMAIN.toUpperCase()],
|
||||
forward: forwardAddress,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const to = address.replace(`@${TEST_DOMAIN}`, `@${TEST_DOMAIN.toUpperCase()}`);
|
||||
const subject = `forward-case-${Date.now()}`;
|
||||
const raw = [
|
||||
`From: sender@test.example.com`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <${subject}@test>`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Forward domain normalization test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.forwardedTo).toEqual([forwardAddress]);
|
||||
|
||||
const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const mailsBody = await mailsRes.json();
|
||||
expect(mailsBody.results.some((mail: { address: string; raw: string }) => {
|
||||
return mail.address === address && mail.raw.includes(subject);
|
||||
})).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not forward when only the domain suffix string matches', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'forward-boundary');
|
||||
const forwardAddress = 'forward-boundary-target@test.example.com';
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
...DEFAULT_ACCOUNT_SETTINGS,
|
||||
emailRuleSettings: {
|
||||
emailForwardingList: [{
|
||||
domains: [TEST_DOMAIN],
|
||||
forward: forwardAddress,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const to = address.replace(`@${TEST_DOMAIN}`, `@evil${TEST_DOMAIN}`);
|
||||
const subject = `forward-boundary-${Date.now()}`;
|
||||
const raw = [
|
||||
`From: sender@test.example.com`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <${subject}@test>`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Forward domain boundary test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.forwardedTo).toEqual([]);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps blank forwarding rule domain as catch-all', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'forward-catch-all');
|
||||
const forwardAddress = 'forward-catch-all-target@test.example.com';
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
...DEFAULT_ACCOUNT_SETTINGS,
|
||||
emailRuleSettings: {
|
||||
emailForwardingList: [{
|
||||
domains: ['', 'not-the-domain.example.com'],
|
||||
forward: forwardAddress,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const subject = `forward-catch-all-${Date.now()}`;
|
||||
const raw = [
|
||||
`From: sender@test.example.com`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <${subject}@test>`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Forward catch-all domain test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to: address, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.forwardedTo).toEqual([forwardAddress]);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
23
e2e/tests/api/health.spec.ts
Normal file
23
e2e/tests/api/health.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Health & Settings', () => {
|
||||
test('GET /health_check returns OK', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/health_check`);
|
||||
expect(res.ok()).toBe(true);
|
||||
expect(await res.text()).toBe('OK');
|
||||
});
|
||||
|
||||
test('GET /open_api/settings returns correct domains and sendMail enabled', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const settings = await res.json();
|
||||
expect(settings.domains).toContain('test.example.com');
|
||||
expect(settings.defaultDomains).toContain('test.example.com');
|
||||
expect(settings.prefix).toBe('tmp');
|
||||
expect(settings.enableSendMail).toBe(true);
|
||||
expect(settings.enableUserCreateEmail).toBe(true);
|
||||
expect(settings.enableUserDeleteEmail).toBe(true);
|
||||
});
|
||||
});
|
||||
274
e2e/tests/api/ip-whitelist.spec.ts
Normal file
274
e2e/tests/api/ip-whitelist.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||
|
||||
const RESET_SETTINGS = {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
};
|
||||
|
||||
test.describe('IP Whitelist Settings', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: RESET_SETTINGS,
|
||||
});
|
||||
});
|
||||
|
||||
test('get default IP whitelist settings returns disabled with empty list', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enableWhitelist).toBeFalsy();
|
||||
expect(settings.whitelist).toEqual([]);
|
||||
});
|
||||
|
||||
test('save and retrieve IP whitelist settings', async ({ request }) => {
|
||||
// Save whitelist settings
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: ['1.2.3.4', '^192\\.168\\.1\\.\\d+$'],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
const saveBody = await saveRes.json();
|
||||
expect(saveBody.success).toBe(true);
|
||||
|
||||
// Retrieve and verify
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enableWhitelist).toBe(true);
|
||||
expect(settings.whitelist).toEqual(['1.2.3.4', '^192\\.168\\.1\\.\\d+$']);
|
||||
});
|
||||
|
||||
test('whitelist rejects empty list when enabled', async ({ request }) => {
|
||||
// Note: Frontend blocks this, but backend allows it (empty list = ignored)
|
||||
// This test verifies backend behavior
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
// Backend accepts empty whitelist (it will be ignored at runtime)
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('whitelist validates array type', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: 'not-an-array', // Invalid type
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('whitelist enforces max size limit', async ({ request }) => {
|
||||
const largeList = Array.from({ length: 1001 }, (_, i) => `1.2.3.${i % 256}`);
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: largeList,
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
const body = await saveRes.text();
|
||||
expect(body).toContain('whitelist');
|
||||
expect(body).toContain('1000');
|
||||
});
|
||||
|
||||
test('backward compatibility: old frontend without whitelist fields', async ({ request }) => {
|
||||
// Simulate old frontend that doesn't send enableWhitelist/whitelist
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: true,
|
||||
blacklist: ['10.0.0.1'],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
// enableWhitelist and whitelist omitted
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
// Should succeed with defaults applied
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Verify defaults were applied
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enableWhitelist).toBe(false);
|
||||
expect(settings.whitelist).toEqual([]);
|
||||
});
|
||||
|
||||
test('whitelist sanitizes patterns (trims and removes empty)', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [' 1.2.3.4 ', '', ' ', '5.6.7.8'],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
// Empty strings should be filtered out, whitespace trimmed
|
||||
expect(settings.whitelist).toEqual(['1.2.3.4', '5.6.7.8']);
|
||||
});
|
||||
|
||||
test('whitelist rejects invalid regex pattern', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: { ...RESET_SETTINGS, whitelist: ['^[1.2.3.4$'] }, // invalid regex
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
expect(await saveRes.text()).toContain('whitelist');
|
||||
});
|
||||
|
||||
test('whitelist rejects non-string elements', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: { ...RESET_SETTINGS, whitelist: [1, null] },
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IP Whitelist Runtime Behavior', () => {
|
||||
test('whitelist with empty list allows requests (protection mode)', async ({ request }) => {
|
||||
// Enable whitelist with empty list
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to create address (rate-limited endpoint)
|
||||
// Should succeed because empty whitelist is ignored
|
||||
const res = await createTestAddress(request, 'whitelist-empty');
|
||||
expect(res.jwt).toBeTruthy();
|
||||
expect(res.address).toBeTruthy();
|
||||
});
|
||||
|
||||
test('whitelist blocks requests when IP does not match whitelist', async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
...RESET_SETTINGS,
|
||||
enableWhitelist: true,
|
||||
whitelist: ['1.2.3.4'],
|
||||
},
|
||||
});
|
||||
|
||||
// In e2e, cf-connecting-ip is absent → fail-closed → 403
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: `whitelist-block-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('IP');
|
||||
});
|
||||
|
||||
test('fingerprint blacklist blocks even when cf-connecting-ip is absent', async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
...RESET_SETTINGS,
|
||||
enabled: true,
|
||||
fingerprintBlacklist: ['blocked-fingerprint-123'],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
headers: { 'x-fingerprint': 'blocked-fingerprint-123' },
|
||||
data: { name: `fp-block-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
expect(await res.text()).toContain('fingerprint');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Reset whitelist to disabled after each test
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
124
e2e/tests/api/login-endpoints.spec.ts
Normal file
124
e2e/tests/api/login-endpoints.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
|
||||
|
||||
test('settings returns enableGlobalTurnstileCheck as false', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enableGlobalTurnstileCheck).toBe(false);
|
||||
});
|
||||
|
||||
test.describe('/open_api/site_login', () => {
|
||||
test('returns 401 when no PASSWORDS configured', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/site_login`, {
|
||||
data: {
|
||||
password: hashPassword('any-pass'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/open_api/admin_login', () => {
|
||||
test('correct hashed password succeeds', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: hashPassword('e2e-admin-pass'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
test('wrong password returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: hashPassword('wrong-admin'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('empty password returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: '',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/open_api/credential_login', () => {
|
||||
test('valid JWT credential succeeds', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'cred-login');
|
||||
try {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: jwt,
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid JWT returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: 'invalid.jwt.token',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('empty credential returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: '',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/api/address_login with cf_token', () => {
|
||||
test('address login with empty cf_token works when turnstile disabled', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'addr-cf');
|
||||
try {
|
||||
// Set a password
|
||||
await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: hashPassword('addr-pass-123') },
|
||||
});
|
||||
|
||||
// Login with cf_token field present but empty
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: {
|
||||
email: address,
|
||||
password: hashPassword('addr-pass-123'),
|
||||
cf_token: ''
|
||||
},
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const body = await loginRes.json();
|
||||
expect(body.jwt).toBeTruthy();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
181
e2e/tests/api/mail-deletion.spec.ts
Normal file
181
e2e/tests/api/mail-deletion.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, WORKER_URL_ENV_OFF, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Mail Deletion', () => {
|
||||
test('user mail deletion is disabled when ENABLE_USER_DELETE_EMAIL is false', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
const testUserEmail = `mail-delete-e2e-${Date.now()}@test.example.com`;
|
||||
const testUserPassword = 'test-password-123';
|
||||
const testUserPasswordHash = createHash('sha256').update(testUserPassword).digest('hex');
|
||||
|
||||
const enableRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
},
|
||||
});
|
||||
expect(enableRes.ok()).toBe(true);
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/register`, {
|
||||
data: { email: testUserEmail, password: testUserPasswordHash },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/login`, {
|
||||
data: { email: testUserEmail, password: testUserPasswordHash },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
expect(userJwt).toBeTruthy();
|
||||
|
||||
const createRes = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, {
|
||||
data: {
|
||||
name: `user-del-disabled${Date.now()}`,
|
||||
domain: 'test.example.com',
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const { jwt, address, address_id } = await createRes.json();
|
||||
|
||||
try {
|
||||
const bindRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
|
||||
const from = 'sender@test.example.com';
|
||||
const subject = 'Disabled Mail Delete';
|
||||
const boundary = `----E2E${Date.now()}`;
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Hello from E2E',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'',
|
||||
'<p>Hello from E2E</p>',
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
expect(seedRes.ok()).toBe(true);
|
||||
const seedBody = await seedRes.json();
|
||||
expect(seedBody.success).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${WORKER_URL_ENV_OFF}/user_api/mails?limit=10&offset=0`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
|
||||
const targetId = results[0].id;
|
||||
const delRes = await request.delete(`${WORKER_URL_ENV_OFF}/user_api/mails/${targetId}`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(delRes.status()).toBe(403);
|
||||
|
||||
const afterRes = await request.get(`${WORKER_URL_ENV_OFF}/user_api/mails?limit=10&offset=0`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
expect(after.results).toHaveLength(1);
|
||||
expect(after.results[0].id).toBe(targetId);
|
||||
} finally {
|
||||
const deleteRes = await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${address_id}`);
|
||||
expect(deleteRes.ok()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('delete a single mail by ID', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'del-single');
|
||||
|
||||
try {
|
||||
// Seed 3 emails
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await seedTestMail(request, address, { subject: `Mail ${i}` });
|
||||
}
|
||||
|
||||
// List mails — should have 3
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(3);
|
||||
|
||||
// Delete the second mail
|
||||
const targetId = results[1].id;
|
||||
const delRes = await request.delete(`${WORKER_URL}/api/mails/${targetId}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(delRes.ok()).toBe(true);
|
||||
const delBody = await delRes.json();
|
||||
expect(delBody.success).toBe(true);
|
||||
|
||||
// List again — should have 2, and the deleted ID should be gone
|
||||
const afterRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
expect(after.results).toHaveLength(2);
|
||||
expect(after.results.every((m: any) => m.id !== targetId)).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('clear entire inbox', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'del-clear');
|
||||
|
||||
try {
|
||||
// Seed 3 emails
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await seedTestMail(request, address, { subject: `Mail ${i}` });
|
||||
}
|
||||
|
||||
// Verify 3 mails exist
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(3);
|
||||
|
||||
// Clear inbox
|
||||
const clearRes = await request.delete(`${WORKER_URL}/api/clear_inbox`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(clearRes.ok()).toBe(true);
|
||||
const clearBody = await clearRes.json();
|
||||
expect(clearBody.success).toBe(true);
|
||||
|
||||
// Verify inbox is empty
|
||||
const afterRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
expect(after.results).toHaveLength(0);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
100
e2e/tests/api/mail-detail.spec.ts
Normal file
100
e2e/tests/api/mail-detail.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
test.describe('Mail Detail', () => {
|
||||
test('fetch a single mail by ID', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'detail-get');
|
||||
|
||||
try {
|
||||
// Seed a mail with known content
|
||||
await seedTestMail(request, address, {
|
||||
subject: 'Detail Test',
|
||||
from: 'alice@test.example.com',
|
||||
html: '<p>Hello detail</p>',
|
||||
text: 'Hello detail',
|
||||
});
|
||||
|
||||
// List mails to get the ID
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
// Fetch single mail by ID
|
||||
const detailRes = await request.get(`${WORKER_URL}/api/mail/${mailId}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.id).toBe(mailId);
|
||||
expect(mail.address).toBe(address);
|
||||
expect(mail.source).toBe('alice@test.example.com');
|
||||
expect(mail.raw).toContain('Detail Test');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetch non-existent mail returns null', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'detail-404');
|
||||
|
||||
try {
|
||||
const res = await request.get(`${WORKER_URL}/api/mail/99999999`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body).toBeNull();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin Mail Detail', () => {
|
||||
test('fetch a single mail by ID without a mailbox JWT', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'admin-detail-get');
|
||||
|
||||
try {
|
||||
await seedTestMail(request, address, {
|
||||
subject: 'Admin Detail Test',
|
||||
from: 'admin-detail@test.example.com',
|
||||
text: 'Hello admin detail',
|
||||
});
|
||||
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
const detailRes = await request.get(`${WORKER_URL}/admin/mails/${mailId}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.id).toBe(mailId);
|
||||
expect(mail.address).toBe(address);
|
||||
expect(mail.source).toBe('admin-detail@test.example.com');
|
||||
expect(mail.raw).toContain('Admin Detail Test');
|
||||
expect(mail.raw_blob).toBeUndefined();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetch non-existent mail returns null', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/admin/mails/99999999`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
expect(await res.json()).toBeNull();
|
||||
});
|
||||
});
|
||||
162
e2e/tests/api/passkey.spec.ts
Normal file
162
e2e/tests/api/passkey.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const TEST_USER_EMAIL = `passkey-e2e-${Date.now()}@test.example.com`;
|
||||
const TEST_USER_PASSWORD = 'test-password-123';
|
||||
|
||||
/**
|
||||
* Enable user registration via admin API, register a user, and login to get JWT.
|
||||
*/
|
||||
async function createTestUser(request: APIRequestContext): Promise<string> {
|
||||
// Enable user registration (KV setting)
|
||||
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
},
|
||||
});
|
||||
expect(enableRes.ok()).toBe(true);
|
||||
|
||||
// Register user
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
// Login to get JWT
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
expect(jwt).toBeTruthy();
|
||||
return jwt;
|
||||
}
|
||||
|
||||
test.describe('Passkey API', () => {
|
||||
let userJwt: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
userJwt = await createTestUser(request);
|
||||
});
|
||||
|
||||
test('register_request returns valid WebAuthn options', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_request`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { domain: 'localhost' },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const options = await res.json();
|
||||
|
||||
// Verify WebAuthn registration options structure
|
||||
expect(options.rp).toBeDefined();
|
||||
expect(options.rp.id).toBe('localhost');
|
||||
expect(options.user).toBeDefined();
|
||||
expect(options.user.name).toBe(TEST_USER_EMAIL);
|
||||
expect(options.challenge).toBeTruthy();
|
||||
expect(options.pubKeyCredParams).toBeInstanceOf(Array);
|
||||
expect(options.pubKeyCredParams.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('authenticate_request returns valid WebAuthn options', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_request`, {
|
||||
data: { domain: 'localhost' },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const options = await res.json();
|
||||
|
||||
// Verify WebAuthn authentication options structure
|
||||
expect(options.challenge).toBeTruthy();
|
||||
expect(options.rpId).toBe('localhost');
|
||||
expect(options.allowCredentials).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
test('authenticate_response with invalid credential returns error', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_response`, {
|
||||
data: {
|
||||
domain: 'localhost',
|
||||
origin: 'http://localhost',
|
||||
credential: { id: 'nonexistent-passkey-id' },
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('passkey list is empty for new user', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/user_api/passkey`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const passkeys = await res.json();
|
||||
expect(passkeys).toBeInstanceOf(Array);
|
||||
expect(passkeys.length).toBe(0);
|
||||
});
|
||||
|
||||
test('passkey list remains empty without registration', async ({ request }) => {
|
||||
const listRes = await request.get(`${WORKER_URL}/user_api/passkey`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const passkeys = await listRes.json();
|
||||
expect(passkeys).toBeInstanceOf(Array);
|
||||
expect(passkeys.length).toBe(0);
|
||||
});
|
||||
|
||||
test('register_response with invalid credential returns 400', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_response`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
credential: {
|
||||
id: 'fake-id',
|
||||
rawId: 'fake-raw-id',
|
||||
type: 'public-key',
|
||||
response: {
|
||||
attestationObject: 'invalid-data',
|
||||
clientDataJSON: 'invalid-data',
|
||||
},
|
||||
},
|
||||
origin: 'http://localhost',
|
||||
passkey_name: 'test-passkey',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
// Should fail verification
|
||||
expect(res.status()).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test('rename nonexistent passkey succeeds silently', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
passkey_id: 'nonexistent-id',
|
||||
passkey_name: 'new-name',
|
||||
},
|
||||
});
|
||||
// The SQL UPDATE just affects 0 rows, still returns success
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
test('rename with invalid name returns 400', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
passkey_id: 'any-id',
|
||||
passkey_name: 'x'.repeat(256),
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('delete nonexistent passkey succeeds silently', async ({ request }) => {
|
||||
const res = await request.delete(`${WORKER_URL}/user_api/passkey/nonexistent-id`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
145
e2e/tests/api/send-access.spec.ts
Normal file
145
e2e/tests/api/send-access.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
requestSendAccess,
|
||||
deleteAddress,
|
||||
deleteAddressSender,
|
||||
getAddressSender,
|
||||
updateAddressSender,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Send Access', () => {
|
||||
test('request send access stays idempotent when default balance is auto-initialized', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'send-access');
|
||||
|
||||
try {
|
||||
// First request — should succeed even if balance will also auto-init elsewhere.
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
// Verify balance is set via settings
|
||||
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
const settings = await settingsRes.json();
|
||||
expect(settings.send_balance).toBe(10);
|
||||
|
||||
// Duplicate request should stay safe and idempotent.
|
||||
const dupRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(dupRes.ok()).toBe(true);
|
||||
|
||||
const sender = await getAddressSender(request, address);
|
||||
expect(sender.balance).toBe(10);
|
||||
expect(sender.enabled).toBe(1);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin-disabled rows are not overwritten by settings or send', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'sa-admin-blocked');
|
||||
|
||||
try {
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const sender = await getAddressSender(request, address);
|
||||
await updateAddressSender(request, {
|
||||
address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
// Reading settings must not auto-repair an admin-disabled row.
|
||||
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
const settings = await settingsRes.json();
|
||||
expect(settings.send_balance).toBe(0);
|
||||
|
||||
const stillDisabled = await getAddressSender(request, address);
|
||||
expect(stillDisabled.balance).toBe(0);
|
||||
expect(stillDisabled.enabled).toBe(0);
|
||||
|
||||
// Attempting to send must also fail and must not auto-repair the row.
|
||||
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: 'E2E',
|
||||
to_name: 'E2E',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'should not send',
|
||||
content: 'body',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(sendRes.ok()).toBe(false);
|
||||
|
||||
const afterSend = await getAddressSender(request, address);
|
||||
expect(afterSend.balance).toBe(0);
|
||||
expect(afterSend.enabled).toBe(0);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('send after admin deletion auto-initializes a fresh sender row', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'send-access-deleted');
|
||||
|
||||
try {
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const sender = await getAddressSender(request, address);
|
||||
await deleteAddressSender(request, sender.id);
|
||||
|
||||
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: 'E2E',
|
||||
to_name: 'E2E',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `E2E reinit ${Date.now()}`,
|
||||
content: 'body',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
|
||||
// A fresh row should exist with the default balance decremented by 1.
|
||||
const recreated = await getAddressSender(request, address);
|
||||
expect(recreated.enabled).toBe(1);
|
||||
expect(recreated.balance).toBe(9);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('request send access does not falsely succeed when quota is already exhausted', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'sendexh');
|
||||
|
||||
try {
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const sender = await getAddressSender(request, address);
|
||||
await updateAddressSender(request, {
|
||||
address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const retryRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(retryRes.status()).toBe(400);
|
||||
const retryBody = await retryRes.text();
|
||||
expect(retryBody).toContain('Already');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
532
e2e/tests/api/send-mail-limit.spec.ts
Normal file
532
e2e/tests/api/send-mail-limit.spec.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
WORKER_URL_SEND_MAIL_DOMAIN,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD };
|
||||
|
||||
const DEFAULT_ACCOUNT_SETTINGS = {
|
||||
blockList: [],
|
||||
sendBlockList: [],
|
||||
verifiedAddressList: [],
|
||||
fromBlockList: [],
|
||||
noLimitSendAddressList: [],
|
||||
emailRuleSettings: {},
|
||||
addressCreationSettings: {},
|
||||
};
|
||||
|
||||
const DISABLED_LIMIT_CONFIG = {
|
||||
dailyEnabled: false,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: null as number | null,
|
||||
monthlyLimit: null as number | null,
|
||||
};
|
||||
|
||||
async function saveLimitConfig(
|
||||
request: APIRequestContext,
|
||||
sendMailLimitConfig: Record<string, unknown>
|
||||
) {
|
||||
return request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { ...DEFAULT_ACCOUNT_SETTINGS, sendMailLimitConfig },
|
||||
});
|
||||
}
|
||||
|
||||
async function resetLimitConfig(request: APIRequestContext) {
|
||||
const res = await saveLimitConfig(request, DISABLED_LIMIT_CONFIG);
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function sendOneMail(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
tag: string,
|
||||
opts: { expectDelivery?: boolean; lang?: string } = {}
|
||||
) {
|
||||
const { expectDelivery = true, lang } = opts;
|
||||
const subject = `limit-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${jwt}` };
|
||||
if (lang) headers['x-lang'] = lang;
|
||||
|
||||
let listener: ReturnType<typeof onMailpitMessage> | undefined;
|
||||
if (expectDelivery) {
|
||||
listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
}
|
||||
const res = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers,
|
||||
data: {
|
||||
from_name: 'Limit E2E',
|
||||
to_name: 'Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: `Limit test body ${tag}`,
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
return { res, listener, subject };
|
||||
}
|
||||
|
||||
async function probeLimitBaseline(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
config: {
|
||||
dailyEnabled: boolean;
|
||||
monthlyEnabled: boolean;
|
||||
dailyLimit: number | null;
|
||||
monthlyLimit: number | null;
|
||||
},
|
||||
subjectPrefix: string,
|
||||
maxProbeLimit: number = 50
|
||||
): Promise<number> {
|
||||
for (let limit = 1; limit <= maxProbeLimit; limit++) {
|
||||
const save = await saveLimitConfig(request, {
|
||||
...config,
|
||||
dailyLimit: config.dailyEnabled ? limit : null,
|
||||
monthlyLimit: config.monthlyEnabled ? limit : null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const probe = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: 'probe',
|
||||
to_name: '',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `${subjectPrefix}-${limit}-${Date.now()}`,
|
||||
content: 'probe',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
if (probe.ok()) {
|
||||
return limit;
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to probe send mail limit baseline within ${maxProbeLimit}`);
|
||||
}
|
||||
|
||||
async function probeDailyBaseline(
|
||||
request: APIRequestContext,
|
||||
jwt: string
|
||||
): Promise<number> {
|
||||
return probeLimitBaseline(request, jwt, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: null,
|
||||
}, 'probe-daily');
|
||||
}
|
||||
|
||||
async function probeMonthlyBaseline(
|
||||
request: APIRequestContext,
|
||||
jwt: string
|
||||
): Promise<number> {
|
||||
return probeLimitBaseline(request, jwt, {
|
||||
dailyEnabled: false,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: null,
|
||||
monthlyLimit: 1,
|
||||
}, 'probe-monthly');
|
||||
}
|
||||
|
||||
test.describe('Send Mail Limit', () => {
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
await resetLimitConfig(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await resetLimitConfig(request);
|
||||
});
|
||||
|
||||
test('save + read roundtrip preserves all fields', async ({ request }) => {
|
||||
const config = {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: 7,
|
||||
monthlyLimit: 1234,
|
||||
};
|
||||
const save = await saveLimitConfig(request, config);
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(read.ok()).toBe(true);
|
||||
const body = await read.json();
|
||||
expect(body.sendMailLimitConfig).toEqual(config);
|
||||
});
|
||||
|
||||
test('disabled flags coerce numeric limits to null', async ({ request }) => {
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: false,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: 10,
|
||||
monthlyLimit: 20,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
const body = await read.json();
|
||||
expect(body.sendMailLimitConfig).toEqual(DISABLED_LIMIT_CONFIG);
|
||||
});
|
||||
|
||||
test('minus one is accepted as unlimited', async ({ request }) => {
|
||||
const config = {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: -1,
|
||||
monthlyLimit: -1,
|
||||
};
|
||||
const save = await saveLimitConfig(request, config);
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
const body = await read.json();
|
||||
expect(body.sendMailLimitConfig).toEqual(config);
|
||||
});
|
||||
|
||||
test('invalid payloads rejected with 400', async ({ request }) => {
|
||||
const cases: Array<Record<string, unknown>> = [
|
||||
{ dailyEnabled: 'yes', monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
|
||||
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: -2, monthlyLimit: null },
|
||||
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: 1.5, monthlyLimit: null },
|
||||
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
|
||||
{ dailyEnabled: false, monthlyEnabled: true, dailyLimit: null, monthlyLimit: null },
|
||||
];
|
||||
for (const bad of cases) {
|
||||
const res = await saveLimitConfig(request, bad);
|
||||
expect(res.status(), `payload: ${JSON.stringify(bad)}`).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled limit allows unlimited sends', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-off');
|
||||
await requestSendAccess(request, jwt);
|
||||
await resetLimitConfig(request);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { res, listener } = await sendOneMail(request, jwt, `off${i}`);
|
||||
expect(res.ok()).toBe(true);
|
||||
await listener!.message;
|
||||
}
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('zero limit blocks sending immediately', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-zero');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: 0,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const { res } = await sendOneMail(request, jwt, 'zero', {
|
||||
expectDelivery: false,
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('Server daily send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('daily limit blocks once reached and returns English message', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-daily');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const allowed = 2;
|
||||
const limit = baseline + allowed;
|
||||
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: limit,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
for (let i = 0; i < allowed; i++) {
|
||||
const { res, listener } = await sendOneMail(request, jwt, `d${i}`);
|
||||
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||
await listener!.message;
|
||||
}
|
||||
|
||||
const { res: blocked } = await sendOneMail(request, jwt, 'd-over', {
|
||||
expectDelivery: false,
|
||||
});
|
||||
expect(blocked.ok()).toBe(false);
|
||||
const text = await blocked.text();
|
||||
expect(text).toContain('Server daily send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('monthly limit blocks once reached', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-monthly');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeMonthlyBaseline(request, jwt);
|
||||
const allowed = 2;
|
||||
const limit = baseline + allowed;
|
||||
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: false,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: null,
|
||||
monthlyLimit: limit,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
for (let i = 0; i < allowed; i++) {
|
||||
const { res, listener } = await sendOneMail(request, jwt, `m${i}`);
|
||||
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||
await listener!.message;
|
||||
}
|
||||
|
||||
const { res: blocked } = await sendOneMail(request, jwt, 'm-over', {
|
||||
expectDelivery: false,
|
||||
});
|
||||
expect(blocked.ok()).toBe(false);
|
||||
const text = await blocked.text();
|
||||
expect(text).toContain('Server monthly send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('zh-lang header returns Chinese daily limit message', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-zh');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: baseline,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const { res } = await sendOneMail(request, jwt, 'zh-over', {
|
||||
expectDelivery: false,
|
||||
lang: 'zh',
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('服务器今日发信次数已达上限');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('validation failures (missing subject) do not consume quota', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-noconsume');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: baseline + 1,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
// Empty subject → rejected by validation BEFORE the counter increments.
|
||||
const badRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: '',
|
||||
to_name: '',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: '',
|
||||
content: 'no subject',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(badRes.ok()).toBe(false);
|
||||
|
||||
const { res, listener } = await sendOneMail(request, jwt, 'after-bad');
|
||||
expect(res.ok()).toBe(true);
|
||||
await listener!.message;
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('both daily + monthly enabled: tighter daily limit wins', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-both');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: baseline,
|
||||
monthlyLimit: baseline + 10_000,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const { res } = await sendOneMail(request, jwt, 'both-over', {
|
||||
expectDelivery: false,
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('Server daily send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('minus one means unlimited at runtime', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-unlimited');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: -1,
|
||||
monthlyLimit: -1,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { res, listener } = await sendOneMail(request, jwt, `unl${i}`);
|
||||
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||
await listener!.message;
|
||||
}
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding returns 400 when SEND_MAIL binding is missing', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: 'admin@test.example.com',
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: 'no-binding',
|
||||
text: 'body',
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding normalizes uppercase allowed domain', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: 'admin@TEST.EXAMPLE.COM',
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: `send-mail-domain-ok-${Date.now()}`,
|
||||
text: 'body',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
expect(await res.json()).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding normalizes object-form from domain', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: { email: 'admin@TEST.EXAMPLE.COM', name: 'Admin' },
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: `send-mail-domain-object-ok-${Date.now()}`,
|
||||
text: 'body',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
expect(await res.json()).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding returns 400 when domain is not allowed', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: 'admin@blocked.example.com',
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: `send-mail-domain-blocked-${Date.now()}`,
|
||||
text: 'body',
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Please enable SEND_MAIL for this domain first');
|
||||
});
|
||||
|
||||
test('daily and monthly counters both increment on successful send', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-both-inc');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const dailyBaseline = await probeDailyBaseline(request, jwt);
|
||||
const monthlyBaseline = await probeMonthlyBaseline(request, jwt);
|
||||
|
||||
// Give plenty of headroom so sends succeed.
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: dailyBaseline + 10,
|
||||
monthlyLimit: monthlyBaseline + 10,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const { res, listener } = await sendOneMail(request, jwt, 'inc');
|
||||
expect(res.ok()).toBe(true);
|
||||
await listener!.message;
|
||||
|
||||
// Re-probe to confirm both counters moved forward after the successful send.
|
||||
const dailyAfter = await probeDailyBaseline(request, jwt);
|
||||
expect(dailyAfter).toBeGreaterThanOrEqual(dailyBaseline + 1);
|
||||
const monthlyAfter = await probeMonthlyBaseline(request, jwt);
|
||||
expect(monthlyAfter).toBeGreaterThanOrEqual(monthlyBaseline + 1);
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('admin /admin/send_mail also respects daily limit', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'limit-admin');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
// Probe via a user-facing send to establish baseline.
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: baseline,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/send_mail`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from_name: '',
|
||||
from_mail: address,
|
||||
to_name: '',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `admin-over-${Date.now()}`,
|
||||
content: 'admin blocked body',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('Server daily send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
});
|
||||
54
e2e/tests/api/send-mail.spec.ts
Normal file
54
e2e/tests/api/send-mail.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
onMailpitMessage,
|
||||
WORKER_URL,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Send Mail via SMTP', () => {
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
});
|
||||
|
||||
test('send HTML email and verify in Mailpit', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'sender-test');
|
||||
const subject = `E2E Test ${Date.now()}`;
|
||||
const htmlContent = '<h1>Hello</h1><p>This is an <b>E2E test</b> email.</p>';
|
||||
|
||||
// Start listening for the message BEFORE sending
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
// Send mail via worker API
|
||||
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: 'E2E Sender',
|
||||
to_name: 'E2E Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: htmlContent,
|
||||
is_html: true,
|
||||
},
|
||||
});
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
|
||||
// Wait for Mailpit WebSocket "new" event — no polling
|
||||
const mail = await listener.message;
|
||||
expect(mail.From.Address).toBe(address);
|
||||
expect(mail.To[0].Address).toBe('recipient@test.example.com');
|
||||
|
||||
// Balance should auto-initialize to 10 and then decrement to 9 after sending.
|
||||
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
const settings = await settingsRes.json();
|
||||
expect(settings.send_balance).toBe(9);
|
||||
|
||||
// Cleanup
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
});
|
||||
230
e2e/tests/api/subdomain-create.spec.ts
Normal file
230
e2e/tests/api/subdomain-create.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
const MANUAL_BASE_DOMAIN = 'manual.example.com';
|
||||
const SUBDOMAIN = `team.${MANUAL_BASE_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${MANUAL_BASE_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${MANUAL_BASE_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${MANUAL_BASE_DOMAIN}`;
|
||||
const RANDOM_SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
||||
let originalCreateAddressStoredEnabled: boolean | undefined;
|
||||
let originalEnvOffStoredEnabled: boolean | undefined;
|
||||
|
||||
async function getAccountSettings(request: any, workerUrl: string) {
|
||||
const res = await request.get(`${workerUrl}/admin/account_settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function buildAccountSettingsPayload(
|
||||
current: any,
|
||||
addressCreationSettings?: { enableSubdomainMatch?: boolean | null },
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
blockList: current.blockList || [],
|
||||
sendBlockList: current.sendBlockList || [],
|
||||
verifiedAddressList: current.verifiedAddressList || [],
|
||||
fromBlockList: current.fromBlockList || [],
|
||||
noLimitSendAddressList: current.noLimitSendAddressList || [],
|
||||
emailRuleSettings: current.emailRuleSettings || {},
|
||||
...(typeof addressCreationSettings !== 'undefined'
|
||||
? { addressCreationSettings }
|
||||
: {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSubdomainMatchSetting(
|
||||
request: any,
|
||||
workerUrl: string,
|
||||
enableSubdomainMatch: boolean | null
|
||||
) {
|
||||
const current = await getAccountSettings(request, workerUrl);
|
||||
const res = await request.post(`${workerUrl}/admin/account_settings`, {
|
||||
data: buildAccountSettingsPayload(current, {
|
||||
enableSubdomainMatch,
|
||||
}),
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function restoreSubdomainMatchSetting(
|
||||
request: any,
|
||||
workerUrl: string,
|
||||
originalValue: boolean | undefined
|
||||
) {
|
||||
if (typeof originalValue === 'boolean') {
|
||||
await saveSubdomainMatchSetting(request, workerUrl, originalValue);
|
||||
return;
|
||||
}
|
||||
await saveSubdomainMatchSetting(request, workerUrl, null);
|
||||
}
|
||||
|
||||
test.describe('Create Address Subdomain Match', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const createAddressSettings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
originalCreateAddressStoredEnabled = createAddressSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||
|
||||
if (WORKER_URL_ENV_OFF) {
|
||||
const envOffSettings = await getAccountSettings(request, WORKER_URL_ENV_OFF);
|
||||
originalEnvOffStoredEnabled = envOffSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await restoreSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, originalCreateAddressStoredEnabled);
|
||||
if (WORKER_URL_ENV_OFF) {
|
||||
await restoreSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, originalEnvOffStoredEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin can clear override and return to env fallback', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, null);
|
||||
|
||||
const settings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
expect(settings.addressCreationSubdomainMatchStatus?.storedEnabled).toBeUndefined();
|
||||
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subenvfb${Date.now()}`, domain: SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('invalid addressCreationSettings payload does not partially persist earlier settings', async ({ request }) => {
|
||||
const current = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
const uniqueBlockedKeyword = `should-not-persist-${Date.now()}`;
|
||||
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/account_settings`, {
|
||||
data: buildAccountSettingsPayload(
|
||||
current,
|
||||
{ enableSubdomainMatch: 'invalid-value' as any },
|
||||
{
|
||||
blockList: [...(current.blockList || []), uniqueBlockedKeyword],
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const after = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
expect(after.blockList || []).toEqual(current.blockList || []);
|
||||
expect(after.addressCreationSubdomainMatchStatus?.storedEnabled).toBe(
|
||||
current.addressCreationSubdomainMatchStatus?.storedEnabled
|
||||
);
|
||||
});
|
||||
|
||||
test('random subdomain scope allows a manually entered subdomain', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const uniqueName = `subdomain-default-${Date.now()}`;
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: RANDOM_SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
|
||||
});
|
||||
|
||||
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||
|
||||
const adminName = `subdomain-admin-${Date.now()}`;
|
||||
const adminRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: adminName, domain: SUBDOMAIN },
|
||||
});
|
||||
expect(adminRes.ok()).toBe(true);
|
||||
const adminBody = await adminRes.json();
|
||||
expect(adminBody.address).toContain(`@${SUBDOMAIN}`);
|
||||
expect(adminBody.address_id).toBeGreaterThan(0);
|
||||
|
||||
const userName = `subdomain-user-${Date.now()}`;
|
||||
const userRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name: userName, domain: NESTED_SUBDOMAIN },
|
||||
});
|
||||
expect(userRes.ok()).toBe(true);
|
||||
const userBody = await userRes.json();
|
||||
expect(userBody.address).toContain(`@${NESTED_SUBDOMAIN}`);
|
||||
expect(userBody.address_id).toBeGreaterThan(0);
|
||||
|
||||
const mixedCaseRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subcase${Date.now()}`, domain: MIXED_CASE_SUBDOMAIN },
|
||||
});
|
||||
expect(mixedCaseRes.ok()).toBe(true);
|
||||
const mixedCaseBody = await mixedCaseRes.json();
|
||||
expect(mixedCaseBody.address).toContain(`@${SUBDOMAIN}`);
|
||||
|
||||
const invalidRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subinvalid${Date.now()}`, domain: INVALID_LOOKALIKE_DOMAIN },
|
||||
});
|
||||
expect(invalidRes.ok()).toBe(false);
|
||||
expect(await invalidRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidEmptyPrefixRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subempty${Date.now()}`, domain: INVALID_EMPTY_PREFIX_DOMAIN },
|
||||
});
|
||||
expect(invalidEmptyPrefixRes.ok()).toBe(false);
|
||||
expect(await invalidEmptyPrefixRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidEmptyLabelRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `sublabel${Date.now()}`, domain: INVALID_EMPTY_LABEL_DOMAIN },
|
||||
});
|
||||
expect(invalidEmptyLabelRes.ok()).toBe(false);
|
||||
expect(await invalidEmptyLabelRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidOverlongRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `sublong${Date.now()}`, domain: INVALID_OVERLONG_DOMAIN },
|
||||
});
|
||||
expect(invalidOverlongRes.ok()).toBe(false);
|
||||
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('deleted random subdomain address can be recreated manually', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const name = `subreuse${Date.now()}`;
|
||||
const firstCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name, domain: TEST_DOMAIN, enableRandomSubdomain: true },
|
||||
});
|
||||
expect(firstCreate.ok()).toBe(true);
|
||||
const firstAddress = await firstCreate.json();
|
||||
const generatedDomain = firstAddress.address.split('@')[1];
|
||||
expect(generatedDomain).not.toBe(TEST_DOMAIN);
|
||||
|
||||
const firstDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${firstAddress.jwt}` },
|
||||
});
|
||||
expect(firstDelete.ok()).toBe(true);
|
||||
|
||||
const secondCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name, domain: generatedDomain },
|
||||
});
|
||||
expect(secondCreate.ok()).toBe(true);
|
||||
const secondAddress = await secondCreate.json();
|
||||
expect(secondAddress.address).toBe(firstAddress.address);
|
||||
|
||||
const secondDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${secondAddress.jwt}` },
|
||||
});
|
||||
expect(secondDelete.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
||||
|
||||
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: RANDOM_SUBDOMAIN },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
});
|
||||
55
e2e/tests/api/telegram-ai-extract.spec.ts
Normal file
55
e2e/tests/api/telegram-ai-extract.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Telegram AI extraction rendering', () => {
|
||||
test('realtime mail stores AI extraction metadata for Telegram rendering', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'tg-ai');
|
||||
|
||||
try {
|
||||
const subject = `Telegram AI realtime ${Date.now()}`;
|
||||
const raw = [
|
||||
'From: sender@test.example.com',
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <telegram-ai-${Date.now()}@test>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Telegram AI extraction realtime body',
|
||||
].join('\r\n');
|
||||
|
||||
const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: {
|
||||
from: 'sender@test.example.com',
|
||||
to: address,
|
||||
raw,
|
||||
ai_extract_result: {
|
||||
type: 'auth_code',
|
||||
result: '123456',
|
||||
result_text: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(receiveRes.ok()).toBe(true);
|
||||
const receiveBody = await receiveRes.json();
|
||||
expect(receiveBody.success).toBe(true);
|
||||
|
||||
const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const { results } = await mailsRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
|
||||
const metadata = JSON.parse(results[0].metadata);
|
||||
expect(metadata.ai_extract).toEqual({
|
||||
type: 'auth_code',
|
||||
result: '123456',
|
||||
result_text: '',
|
||||
});
|
||||
expect(metadata.extracted_at).toBeTruthy();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
170
e2e/tests/api/user-address-pagination.spec.ts
Normal file
170
e2e/tests/api/user-address-pagination.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `address-page-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
test.describe('User address pagination', () => {
|
||||
test('paginates addresses and enforces mail ownership', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let outsider: Awaited<ReturnType<typeof createTestAddress>> | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
const userJwt = user.jwt;
|
||||
userId = user.userId;
|
||||
addresses.push(...await Promise.all([
|
||||
createTestAddress(request, 'user-page-a'),
|
||||
createTestAddress(request, 'user-page-b'),
|
||||
createTestAddress(request, 'user-page-c'),
|
||||
]));
|
||||
outsider = await createTestAddress(request, 'user-page-outsider');
|
||||
|
||||
for (const item of addresses) {
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${item.jwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
}
|
||||
|
||||
const defaultPageRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(defaultPageRes.ok()).toBe(true);
|
||||
const defaultPage = await defaultPageRes.json();
|
||||
expect(defaultPage.count).toBe(3);
|
||||
expect(defaultPage.results).toHaveLength(3);
|
||||
|
||||
const firstPageRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=2&offset=0`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(firstPageRes.ok()).toBe(true);
|
||||
const firstPage = await firstPageRes.json();
|
||||
expect(firstPage.count).toBe(3);
|
||||
expect(firstPage.results).toHaveLength(2);
|
||||
expect(firstPage.results[0].mail_count).toBe(0);
|
||||
expect(firstPage.results[0].send_count).toBe(0);
|
||||
expect(firstPage.results[0]).toHaveProperty('source_meta');
|
||||
expect(firstPage.results[0]).not.toHaveProperty('password');
|
||||
|
||||
const secondPageRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=2&offset=2`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(secondPageRes.ok()).toBe(true);
|
||||
const secondPage = await secondPageRes.json();
|
||||
expect(secondPage.count).toBe(0);
|
||||
expect(secondPage.results).toHaveLength(1);
|
||||
|
||||
const invalidLimitRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=101&offset=0`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(invalidLimitRes.status()).toBe(400);
|
||||
|
||||
const invalidOffsetRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=20&offset=-1`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(invalidOffsetRes.status()).toBe(400);
|
||||
|
||||
await seedTestMail(request, addresses[0].address, { subject: 'Bound mail' });
|
||||
await seedTestMail(request, outsider.address, { subject: 'Outsider mail' });
|
||||
|
||||
const userMailsRes = await request.get(`${WORKER_URL}/user_api/mails?limit=20&offset=0`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(userMailsRes.ok()).toBe(true);
|
||||
const userMails = await userMailsRes.json();
|
||||
expect(userMails.count).toBe(1);
|
||||
expect(userMails.results[0].address).toBe(addresses[0].address);
|
||||
|
||||
const filteredOutsiderMailsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/mails?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(filteredOutsiderMailsRes.ok()).toBe(true);
|
||||
const filteredOutsiderMails = await filteredOutsiderMailsRes.json();
|
||||
expect(filteredOutsiderMails.count).toBe(0);
|
||||
expect(filteredOutsiderMails.results).toHaveLength(0);
|
||||
|
||||
const outsiderMailsRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
});
|
||||
expect(outsiderMailsRes.ok()).toBe(true);
|
||||
const outsiderMails = await outsiderMailsRes.json();
|
||||
expect(outsiderMails.results).toHaveLength(1);
|
||||
|
||||
const forbiddenDeleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/mails/${outsiderMails.results[0].id}`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(forbiddenDeleteRes.ok()).toBe(true);
|
||||
|
||||
const outsiderAfterRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
});
|
||||
expect(outsiderAfterRes.ok()).toBe(true);
|
||||
const outsiderAfter = await outsiderAfterRes.json();
|
||||
expect(outsiderAfter.results).toHaveLength(1);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
[...addresses, outsider].filter((item) => item !== undefined)
|
||||
.map((item) => deleteAddress(request, item.jwt)),
|
||||
);
|
||||
if (userId !== undefined) {
|
||||
const deleteUserRes = await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
expect(deleteUserRes.ok()).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
const restoreSettingsRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
expect(restoreSettingsRes.ok()).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
118
e2e/tests/api/user-domain-normalization.spec.ts
Normal file
118
e2e/tests/api/user-domain-normalization.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import http from 'node:http';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
async function resetUserSettings(request: APIRequestContext) {
|
||||
const res = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: [],
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function resetOauth2Settings(request: APIRequestContext) {
|
||||
const res = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, {
|
||||
data: [],
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function startOauthServer(email: string): Promise<{ server: http.Server; baseUrl: string }> {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ access_token: 'token', token_type: 'Bearer' }));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/userinfo') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ email }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '0.0.0.0', resolve));
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('Failed to resolve OAuth test server port');
|
||||
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
|
||||
return { server, baseUrl: `http://${hostname}:${addr.port}` };
|
||||
}
|
||||
|
||||
test.describe('User domain normalization', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
await resetOauth2Settings(request);
|
||||
await resetUserSettings(request);
|
||||
});
|
||||
|
||||
test('normalizes uppercase verify mail sender domain in admin settings', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: true,
|
||||
verifyMailSender: 'verify@TEST.EXAMPLE.COM',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes uppercase user registration allow-list domains', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
enableMailAllowList: true,
|
||||
mailAllowList: ['TEST.EXAMPLE.COM'],
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: {
|
||||
email: `allow-list-${Date.now()}@TEST.EXAMPLE.COM`,
|
||||
password: 'allow-list-password',
|
||||
},
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes uppercase OAuth2 allow-list domains', async ({ request }) => {
|
||||
const email = `oauth-allow-${Date.now()}@TEST.EXAMPLE.COM`;
|
||||
const { server, baseUrl } = await startOauthServer(email);
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, {
|
||||
data: [{
|
||||
name: 'case-oauth',
|
||||
clientID: 'case-client',
|
||||
clientSecret: 'case-secret',
|
||||
authorizationURL: `${baseUrl}/authorize`,
|
||||
accessTokenURL: `${baseUrl}/token`,
|
||||
accessTokenFormat: 'json',
|
||||
userInfoURL: `${baseUrl}/userinfo`,
|
||||
redirectURL: `${baseUrl}/callback`,
|
||||
userEmailKey: 'email',
|
||||
scope: 'openid email',
|
||||
enableMailAllowList: true,
|
||||
mailAllowList: ['TEST.EXAMPLE.COM'],
|
||||
}],
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const callbackRes = await request.post(`${WORKER_URL}/user_api/oauth2/callback`, {
|
||||
data: { clientID: 'case-client', code: 'case-code' },
|
||||
});
|
||||
expect(callbackRes.ok()).toBe(true);
|
||||
const body = await callbackRes.json();
|
||||
expect(body.jwt).toBeTruthy();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
415
e2e/tests/api/user-send-mail.spec.ts
Normal file
415
e2e/tests/api/user-send-mail.spec.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
getAddressSender,
|
||||
hashPassword,
|
||||
onMailpitMessage,
|
||||
updateAddressSender,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `user-send-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
async function bindAddress(
|
||||
request: APIRequestContext,
|
||||
userJwt: string,
|
||||
addressJwt: string,
|
||||
) {
|
||||
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${addressJwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('User send mail API', () => {
|
||||
test('sends and manages sent items for a bound address only', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let userId: number | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
const bound = await createTestAddress(request, 'user-send-bound-');
|
||||
const accessRequest = await createTestAddress(request, 'user-send-access-');
|
||||
const outsider = await createTestAddress(request, 'usr-outsider-');
|
||||
addresses.push(bound, accessRequest, outsider);
|
||||
await bindAddress(request, user.jwt, bound.jwt);
|
||||
await bindAddress(request, user.jwt, accessRequest.jwt);
|
||||
|
||||
const invalidAddressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/0/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(invalidAddressSettingsRes.status()).toBe(400);
|
||||
|
||||
const outsiderSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderSettingsRes.status()).toBe(400);
|
||||
|
||||
const outsiderCredentialRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderCredentialRes.status()).toBe(400);
|
||||
|
||||
const outsiderAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderAccessRes.status()).toBe(400);
|
||||
|
||||
const outsiderUserSendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'Forbidden user send',
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(outsiderUserSendRes.status()).toBe(400);
|
||||
|
||||
const unauthenticatedSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
);
|
||||
expect(unauthenticatedSendboxRes.status()).toBe(401);
|
||||
|
||||
const requestAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(requestAccessRes.ok()).toBe(true);
|
||||
|
||||
const accessSender = await getAddressSender(request, accessRequest.address);
|
||||
await updateAddressSender(request, {
|
||||
address: accessRequest.address,
|
||||
address_id: accessSender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const duplicateAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(duplicateAccessRes.status()).toBe(400);
|
||||
expect(await duplicateAccessRes.text()).toContain('Already');
|
||||
|
||||
const addressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(addressSettingsRes.ok()).toBe(true);
|
||||
const addressSettings = await addressSettingsRes.json();
|
||||
expect(addressSettings.address).toBe(bound.address);
|
||||
expect(addressSettings.send_balance).toBe(10);
|
||||
|
||||
await deleteAllMailpitMessages(request);
|
||||
const subject = `User API send ${Date.now()}`;
|
||||
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const sendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
from_name: 'User Sender',
|
||||
from_mail: outsider.address,
|
||||
to_name: 'Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: 'Sent through the user API',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.From.Address).toBe(bound.address);
|
||||
|
||||
const outsiderSubject = `Outsider send ${Date.now()}`;
|
||||
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: outsiderSubject,
|
||||
content: 'This sent item must remain inaccessible to the user',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(outsiderSendRes.ok()).toBe(true);
|
||||
const outsiderAddressSendboxRes = await request.get(
|
||||
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||
);
|
||||
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
|
||||
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
|
||||
JSON.parse(item.raw).subject === outsiderSubject
|
||||
));
|
||||
expect(outsiderMail).toBeTruthy();
|
||||
|
||||
const unauthorizedDeleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(unauthorizedDeleteRes.ok()).toBe(true);
|
||||
const outsiderSendboxAfterDeleteRes = await request.get(
|
||||
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||
);
|
||||
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
|
||||
|
||||
const updatedSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect((await updatedSettingsRes.json()).send_balance).toBe(9);
|
||||
|
||||
const sender = await getAddressSender(request, bound.address);
|
||||
await updateAddressSender(request, {
|
||||
address: bound.address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const noBalanceRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'No balance user send',
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(noBalanceRes.status()).toBe(400);
|
||||
expect(await noBalanceRes.text()).toContain('No balance');
|
||||
|
||||
const userSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(userSendboxRes.ok()).toBe(true);
|
||||
const userSendbox = await userSendboxRes.json();
|
||||
expect(userSendbox.count).toBe(1);
|
||||
expect(userSendbox.results).toHaveLength(1);
|
||||
expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject);
|
||||
|
||||
const filteredSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(filteredSendboxRes.ok()).toBe(true);
|
||||
expect((await filteredSendboxRes.json()).count).toBe(1);
|
||||
|
||||
const outsiderFilterRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderFilterRes.ok()).toBe(true);
|
||||
expect((await outsiderFilterRes.json()).count).toBe(0);
|
||||
|
||||
const deleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(deleteRes.ok()).toBe(true);
|
||||
|
||||
const emptySendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
const emptySendbox = await emptySendboxRes.json();
|
||||
expect(emptySendbox.count).toBe(0);
|
||||
expect(emptySendbox.results).toHaveLength(0);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('applies unlimited balance from the user role access token', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
const userIds: number[] = [];
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userIds.push(user.userId);
|
||||
const address = await createTestAddress(request, 'user-send-role-');
|
||||
addresses.push(address);
|
||||
await bindAddress(request, user.jwt, address.jwt);
|
||||
|
||||
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
data: { user_id: user.userId, role_text: 'case-role' },
|
||||
});
|
||||
expect(updateRoleRes.ok()).toBe(true);
|
||||
|
||||
const accessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(accessRes.ok()).toBe(true);
|
||||
const sender = await getAddressSender(request, address.address);
|
||||
await updateAddressSender(request, {
|
||||
address: address.address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
});
|
||||
expect(userSettingsRes.ok()).toBe(true);
|
||||
const { access_token: accessToken } = await userSettingsRes.json();
|
||||
expect(accessToken).toBeTruthy();
|
||||
const userHeaders = {
|
||||
'x-user-token': user.jwt,
|
||||
'x-user-access-token': accessToken,
|
||||
};
|
||||
|
||||
const addressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/settings`,
|
||||
{ headers: userHeaders },
|
||||
);
|
||||
expect(addressSettingsRes.ok()).toBe(true);
|
||||
expect((await addressSettingsRes.json()).send_balance).toBe(99999);
|
||||
|
||||
const sendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/send_mail`,
|
||||
{
|
||||
headers: userHeaders,
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `Unlimited role send ${Date.now()}`,
|
||||
content: 'Sent without consuming address balance',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(0);
|
||||
|
||||
const otherUser = await createUser(request);
|
||||
userIds.push(otherUser.userId);
|
||||
const otherAddress = await createTestAddress(request, 'user-send-other-');
|
||||
addresses.push(otherAddress);
|
||||
await bindAddress(request, otherUser.jwt, otherAddress.jwt);
|
||||
const otherAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': otherUser.jwt } },
|
||||
);
|
||||
expect(otherAccessRes.ok()).toBe(true);
|
||||
const otherSender = await getAddressSender(request, otherAddress.address);
|
||||
await updateAddressSender(request, {
|
||||
address: otherAddress.address,
|
||||
address_id: otherSender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const mixedHeaders = {
|
||||
'x-user-token': otherUser.jwt,
|
||||
'x-user-access-token': accessToken,
|
||||
};
|
||||
const otherSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`,
|
||||
{ headers: mixedHeaders },
|
||||
);
|
||||
expect(otherSettingsRes.ok()).toBe(true);
|
||||
expect((await otherSettingsRes.json()).send_balance).toBe(0);
|
||||
|
||||
const otherSendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`,
|
||||
{
|
||||
headers: mixedHeaders,
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `Mismatched role token ${Date.now()}`,
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(otherSendRes.status()).toBe(400);
|
||||
expect(await otherSendRes.text()).toContain('No balance');
|
||||
} finally {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
await Promise.allSettled(userIds.map((userId) => (
|
||||
request.delete(`${WORKER_URL}/admin/users/${userId}`)
|
||||
)));
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
88
e2e/tests/api/webhook-settings.spec.ts
Normal file
88
e2e/tests/api/webhook-settings.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
seedTestMail,
|
||||
deleteAddress,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Webhook Settings', () => {
|
||||
test('get default webhook settings returns empty/disabled', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'webhook-get');
|
||||
|
||||
try {
|
||||
const res = await request.get(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enabled).toBeFalsy();
|
||||
expect(settings.url).toBe('');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('save and retrieve webhook settings', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'webhook-save');
|
||||
|
||||
try {
|
||||
// Save webhook settings
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: true,
|
||||
url: 'https://example.com/webhook',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ from: '${from}', subject: '${subject}' }),
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
const saveBody = await saveRes.json();
|
||||
expect(saveBody.success).toBe(true);
|
||||
|
||||
// Retrieve and verify
|
||||
const getRes = await request.get(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enabled).toBe(true);
|
||||
expect(settings.url).toBe('https://example.com/webhook');
|
||||
expect(settings.method).toBe('POST');
|
||||
expect(settings.headers).toBe(JSON.stringify({ 'Content-Type': 'application/json' }));
|
||||
expect(settings.body).toBe(JSON.stringify({ from: '${from}', subject: '${subject}' }));
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('test webhook with unreachable URL returns error', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'webhook-fail');
|
||||
|
||||
try {
|
||||
// Seed a mail so the test endpoint has raw data
|
||||
await seedTestMail(request, address, {
|
||||
subject: 'Webhook Fail Test',
|
||||
from: 'sender@test.example.com',
|
||||
text: 'This webhook should fail',
|
||||
});
|
||||
|
||||
// Test webhook with unreachable URL — expect non-2xx response
|
||||
const testRes = await request.post(`${WORKER_URL}/api/webhook/test`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: true,
|
||||
url: 'http://unreachable.invalid/webhook',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ from: '${from}' }),
|
||||
},
|
||||
});
|
||||
expect(testRes.ok()).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
176
e2e/tests/api/webhook-trigger.spec.ts
Normal file
176
e2e/tests/api/webhook-trigger.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import http from 'node:http';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
/**
|
||||
* Start a temporary HTTP server that records incoming requests.
|
||||
* Returns the server, a promise that resolves with the first request body,
|
||||
* and the URL to use as webhook target.
|
||||
*/
|
||||
async function startWebhookReceiver(): Promise<{
|
||||
server: http.Server;
|
||||
firstRequest: Promise<{ body: string; method: string; path: string; headers: http.IncomingHttpHeaders }>;
|
||||
url: string;
|
||||
}> {
|
||||
let resolve: (val: any) => void;
|
||||
const firstRequest = new Promise<any>((r) => { resolve = r; });
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
resolve({
|
||||
body: Buffer.concat(chunks).toString('utf-8'),
|
||||
method: req.method || '',
|
||||
path: req.url || '',
|
||||
headers: req.headers,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
});
|
||||
|
||||
// Use port 0 to let the OS assign a free port
|
||||
await new Promise<void>((resolve) => server.listen(0, '0.0.0.0', resolve));
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('Failed to resolve webhook receiver port');
|
||||
const boundPort = addr.port;
|
||||
// In Docker network, e2e-runner container hostname is "e2e-runner"
|
||||
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
|
||||
return { server, firstRequest, url: `http://${hostname}:${boundPort}/webhook` };
|
||||
}
|
||||
|
||||
test.describe('Webhook — triggered on incoming mail', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
({ jwt, address } = await createTestAddress(request, 'webhook-trigger'));
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('webhook is called with correct payload when mail arrives', async ({ request }) => {
|
||||
const { server, firstRequest, url } = await startWebhookReceiver();
|
||||
|
||||
try {
|
||||
// Configure user webhook
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: true,
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
from: '${from}',
|
||||
to: '${to}',
|
||||
subject: '${subject}',
|
||||
aiExtractType: '${aiExtractType}',
|
||||
aiExtractResult: '${aiExtractResult}',
|
||||
aiExtractResultText: '${aiExtractResultText}',
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Send incoming mail via receive_mail endpoint
|
||||
const from = `webhook-sender@test.example.com`;
|
||||
const subject = `Webhook Test ${Date.now()}`;
|
||||
const messageId = `<webhook-${Date.now()}@test>`;
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Webhook trigger test body`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: {
|
||||
from,
|
||||
to: address,
|
||||
raw,
|
||||
ai_extract_result: {
|
||||
type: 'auth_code',
|
||||
result: '654321',
|
||||
result_text: 'Login verification code',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
// Wait for webhook to be called
|
||||
const received = await firstRequest;
|
||||
expect(received.method).toBe('POST');
|
||||
expect(received.path).toBe('/webhook');
|
||||
|
||||
const payload = JSON.parse(received.body);
|
||||
expect(payload.from).toContain('webhook-sender@test.example.com');
|
||||
expect(payload.to).toBe(address);
|
||||
expect(payload.subject).toBe(subject);
|
||||
expect(payload.aiExtractType).toBe('auth_code');
|
||||
expect(payload.aiExtractResult).toBe('654321');
|
||||
expect(payload.aiExtractResultText).toBe('Login verification code');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('webhook is NOT called when disabled', async ({ request }) => {
|
||||
const { server, firstRequest, url } = await startWebhookReceiver();
|
||||
|
||||
try {
|
||||
// Disable webhook
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: false,
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ from: '${from}' }),
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Send incoming mail
|
||||
const subject = `Webhook Disabled ${Date.now()}`;
|
||||
const messageId = `<webhook-off-${Date.now()}@test>`;
|
||||
const raw = [
|
||||
`From: sender@test.example.com`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Should not trigger webhook`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to: address, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
// Webhook should NOT be called — wait briefly then verify timeout
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('timeout')), 3_000)
|
||||
);
|
||||
await expect(
|
||||
Promise.race([firstRequest, timeoutPromise])
|
||||
).rejects.toThrow('timeout');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
50
e2e/tests/browser/custom-subdomain.spec.ts
Normal file
50
e2e/tests/browser/custom-subdomain.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FRONTEND_URL, TEST_DOMAIN, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test('create an address with a custom subdomain from the UI', async ({ page, request }) => {
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.getByRole('button', { name: 'Create New Email' }).click();
|
||||
|
||||
const name = `subui${Date.now()}`;
|
||||
const createForm = page.locator('.n-tab-pane:visible form');
|
||||
await createForm.locator('.n-input-group .n-input input').fill(name);
|
||||
|
||||
const domainSelect = createForm.locator('.n-input-group .n-select');
|
||||
await expect(domainSelect.locator('input')).toHaveCount(0);
|
||||
|
||||
const normalSubdomain = createForm.getByRole('radio', { name: 'Normal Domain' });
|
||||
const randomSubdomain = createForm.getByRole('radio', { name: 'Use Random Subdomain' });
|
||||
const customSubdomain = createForm.getByRole('radio', { name: 'Use Custom Subdomain' });
|
||||
|
||||
await expect(normalSubdomain).toBeChecked();
|
||||
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
|
||||
await expect(normalSubdomain).not.toBeChecked();
|
||||
await expect(randomSubdomain).toBeChecked();
|
||||
await expect(customSubdomain).not.toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||
await expect(randomSubdomain).not.toBeChecked();
|
||||
await expect(customSubdomain).toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
|
||||
await expect(randomSubdomain).toBeChecked();
|
||||
await expect(customSubdomain).not.toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
|
||||
|
||||
await createForm.getByRole('button', { name: 'Create New Email' }).click();
|
||||
|
||||
const domain = `team.${TEST_DOMAIN}`;
|
||||
const address = `tmp${name}@${domain}`;
|
||||
await expect(page.locator('code').getByText(address, { exact: true })).toBeVisible();
|
||||
await page.waitForFunction(() => Boolean(localStorage.getItem('jwt')));
|
||||
jwt = await page.evaluate(() => localStorage.getItem('jwt') || undefined);
|
||||
expect(jwt).toBeTruthy();
|
||||
} finally {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
37
e2e/tests/browser/database-storage.spec.ts
Normal file
37
e2e/tests/browser/database-storage.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FRONTEND_URL, WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
test('persists the selected D1 plan and restores it after reload', async ({ page, request }) => {
|
||||
const seedResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'd1_storage_plan', value: 'free' },
|
||||
});
|
||||
expect(seedResponse.ok()).toBe(true);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'qucickSetup');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
|
||||
const storagePanel = page.locator('.storage-panel');
|
||||
const planSelect = storagePanel.locator('.plan-select .n-select');
|
||||
|
||||
await expect(storagePanel.getByText('Current Database Size', { exact: true })).toBeVisible();
|
||||
await expect(storagePanel.getByText('Database Capacity Limit', { exact: true })).toBeVisible();
|
||||
await expect(storagePanel.getByText('Capacity Usage', { exact: true })).toBeVisible();
|
||||
await expect(planSelect).toContainText('Free');
|
||||
|
||||
await planSelect.click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: 'Workers Paid' }).click();
|
||||
|
||||
await expect(page.getByText('Workers plan saved')).toBeVisible();
|
||||
await expect(storagePanel).toContainText('10.0 GB');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(planSelect).toContainText('Workers Paid');
|
||||
await expect(storagePanel).toContainText('10.0 GB');
|
||||
});
|
||||
49
e2e/tests/browser/inbox.spec.ts
Normal file
49
e2e/tests/browser/inbox.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
createTestAddress,
|
||||
seedTestMail,
|
||||
deleteAddress,
|
||||
} from '../../fixtures/test-helpers';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
|
||||
test.describe('Inbox Browser Flow', () => {
|
||||
test('login via JWT, view inbox, open email', async ({ page }) => {
|
||||
// Create API context for setup
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'inbox-browser');
|
||||
jwt = created.jwt;
|
||||
const address = created.address;
|
||||
|
||||
// Seed an email
|
||||
const subject = `Browser Test ${Date.now()}`;
|
||||
await seedTestMail(api, address, {
|
||||
subject,
|
||||
html: '<h1>Welcome</h1><p>This is a <b>browser test</b> email.</p>',
|
||||
});
|
||||
|
||||
// Login via JWT query param with /en/ path to force English locale
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// The mail subject should be visible in the inbox list item
|
||||
const mailItem = page.getByRole('listitem').getByText(subject);
|
||||
await expect(mailItem).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click to open the email
|
||||
await mailItem.click();
|
||||
|
||||
// Verify the email detail panel shows the subject as a heading
|
||||
// (n-card-header wraps n-card-header__main, both match heading role — use .first())
|
||||
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 5_000 });
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
64
e2e/tests/browser/locale-switch.spec.ts
Normal file
64
e2e/tests/browser/locale-switch.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const installLocaleInitScript = async (page: Page, locales: string[], preferredLocale: string | null = null) => {
|
||||
await page.addInitScript(
|
||||
({ locales: initialLocales, preferredLocale: initialPreferredLocale }: { locales: string[]; preferredLocale: string | null }) => {
|
||||
const localeInitStorageKey = '__localeInitDone';
|
||||
if (!window.sessionStorage.getItem(localeInitStorageKey)) {
|
||||
window.localStorage.removeItem('preferredLocale');
|
||||
if (initialPreferredLocale) {
|
||||
window.localStorage.setItem('preferredLocale', initialPreferredLocale);
|
||||
}
|
||||
window.sessionStorage.setItem(localeInitStorageKey, '1');
|
||||
}
|
||||
|
||||
Object.defineProperty(window.navigator, 'language', {
|
||||
configurable: true,
|
||||
get: () => initialLocales[0],
|
||||
});
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
configurable: true,
|
||||
get: () => initialLocales,
|
||||
});
|
||||
},
|
||||
{ locales, preferredLocale },
|
||||
);
|
||||
};
|
||||
|
||||
const selectLanguage = async (page: Page, selectTrigger: Locator, optionLabel: string) => {
|
||||
await selectTrigger.click();
|
||||
const option = page.locator('.n-dropdown-option, .n-dropdown-option-body').filter({ hasText: optionLabel }).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
};
|
||||
|
||||
test.describe('Locale switching', () => {
|
||||
test('keeps default route in Chinese while persisting browser language preference', async ({ page }) => {
|
||||
await installLocaleInitScript(page, ['es-ES', 'en-US']);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/`);
|
||||
|
||||
await expect(page).toHaveURL(`${FRONTEND_URL}/`);
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('preferredLocale'))).toBe('es');
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('zh');
|
||||
});
|
||||
|
||||
test('mobile drawer switch updates locale route and persisted preference', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 844 });
|
||||
await installLocaleInitScript(page, ['zh-CN']);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/`);
|
||||
|
||||
await page.getByRole('button', { name: /菜单|Menu/i }).click();
|
||||
|
||||
const drawerLocaleDropdown = page.locator('.n-drawer').getByRole('button', { name: /中文|English|Español|Português|日本語|Deutsch/ }).first();
|
||||
await selectLanguage(page, drawerLocaleDropdown, 'Deutsch');
|
||||
|
||||
await expect(page).toHaveURL(`${FRONTEND_URL}/de/`);
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('preferredLocale'))).toBe('de');
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('de');
|
||||
});
|
||||
});
|
||||
114
e2e/tests/browser/passkey.spec.ts
Normal file
114
e2e/tests/browser/passkey.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
import { createHash } from 'crypto';
|
||||
import { WORKER_URL, FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const TEST_USER_EMAIL = `passkey-browser-${Date.now()}@test.example.com`;
|
||||
const TEST_USER_PASSWORD = 'browser-test-pwd-123';
|
||||
|
||||
// Frontend hashes passwords with SHA-256 before sending to the API.
|
||||
// Register with the hashed password so UI login matches.
|
||||
const HASHED_PASSWORD = createHash('sha256').update(TEST_USER_PASSWORD).digest('hex');
|
||||
|
||||
test.describe('Passkey Browser Flow', () => {
|
||||
let userJwt: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const api = await apiRequest.newContext();
|
||||
try {
|
||||
// Enable user registration
|
||||
await api.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { enable: true, enableMailVerify: false },
|
||||
});
|
||||
// Register user with hashed password (matching frontend behavior)
|
||||
await api.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
|
||||
});
|
||||
// Login to get JWT for localStorage injection
|
||||
const loginRes = await api.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
|
||||
});
|
||||
const body = await loginRes.json();
|
||||
userJwt = body.jwt;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('register passkey, then login with passkey', async ({ page, context }) => {
|
||||
// Set up virtual authenticator via CDP
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('WebAuthn.enable');
|
||||
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
|
||||
options: {
|
||||
protocol: 'ctap2',
|
||||
transport: 'internal',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// === Step 1: Login via localStorage injection ===
|
||||
// Inject JWT into localStorage to skip UI login flow.
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
// VueUse's useStorage with string default stores raw strings (no JSON)
|
||||
await page.evaluate((jwt) => {
|
||||
localStorage.setItem('userJwt', jwt);
|
||||
}, userJwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
// Wait for user settings to load (shows user email)
|
||||
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// === Step 2: Click "User Settings" tab ===
|
||||
await page.getByText('User Settings').click();
|
||||
|
||||
// === Step 3: Create a passkey ===
|
||||
await page.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
// Fill passkey name in the modal
|
||||
const createModal = page.locator('.n-dialog');
|
||||
await expect(createModal).toBeVisible({ timeout: 5_000 });
|
||||
await createModal.getByRole('textbox').fill('E2E Test Passkey');
|
||||
|
||||
// Click the Create Passkey button inside the modal
|
||||
await createModal.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
// Wait for success — modal should close
|
||||
await expect(createModal).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// === Step 4: Verify passkey appears in the list ===
|
||||
await page.getByRole('button', { name: 'Show Passkey List' }).click();
|
||||
|
||||
const listModal = page.locator('.n-card-header:has-text("Show Passkey List")').locator('..');
|
||||
await expect(page.getByText('E2E Test Passkey')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Close the list modal
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// === Step 5: Logout ===
|
||||
await page.getByRole('button', { name: 'Logout' }).click();
|
||||
const logoutModal = page.locator('.n-dialog');
|
||||
await expect(logoutModal).toBeVisible({ timeout: 5_000 });
|
||||
await logoutModal.getByRole('button', { name: 'Logout' }).click();
|
||||
|
||||
// Wait for logout to complete and navigate to user page
|
||||
await page.waitForTimeout(2000);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
// === Step 6: Login with passkey ===
|
||||
const passkeyBtn = page.getByRole('button', { name: 'Login with Passkey' });
|
||||
await expect(passkeyBtn).toBeVisible({ timeout: 10_000 });
|
||||
await passkeyBtn.click();
|
||||
|
||||
// Virtual authenticator handles the WebAuthn ceremony automatically
|
||||
// Wait for login to complete — user email should appear
|
||||
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
||||
await cdp.detach();
|
||||
}
|
||||
});
|
||||
});
|
||||
105
e2e/tests/browser/reply-html.spec.ts
Normal file
105
e2e/tests/browser/reply-html.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
createTestAddress,
|
||||
seedTestMail,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
} from '../../fixtures/test-helpers';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
|
||||
test.describe('Reply HTML & XSS Sanitization', () => {
|
||||
test('reply to HTML email — XSS payloads stripped, HTML preserved', async ({ page }) => {
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
await deleteAllMailpitMessages(api);
|
||||
|
||||
const created = await createTestAddress(api, 'reply-xss');
|
||||
jwt = created.jwt;
|
||||
const address = created.address;
|
||||
|
||||
// Request send access so Reply can navigate to compose form
|
||||
await requestSendAccess(api, jwt);
|
||||
|
||||
// Seed email with XSS payloads embedded in HTML
|
||||
const xssHtml = [
|
||||
'<div>',
|
||||
' <h1>Important Message</h1>',
|
||||
' <p>Please review this content.</p>',
|
||||
' <script>alert("xss")</script>',
|
||||
' <img src=x onerror="alert(1)">',
|
||||
' <a href="javascript:alert(2)">click me</a>',
|
||||
' <p style="color:red">Styled paragraph</p>',
|
||||
'</div>',
|
||||
].join('\n');
|
||||
|
||||
await seedTestMail(api, address, {
|
||||
subject: 'XSS Test Email',
|
||||
html: xssHtml,
|
||||
from: 'attacker@test.example.com',
|
||||
});
|
||||
|
||||
// Single dialog handler with phase tracking.
|
||||
// During email rendering, the mail viewer uses an unsandboxed iframe so
|
||||
// inline event handlers like onerror may fire — we dismiss those.
|
||||
// After clicking Reply, any dialog means the compose path failed to sanitize.
|
||||
let inComposePhase = false;
|
||||
let composeDialogAppeared = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
if (inComposePhase) composeDialogAppeared = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
|
||||
// Login with English locale
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// Open the email (use listitem to avoid strict mode violation
|
||||
// when detail panel also shows the subject)
|
||||
const mailItem = page.getByRole('listitem').getByText('XSS Test Email');
|
||||
await expect(mailItem).toBeVisible({ timeout: 10_000 });
|
||||
await mailItem.click();
|
||||
|
||||
// Wait for Reply button to appear — signals email content has rendered
|
||||
const replyButton = page.locator('button').filter({ hasText: /Reply/i }).first();
|
||||
await expect(replyButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click Reply — from here on, dialogs indicate sanitization failure (#857)
|
||||
inComposePhase = true;
|
||||
await replyButton.click();
|
||||
|
||||
// In the reply compose area, check that the forwarded HTML is sanitized:
|
||||
// - <script> tags should be removed
|
||||
// - onerror attributes should be removed
|
||||
// - javascript: URLs should be removed
|
||||
const composeArea = page.locator('.ql-editor, [contenteditable], textarea').first();
|
||||
await expect(composeArea).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Use inputValue() for <textarea> (Vue v-model sets .value, not innerHTML),
|
||||
// fall back to innerHTML() for contenteditable elements
|
||||
const tagName = await composeArea.evaluate(el => el.tagName.toLowerCase());
|
||||
const content = tagName === 'textarea'
|
||||
? await composeArea.inputValue()
|
||||
: await composeArea.innerHTML();
|
||||
|
||||
// Verify content is non-empty (guard against vacuous pass)
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
// XSS vectors must be stripped
|
||||
expect(content).not.toContain('<script>');
|
||||
expect(content).not.toContain('onerror');
|
||||
expect(content).not.toContain('javascript:');
|
||||
|
||||
// No XSS dialog should have fired in the compose area
|
||||
expect(composeDialogAppeared).toBe(false);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
134
e2e/tests/browser/send-mail-compose.spec.ts
Normal file
134
e2e/tests/browser/send-mail-compose.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { test, expect, request as apiRequest, type Page } from '@playwright/test';
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
requestSendAccess,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const expectEditorOriginsToAlign = async (page: Page) => {
|
||||
const editorOriginDelta = await page.locator('.compose-textarea').evaluate((editor) => {
|
||||
const textareaElement = editor.querySelector('textarea');
|
||||
const placeholder = editor.querySelector('.n-input__placeholder');
|
||||
if (!textareaElement) throw new Error('compose textarea element not found');
|
||||
if (!placeholder) throw new Error('compose placeholder element not found');
|
||||
const textareaBox = textareaElement.getBoundingClientRect();
|
||||
const placeholderBox = placeholder.getBoundingClientRect();
|
||||
const textareaStyle = getComputedStyle(textareaElement);
|
||||
const placeholderStyle = getComputedStyle(placeholder);
|
||||
return {
|
||||
x: textareaBox.x + parseFloat(textareaStyle.paddingLeft)
|
||||
- placeholderBox.x - parseFloat(placeholderStyle.paddingLeft),
|
||||
y: textareaBox.y + parseFloat(textareaStyle.paddingTop)
|
||||
- placeholderBox.y - parseFloat(placeholderStyle.paddingTop),
|
||||
};
|
||||
});
|
||||
expect(Math.abs(editorOriginDelta.x)).toBeLessThan(1);
|
||||
expect(Math.abs(editorOriginDelta.y)).toBeLessThan(1);
|
||||
};
|
||||
|
||||
test.describe('Send mail composer', () => {
|
||||
test('edits a draft, changes format, and previews HTML', async ({ page }) => {
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'compose-ui');
|
||||
jwt = created.jwt;
|
||||
await requestSendAccess(api, jwt);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.composer-title')).toContainText(created.address);
|
||||
|
||||
const expectedFieldOrder = [
|
||||
'send-mail-sender-address',
|
||||
'send-mail-sender-name',
|
||||
'send-mail-recipient-address',
|
||||
'send-mail-recipient-name',
|
||||
];
|
||||
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||
(inputs) => inputs.map((input) => input.id)
|
||||
);
|
||||
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||
|
||||
const textarea = page.locator('.compose-textarea textarea');
|
||||
await expectEditorOriginsToAlign(page);
|
||||
|
||||
const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
|
||||
const subject = page.getByRole('textbox', { name: /^Subject/ });
|
||||
await recipient.fill('recipient@test.example.com');
|
||||
await subject.fill('Composer preview');
|
||||
|
||||
await page.getByText('HTML', { exact: true }).click();
|
||||
const htmlContent = [
|
||||
'<h1>Preview heading</h1><p>Preview body</p>',
|
||||
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
|
||||
].join('');
|
||||
let previewDialogAppeared = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
previewDialogAppeared = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await textarea.fill(htmlContent);
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
|
||||
const preview = page.locator('.compose-preview');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText('Preview heading');
|
||||
await expect(preview.locator('script, [onerror]')).toHaveCount(0);
|
||||
expect(previewDialogAppeared).toBe(false);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(preview).toBeHidden();
|
||||
await expect(recipient).toHaveValue('recipient@test.example.com');
|
||||
await expect(subject).toHaveValue('Composer preview');
|
||||
await expect(textarea).toHaveValue(htmlContent);
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
|
||||
await page.getByText('Enviar correo', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
|
||||
await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('HTML', { exact: true }).click();
|
||||
const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
|
||||
await expect(previewButton).toBeVisible();
|
||||
const previewBox = await previewButton.boundingBox();
|
||||
expect(previewBox).not.toBeNull();
|
||||
expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the Admin field order and editor placeholder aligned', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'mails');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
const expectedFieldOrder = [
|
||||
'admin-send-mail-sender-address',
|
||||
'admin-send-mail-sender-name',
|
||||
'admin-send-mail-recipient-address',
|
||||
'admin-send-mail-recipient-name',
|
||||
];
|
||||
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||
(inputs) => inputs.map((input) => input.id)
|
||||
);
|
||||
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||
await expectEditorOriginsToAlign(page);
|
||||
});
|
||||
});
|
||||
135
e2e/tests/browser/user-address-pagination.spec.ts
Normal file
135
e2e/tests/browser/user-address-pagination.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function saveUserSettings(request: APIRequestContext, settings: Record<string, unknown>) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/user_settings`, { data: settings });
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `address-browser-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerResponse = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerResponse.ok()).toBe(true);
|
||||
|
||||
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginResponse.ok()).toBe(true);
|
||||
const { jwt } = await loginResponse.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { email, jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
async function bindAddress(request: APIRequestContext, userJwt: string, addressJwt: string) {
|
||||
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${addressJwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('User address pagination browser flow', () => {
|
||||
test('paginates addresses and filters mail', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const request = await apiRequest.newContext();
|
||||
const createdAddresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsResponse = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
originalUserSettings = await settingsResponse.json();
|
||||
await saveUserSettings(request, {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
});
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
|
||||
for (let index = 0; index < 21; index += 1) {
|
||||
const address = await createTestAddress(request, `browser-page-${index}-`);
|
||||
createdAddresses.push(address);
|
||||
await bindAddress(request, user.jwt, address.jwt);
|
||||
}
|
||||
const defaultAddressPageResponse = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(defaultAddressPageResponse.ok()).toBe(true);
|
||||
const defaultAddressPage = await defaultAddressPageResponse.json();
|
||||
expect(defaultAddressPage.count).toBe(21);
|
||||
expect(defaultAddressPage.results).toHaveLength(20);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate((userJwt) => {
|
||||
localStorage.setItem('userJwt', userJwt);
|
||||
}, user.jwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||
const pagination = page.locator('.n-pagination').first();
|
||||
const addressRows = page.locator('.n-data-table-tbody .n-data-table-tr');
|
||||
await expect(pagination).toContainText(/Total:\s*21/);
|
||||
await expect(addressRows).toHaveCount(20);
|
||||
|
||||
await pagination.locator('.n-pagination-item').filter({ hasText: /^2$/ }).click();
|
||||
await expect(addressRows).toHaveCount(1);
|
||||
|
||||
const selectedAddress = createdAddresses[20];
|
||||
|
||||
const initialMailboxAddressesResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/user_api/bind_address'
|
||||
&& url.searchParams.get('limit') === '100';
|
||||
});
|
||||
await page.getByText('Mail Box', { exact: true }).click();
|
||||
const initialMailboxResponse = await initialMailboxAddressesResponse;
|
||||
expect(initialMailboxResponse.ok()).toBe(true);
|
||||
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
|
||||
await mailboxAddressSelect.click();
|
||||
|
||||
const mailboxOptions = page.locator('.n-base-select-menu:visible');
|
||||
await expect(mailboxOptions).toContainText(selectedAddress.address);
|
||||
const filteredMailResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/user_api/mails'
|
||||
&& url.searchParams.get('address') === selectedAddress.address;
|
||||
});
|
||||
await mailboxOptions.getByText(selectedAddress.address, { exact: true }).click();
|
||||
expect((await filteredMailResponse).ok()).toBe(true);
|
||||
} finally {
|
||||
try {
|
||||
try {
|
||||
await Promise.allSettled(createdAddresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await saveUserSettings(request, originalUserSettings);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
137
e2e/tests/browser/user-send-mail.spec.ts
Normal file
137
e2e/tests/browser/user-send-mail.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `user-send-browser-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { email, jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
test.describe('User send mail page', () => {
|
||||
test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let userId: number | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
const address = await createTestAddress(request, 'usr-browser-');
|
||||
const secondAddress = await createTestAddress(request, 'usr-second-');
|
||||
addresses.push(address, secondAddress);
|
||||
for (const boundAddress of addresses) {
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${boundAddress.jwt}`,
|
||||
'x-user-token': user.jwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
}
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate((userJwt) => {
|
||||
localStorage.setItem('userJwt', userJwt);
|
||||
}, user.jwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||
const credentialResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname
|
||||
=== `/user_api/bind_address_jwt/${address.address_id}`
|
||||
));
|
||||
const addressRow = page.getByRole('row').filter({ hasText: address.address });
|
||||
await addressRow.getByRole('button', { name: 'Address Credential' }).click();
|
||||
expect((await credentialResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('dialog')).toContainText(address.address);
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const initialSettingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${secondAddress.address_id}/settings`
|
||||
));
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
expect((await initialSettingsResponse).ok()).toBe(true);
|
||||
await expect(page.locator('.composer-title h2')).toHaveText('Compose email');
|
||||
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address.address_id}/settings`
|
||||
));
|
||||
await page.locator('.address-picker-select').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: address.address }).click();
|
||||
expect((await settingsResponse).ok()).toBe(true);
|
||||
await expect(page.locator('.address-picker-select')).toContainText(address.address);
|
||||
|
||||
const subject = `Browser user send ${Date.now()}`;
|
||||
await page.getByRole('textbox', { name: /^Recipient address/ })
|
||||
.fill('recipient@test.example.com');
|
||||
await page.getByRole('textbox', { name: /^Subject/ }).fill(subject);
|
||||
await page.locator('.compose-textarea textarea').fill('Sent from the user page');
|
||||
|
||||
const sendResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address.address_id}/send_mail`
|
||||
));
|
||||
const sendboxResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname === '/user_api/sendbox'
|
||||
));
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
expect((await sendResponse).ok()).toBe(true);
|
||||
expect((await sendboxResponse).ok()).toBe(true);
|
||||
|
||||
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
|
||||
await expect(page.locator('.n-thing-header__title').filter({ hasText: subject }))
|
||||
.toHaveText(subject, { timeout: 15_000 });
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
110
e2e/tests/browser/webhook-presets.spec.ts
Normal file
110
e2e/tests/browser/webhook-presets.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { FRONTEND_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
|
||||
test.describe('Webhook Presets', () => {
|
||||
test('selecting each preset fills valid settings', async ({ page, context }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
// Block popups (presets open doc URLs in new tabs)
|
||||
context.on('page', (p) => p.close());
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'webhook-preset');
|
||||
jwt = created.jwt;
|
||||
|
||||
// Login via JWT
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// Click "Webhook Settings" in the sidebar menu
|
||||
const webhookMenu = page.getByText('Webhook Settings');
|
||||
await expect(webhookMenu).toBeVisible({ timeout: 10_000 });
|
||||
await webhookMenu.click();
|
||||
|
||||
// Verify presets button is visible
|
||||
const presetsBtn = page.getByRole('button', { name: 'Presets' });
|
||||
await expect(presetsBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Helper to get form field value by label text
|
||||
const getFieldValue = async (label: string): Promise<string> => {
|
||||
// Find the label, then get the sibling textbox in the same form row
|
||||
const row = page.locator('div', { hasText: new RegExp(`^${label}$`) }).locator('..');
|
||||
const textbox = row.getByRole('textbox');
|
||||
return textbox.inputValue();
|
||||
};
|
||||
|
||||
// Define expected presets and their key fields
|
||||
const expectedPresets = [
|
||||
{
|
||||
name: 'Message Pusher',
|
||||
urlPattern: 'msgpusher.com',
|
||||
bodyKeys: ['token', 'title', 'description', 'content'],
|
||||
},
|
||||
{
|
||||
name: 'Bark',
|
||||
urlPattern: 'api.day.app',
|
||||
bodyKeys: ['title', 'body', 'group'],
|
||||
},
|
||||
{
|
||||
name: 'ntfy',
|
||||
urlPattern: 'ntfy.sh',
|
||||
bodyKeys: ['topic', 'title', 'message', 'tags'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const preset of expectedPresets) {
|
||||
// Open dropdown and select preset
|
||||
await presetsBtn.click();
|
||||
const option = page.locator('.n-dropdown-option', { hasText: preset.name });
|
||||
await expect(option).toBeVisible({ timeout: 5_000 });
|
||||
await option.click();
|
||||
// Wait for dropdown to close, then for URL field to contain preset pattern
|
||||
await expect(option).toBeHidden({ timeout: 5_000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const allTextboxes = page.getByRole('textbox');
|
||||
const count = await allTextboxes.count();
|
||||
|
||||
// Find URL, HEADERS, BODY values by reading all textboxes
|
||||
let urlValue = '';
|
||||
let headersValue = '';
|
||||
let bodyValue = '';
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const val = await allTextboxes.nth(i).inputValue();
|
||||
if (val.includes(preset.urlPattern)) {
|
||||
urlValue = val;
|
||||
} else if (val.includes('Content-Type')) {
|
||||
headersValue = val;
|
||||
} else if (val.includes('${subject}')) {
|
||||
bodyValue = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify URL
|
||||
expect(urlValue, `${preset.name}: URL should contain ${preset.urlPattern}`).toContain(preset.urlPattern);
|
||||
|
||||
// Verify HEADERS is valid JSON with Content-Type
|
||||
expect(headersValue, `${preset.name}: HEADERS should not be empty`).toBeTruthy();
|
||||
const headers = JSON.parse(headersValue);
|
||||
expect(headers, `${preset.name}: headers should have Content-Type`).toHaveProperty('Content-Type', 'application/json');
|
||||
|
||||
// Verify BODY is valid JSON with expected keys
|
||||
expect(bodyValue, `${preset.name}: BODY should not be empty`).toBeTruthy();
|
||||
const body = JSON.parse(bodyValue);
|
||||
for (const key of preset.bodyKeys) {
|
||||
expect(body, `${preset.name}: body should have key "${key}"`).toHaveProperty(key);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
276
e2e/tests/smtp-proxy/imap-proxy.spec.ts
Normal file
276
e2e/tests/smtp-proxy/imap-proxy.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { createTestAddress, seedTestMail, sendTestMail, deleteAddress, deleteAllMailpitMessages, onMailpitMessage } from '../../fixtures/test-helpers';
|
||||
|
||||
const IMAP_HOST = process.env.SMTP_PROXY_HOST || 'smtp-proxy';
|
||||
const IMAP_PORT = parseInt(process.env.SMTP_PROXY_IMAP_PORT || '11143', 10);
|
||||
|
||||
function createClient(user: string, pass: string) {
|
||||
return new ImapFlow({
|
||||
host: IMAP_HOST,
|
||||
port: IMAP_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('IMAP Proxy', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
({ jwt, address } = await createTestAddress(request, 'imap-e2e'));
|
||||
await seedTestMail(request, address, { subject: 'IMAP Test 1', text: 'First test email' });
|
||||
await seedTestMail(request, address, { subject: 'IMAP Test 2', text: 'Second test email' });
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('login with JWT token', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
expect(client.usable).toBe(true);
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('login with wrong password fails', async () => {
|
||||
const client = createClient(address, 'wrong-password');
|
||||
await expect(client.connect()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('LIST returns INBOX', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const mailboxes = await client.list();
|
||||
const names = mailboxes.map(m => m.path);
|
||||
expect(names).toContain('INBOX');
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('SELECT INBOX returns message count', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
expect(client.mailbox).toBeTruthy();
|
||||
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('STATUS returns MESSAGES and UIDNEXT', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const status = await client.status('INBOX', { messages: true, uidNext: true });
|
||||
expect(status.messages).toBeGreaterThanOrEqual(2);
|
||||
expect(status.uidNext).toBeGreaterThan(0);
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH headers returns Subject', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { headers: true });
|
||||
const headers = msg.headers.toString();
|
||||
expect(headers).toContain('Subject:');
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH body returns content', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true });
|
||||
expect(msg.source.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('SEARCH ALL returns message numbers', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const results = await client.search({ all: true });
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('STORE sets flags', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const result = await client.messageFlagsAdd('1', ['\\Seen']);
|
||||
expect(result).toBe(true);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('UID FETCH works', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const results = await client.search({ all: true });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
const seqMsg = await client.fetchOne(String(results[0]), { uid: true, flags: true });
|
||||
expect(seqMsg.uid).toBeGreaterThan(0);
|
||||
const uidMsg = await client.fetchOne(String(seqMsg.uid), { uid: true, flags: true }, { uid: true });
|
||||
expect(uidMsg.uid).toBe(seqMsg.uid);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH source contains valid MIME with Content-Type and seeded body', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true, envelope: true });
|
||||
const source = msg.source.toString('utf-8');
|
||||
expect(source).toContain('Content-Type:');
|
||||
expect(source).toContain('Subject:');
|
||||
// No duplicate From headers (regression: getBodyFile returned full MIME)
|
||||
const fromMatches = source.match(/^From:/gm);
|
||||
expect(fromMatches).toHaveLength(1);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('RFC822.SIZE matches actual source length', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true, size: true });
|
||||
const source = msg.source.toString('utf-8');
|
||||
expect(msg.size).toBe(Buffer.byteLength(source, 'utf-8'));
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH all messages returns correct sequence numbers', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const messages: { seq: number; uid: number }[] = [];
|
||||
for await (const msg of client.fetch('1:*', { uid: true })) {
|
||||
messages.push({ seq: msg.seq, uid: msg.uid });
|
||||
}
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
// Sequence numbers must be consecutive starting from 1
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
expect(messages[i].seq).toBe(i + 1);
|
||||
}
|
||||
// UIDs must be strictly ascending
|
||||
for (let i = 1; i < messages.length; i++) {
|
||||
expect(messages[i].uid).toBeGreaterThan(messages[i - 1].uid);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('LIST returns SENT mailbox', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const mailboxes = await client.list();
|
||||
const names = mailboxes.map(m => m.path);
|
||||
expect(names).toContain('SENT');
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('SELECT INBOX includes UIDVALIDITY and UIDNEXT', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
expect(client.mailbox!.uidValidity).toBeGreaterThan(0);
|
||||
expect(client.mailbox!.uidNext).toBeGreaterThan(0);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IMAP Proxy — SENT mailbox', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
const sentSubject = `IMAP Sent Test ${Date.now()}`;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
({ jwt, address } = await createTestAddress(request, 'imap-sent'));
|
||||
|
||||
const listener = onMailpitMessage((m) => m.Subject === sentSubject);
|
||||
await listener.ready;
|
||||
|
||||
await sendTestMail(request, address, {
|
||||
to_mail: `recipient@test.example.com`,
|
||||
subject: sentSubject,
|
||||
content: 'E2E sent mail body',
|
||||
});
|
||||
await listener.message;
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('SELECT SENT returns message count', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('SENT');
|
||||
try {
|
||||
expect(client.mailbox).toBeTruthy();
|
||||
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH SENT source contains valid MIME', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('SENT');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true, envelope: true });
|
||||
const source = msg.source.toString('utf-8');
|
||||
expect(source.length).toBeGreaterThan(50);
|
||||
expect(source).toContain('Content-Type:');
|
||||
expect(source).toContain('Subject:');
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
});
|
||||
81
e2e/tests/smtp-proxy/imap-tls.spec.ts
Normal file
81
e2e/tests/smtp-proxy/imap-tls.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const IMAP_TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls';
|
||||
const IMAP_TLS_PORT = parseInt(process.env.SMTP_PROXY_TLS_IMAP_PORT || '11144', 10);
|
||||
|
||||
function createClient(user: string, pass: string) {
|
||||
return new ImapFlow({
|
||||
host: IMAP_TLS_HOST,
|
||||
port: IMAP_TLS_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('IMAP Proxy — STARTTLS', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
({ jwt, address } = await createTestAddress(request, 'imap-tls'));
|
||||
await seedTestMail(request, address, { subject: 'IMAP TLS Test 1', text: 'First TLS test email' });
|
||||
await seedTestMail(request, address, { subject: 'IMAP TLS Test 2', text: 'Second TLS test email' });
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('login with JWT over STARTTLS', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
expect(client.usable).toBe(true);
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('login with wrong password fails over STARTTLS', async () => {
|
||||
const client = createClient(address, 'wrong-password');
|
||||
await expect(client.connect()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('LIST returns INBOX over STARTTLS', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const mailboxes = await client.list();
|
||||
const names = mailboxes.map(m => m.path);
|
||||
expect(names).toContain('INBOX');
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('SELECT INBOX returns messages over STARTTLS', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
expect(client.mailbox).toBeTruthy();
|
||||
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH source over STARTTLS contains valid MIME', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true });
|
||||
const source = msg.source.toString('utf-8');
|
||||
expect(source).toContain('Content-Type:');
|
||||
expect(source).toContain('Subject:');
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
});
|
||||
112
e2e/tests/smtp-proxy/smtp-proxy.spec.ts
Normal file
112
e2e/tests/smtp-proxy/smtp-proxy.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import nodemailer from 'nodemailer';
|
||||
import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
WORKER_URL,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const SMTP_HOST = process.env.SMTP_PROXY_HOST || 'smtp-proxy';
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PROXY_SMTP_PORT || '8025', 10);
|
||||
|
||||
function createTransport(user: string, pass: string) {
|
||||
return nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('SMTP Proxy', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
({ jwt, address } = await createTestAddress(request, 'smtp-e2e'));
|
||||
await requestSendAccess(request, jwt);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('send plain text email via SMTP', async ({ request }) => {
|
||||
const subject = `SMTP Plain ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
text: 'Hello from SMTP E2E test',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('send HTML email via SMTP', async ({ request }) => {
|
||||
const subject = `SMTP HTML ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
html: '<h1>Hello</h1><p>HTML E2E test</p>',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('auth with wrong password fails', async () => {
|
||||
const transport = createTransport(address, 'wrong-password');
|
||||
await expect(
|
||||
transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject: 'Should fail',
|
||||
text: 'This should not be sent',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('sent mail appears in sendbox API', async ({ request }) => {
|
||||
const subject = `SMTP Sendbox ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTransport(address, jwt);
|
||||
await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
text: 'Check sendbox',
|
||||
});
|
||||
await listener.message;
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
const found = results.some((r: any) => {
|
||||
const raw = JSON.parse(r.raw);
|
||||
return raw.subject === subject;
|
||||
});
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
114
e2e/tests/smtp-proxy/smtp-tls.spec.ts
Normal file
114
e2e/tests/smtp-proxy/smtp-tls.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import nodemailer from 'nodemailer';
|
||||
import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls';
|
||||
const TLS_SMTP_PORT = parseInt(process.env.SMTP_PROXY_TLS_SMTP_PORT || '8026', 10);
|
||||
|
||||
function createTlsTransport(user: string, pass: string) {
|
||||
return nodemailer.createTransport({
|
||||
host: TLS_HOST,
|
||||
port: TLS_SMTP_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
requireTLS: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createNoTlsTransport(user: string, pass: string) {
|
||||
return nodemailer.createTransport({
|
||||
host: TLS_HOST,
|
||||
port: TLS_SMTP_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('SMTP Proxy — STARTTLS', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
({ jwt, address } = await createTestAddress(request, 'smtp-tls'));
|
||||
await requestSendAccess(request, jwt);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('send plain text email via STARTTLS', async () => {
|
||||
const subject = `SMTP TLS Plain ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTlsTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
text: 'Hello from SMTP STARTTLS E2E test',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('send HTML email via STARTTLS', async () => {
|
||||
const subject = `SMTP TLS HTML ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTlsTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
html: '<h1>Hello</h1><p>STARTTLS HTML E2E test</p>',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('connection without STARTTLS still works', async () => {
|
||||
const subject = `SMTP TLS NoForce ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createNoTlsTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
text: 'Hello without forced STARTTLS',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('auth with wrong password fails over STARTTLS', async () => {
|
||||
const transport = createTlsTransport(address, 'wrong-password');
|
||||
await expect(
|
||||
transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject: 'Should fail',
|
||||
text: 'This should not be sent',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
3
frontend/.vscode/extensions.json
vendored
3
frontend/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "0.5.1",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,39 +8,52 @@
|
||||
"build": "vite build -m prod --emptyOutDir",
|
||||
"build:release": "vite build -m example --emptyOutDir",
|
||||
"build:pages": "vite build -m pages --emptyOutDir",
|
||||
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
|
||||
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
|
||||
"build:telegram:pages": "VITE_IS_TELEGRAM=true vite build -m pages --emptyOutDir",
|
||||
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
|
||||
"preview": "vite preview",
|
||||
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
|
||||
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
|
||||
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
|
||||
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
|
||||
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
|
||||
"deploy:actions": "npm run build && wrangler pages deploy ./dist",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@unhead/vue": "^1.9.12",
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vueuse/core": "^10.10.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@unhead/vue": "^2.1.17",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.2",
|
||||
"axios": "^1.19.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.1.8",
|
||||
"naive-ui": "^2.38.2",
|
||||
"postal-mime": "^2.2.5",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.45.0",
|
||||
"postal-mime": "^2.7.6",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.4.27",
|
||||
"vue": "^3.5.41",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.3.2"
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"unplugin-auto-import": "^0.17.6",
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
"vite": "^5.2.12",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"workbox-window": "^7.1.0",
|
||||
"wrangler": "^3.58.0"
|
||||
}
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"jsdom": "^28.1.0",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^4.1.11",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1",
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
7250
frontend/pnpm-lock.yaml
generated
7250
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,52 @@
|
||||
<script setup>
|
||||
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import {
|
||||
darkTheme,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, watchEffect } from 'vue'
|
||||
import { useScript } from '@unhead/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from './store'
|
||||
import { useIsMobile } from './utils/composables'
|
||||
import Header from './views/Header.vue';
|
||||
import Footer from './views/Footer.vue';
|
||||
|
||||
import { api } from './api'
|
||||
import { getNaiveLocaleConfig } from './i18n/naive-locale'
|
||||
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
|
||||
|
||||
const {
|
||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||
} = useGlobalState()
|
||||
const { locale } = useI18n({});
|
||||
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
|
||||
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
|
||||
const { locale } = useI18n({ useScope: 'global' });
|
||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
|
||||
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
|
||||
const isMobile = useIsMobile()
|
||||
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
|
||||
const showAd = computed(() => !isMobile.value && adClient && adSlot);
|
||||
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
|
||||
|
||||
watchEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
|
||||
})
|
||||
|
||||
// Load Google Ad script at top level (not inside onMounted)
|
||||
if (showAd.value) {
|
||||
useScript({
|
||||
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
||||
async: true,
|
||||
crossorigin: "anonymous",
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await api.getUserSettings();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
|
||||
|
||||
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
|
||||
@@ -30,6 +58,13 @@ onMounted(async () => {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
// check if google ad is enabled
|
||||
if (showAd.value) {
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
}
|
||||
|
||||
|
||||
// check if telegram is enabled
|
||||
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
|
||||
if (
|
||||
@@ -51,27 +86,39 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :locale="localeConfig" :theme="theme">
|
||||
<n-config-provider :locale="localeConfig.locale" :date-locale="localeConfig.dateLocale" :theme="theme">
|
||||
<n-global-style />
|
||||
<n-spin description="loading..." :show="loading">
|
||||
<n-message-provider>
|
||||
<n-grid x-gap="12" :cols="12">
|
||||
<n-gi v-if="showSideMargin" span="1"></n-gi>
|
||||
<n-gi :span="!showSideMargin ? 12 : 10">
|
||||
<div class="main">
|
||||
<n-space vertical>
|
||||
<n-layout style="min-height: 80vh;">
|
||||
<Header />
|
||||
<router-view></router-view>
|
||||
</n-layout>
|
||||
<Footer />
|
||||
</n-space>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi v-if="showSideMargin" span="1"></n-gi>
|
||||
</n-grid>
|
||||
<n-back-top />
|
||||
</n-message-provider>
|
||||
<n-notification-provider container-style="margin-top: 60px;">
|
||||
<n-message-provider container-style="margin-top: 20px;">
|
||||
<n-grid x-gap="12" :cols="gridMaxCols">
|
||||
<n-gi v-if="showSideMargin" span="1">
|
||||
<div class="side" v-if="showAd">
|
||||
<ins class="adsbygoogle" style="display:block" :data-ad-client="adClient" :data-ad-slot="adSlot"
|
||||
data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi :span="!showSideMargin ? gridMaxCols : (gridMaxCols - 2)">
|
||||
<div class="main">
|
||||
<n-space vertical>
|
||||
<n-layout style="min-height: 80vh;">
|
||||
<Header />
|
||||
<router-view></router-view>
|
||||
</n-layout>
|
||||
<Footer />
|
||||
</n-space>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi v-if="showSideMargin" span="1">
|
||||
<div class="side" v-if="showAd">
|
||||
<ins class="adsbygoogle" style="display:block" :data-ad-client="adClient" :data-ad-slot="adSlot"
|
||||
data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-back-top />
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-spin>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -82,6 +129,16 @@ onMounted(async () => {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) and (max-width: 1024px) {
|
||||
:where(input, textarea, select, [contenteditable="true"]) {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
:where(.n-input, .n-input-number, .n-base-selection, .n-input-group-label) {
|
||||
--n-font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user