mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-10 01:47:03 +08:00
Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e32cf472b | ||
|
|
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 |
@@ -0,0 +1,3 @@
|
||||
config.json
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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`
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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/.
|
||||
---
|
||||
|
||||
# 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` |
|
||||
|
||||
Note: `vitepress-docs/` uses `--latest` (crosses semver ranges); other packages upgrade within ranges only.
|
||||
|
||||
## Post-upgrade checklist
|
||||
|
||||
1. Inspect `git diff` on `package.json` / `pnpm-lock.yaml` 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`
|
||||
3. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
|
||||
4. 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.
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: cf-temp-mail-usage
|
||||
description: Read mails from a cloudflare_temp_email mailbox using a user-supplied Address JWT and API base URL. Use when the user (or an agent such as OpenClaw / Codex / Cursor) needs to list the inbox, fetch a specific message, or extract a verification code / magic link. Prefers the server-parsed endpoints so the agent gets subject/text/html/attachments directly. Does NOT handle mailbox creation — the user provides the JWT themselves.
|
||||
---
|
||||
|
||||
# Temp-Mail Read-Only Usage
|
||||
|
||||
Consume an existing mailbox. The user hands over the JWT (obtained in a browser after creating an address); the agent only reads mail.
|
||||
|
||||
## Inputs the user must provide
|
||||
|
||||
- `BASE` — API base URL, e.g. `https://mail.example.com` or the Worker's `*.workers.dev` host.
|
||||
- `JWT` — Address JWT. In the frontend it is stored in `localStorage` under the key `jwt` (raw string, no JSON wrap).
|
||||
- *(optional)* `SITE_PASSWORD` — only if the deployment enabled `x-custom-auth`.
|
||||
|
||||
If anything is missing, ask the user before making requests.
|
||||
|
||||
## Required headers
|
||||
|
||||
- `Authorization: Bearer <JWT>` — on every `/api/*` request.
|
||||
- `x-custom-auth: <SITE_PASSWORD>` — only when the site requires it.
|
||||
- `x-lang: en` or `zh` — optional, error-message language.
|
||||
|
||||
Do not send the Address JWT as `x-user-token` — that is a different JWT type and will yield `401 InvalidAddressCredentialMsg`.
|
||||
|
||||
## Endpoints (read-only)
|
||||
|
||||
| Task | Method | Path | Returns |
|
||||
| --------------------------- | ------ | ---------------------------------- | ----------------------------------------- |
|
||||
| Address info | GET | `/api/settings` | `{ address, send_balance }` |
|
||||
| **List parsed mails** | GET | `/api/parsed_mails?limit=&offset=` | `{ results: [parsedMail], count }` |
|
||||
| **Get one parsed mail** | GET | `/api/parsed_mail/:id` | `parsedMail` |
|
||||
| List raw mails | GET | `/api/mails?limit=&offset=` | `{ results: [{...,raw}], count }` |
|
||||
| Get one raw mail | GET | `/api/mail/:id` | `{ ..., raw }` |
|
||||
|
||||
`limit` 1–100, `offset` 0-based. On `429`, back off.
|
||||
|
||||
**Prefer the `parsed_*` endpoints.** They run the same `commonParseMail` (postal-mime) the frontend uses and return structured fields directly, so the agent does not need to ship a MIME parser.
|
||||
|
||||
`parsedMail` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"message_id": "<...>",
|
||||
"source": "noreply@foo.com",
|
||||
"to": "abc@yourdomain.com",
|
||||
"created_at": "2026-04-21 10:00:00",
|
||||
"sender": "Foo <noreply@foo.com>",
|
||||
"subject": "Your code is 123456",
|
||||
"text": "Your code is 123456\n",
|
||||
"html": "<p>Your code is <b>123456</b></p>",
|
||||
"attachments": [
|
||||
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Attachment **binary content is not included** in `parsed_*` responses — only metadata. If you need the bytes, fetch the raw mail via `/api/mail/:id` and parse it client-side (see below).
|
||||
|
||||
## Recipes
|
||||
|
||||
### 1. Smoke-test the JWT
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/settings" -H "Authorization: Bearer $JWT"
|
||||
# → { "address": "abc123@example.com", "send_balance": 0 }
|
||||
```
|
||||
|
||||
If this returns `401`, JWT is wrong / expired / mismatched with `BASE` — ask the user for a fresh one.
|
||||
|
||||
### 2. List the inbox (parsed)
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
### 3. Get one mail (parsed)
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/parsed_mail/<id>" -H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
### 4. Extract a verification code (end-to-end, parsed API)
|
||||
|
||||
```python
|
||||
import re, time, requests
|
||||
|
||||
BASE, JWT = "<BASE>", "<JWT>"
|
||||
H = {"Authorization": f"Bearer {JWT}"}
|
||||
|
||||
def wait_for_code(pattern=r"\b\d{4,8}\b", timeout=120, poll=3):
|
||||
deadline = time.time() + timeout
|
||||
seen = set()
|
||||
while time.time() < deadline:
|
||||
lst = requests.get(f"{BASE}/api/parsed_mails?limit=5&offset=0", headers=H).json()
|
||||
for m in lst.get("results", []):
|
||||
if m["id"] in seen: continue
|
||||
seen.add(m["id"])
|
||||
body = (m.get("subject") or "") + "\n" + (m.get("text") or "") + "\n" + (m.get("html") or "")
|
||||
hit = re.search(pattern, body)
|
||||
if hit:
|
||||
return hit.group(0)
|
||||
time.sleep(poll)
|
||||
raise TimeoutError("no matching mail within window")
|
||||
|
||||
print(wait_for_code())
|
||||
```
|
||||
|
||||
## Raw endpoints (fallback — only if you need attachment bytes or the original MIME)
|
||||
|
||||
`/api/mails` and `/api/mail/:id` return the gzip-resolved RFC822 source in `raw`. Parse it client-side.
|
||||
|
||||
### Node.js (postal-mime, pure JS)
|
||||
|
||||
```bash
|
||||
npm i postal-mime
|
||||
```
|
||||
|
||||
```js
|
||||
import PostalMime from 'postal-mime';
|
||||
|
||||
const mail = await (await fetch(`${BASE}/api/mail/${id}`, {
|
||||
headers: { Authorization: `Bearer ${JWT}` },
|
||||
})).json();
|
||||
const parsed = await PostalMime.parse(mail.raw);
|
||||
// parsed.subject / parsed.from / parsed.text / parsed.html
|
||||
// parsed.attachments[i].content is a Uint8Array
|
||||
```
|
||||
|
||||
### Python (stdlib, no deps)
|
||||
|
||||
```python
|
||||
import email, requests
|
||||
from email import policy
|
||||
|
||||
r = requests.get(f"{BASE}/api/mail/{mid}", headers={"Authorization": f"Bearer {JWT}"}).json()
|
||||
msg = email.message_from_string(r["raw"], policy=policy.default)
|
||||
subject = msg["subject"]
|
||||
text = (msg.get_body(preferencelist=("plain",)) or None) and msg.get_body(preferencelist=("plain",)).get_content()
|
||||
html = (msg.get_body(preferencelist=("html",)) or None) and msg.get_body(preferencelist=("html",)).get_content()
|
||||
for part in msg.iter_attachments():
|
||||
name, mime, data = part.get_filename(), part.get_content_type(), part.get_content()
|
||||
```
|
||||
|
||||
The frontend's reference implementation is `frontend/src/utils/email-parser.js` — tries `mail-parser-wasm` first, falls back to `postal-mime`. The server uses `postal-mime` only.
|
||||
|
||||
## Polling discipline
|
||||
|
||||
- Start at `poll=3s`, exponential backoff capped at 10s.
|
||||
- Dedupe by mail `id`.
|
||||
- Never poll faster than once per second.
|
||||
- Respect `429` — sleep and retry.
|
||||
|
||||
## Common errors
|
||||
|
||||
- `401 InvalidAddressCredentialMsg` — JWT wrong/expired/sent via wrong header. Ask the user for a fresh JWT.
|
||||
- `401 CustomAuthPasswordMsg` — site requires `x-custom-auth`; attach `SITE_PASSWORD`.
|
||||
- `400 InvalidLimitMsg` / `InvalidOffsetMsg` — `limit` must be 1..100, `offset ≥ 0`.
|
||||
- `429` — rate limited; back off.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
|
||||
`CHANGELOG_EN.md` uses the same format.
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
**/node_modules/
|
||||
.git/
|
||||
vitepress-docs/
|
||||
smtp_proxy_server/
|
||||
mail-parser-wasm/
|
||||
db/
|
||||
**/.wrangler/
|
||||
**/dist/
|
||||
**/test-results/
|
||||
**/playwright-report/
|
||||
.DS_Store
|
||||
@@ -1,16 +1,14 @@
|
||||
diff --git a/worker/src/common.ts b/worker/src/common.ts
|
||||
index bd9bcc9..e7e2748 100644
|
||||
index 9b758f0..e2150b5 100644
|
||||
--- a/worker/src/common.ts
|
||||
+++ b/worker/src/common.ts
|
||||
@@ -273,23 +273,23 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
@@ -469,29 +469,29 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
}
|
||||
const raw_mail = parsedEmailContext.rawEmail;
|
||||
// TODO: WASM parse email
|
||||
// NOTE: WASM parse email
|
||||
- // try {
|
||||
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
+ try {
|
||||
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
|
||||
-
|
||||
- // const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
- // parsedEmailContext.parsedEmail = {
|
||||
- // sender: parsedEmail.sender || "",
|
||||
@@ -20,11 +18,20 @@ index bd9bcc9..e7e2748 100644
|
||||
- // (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 || "",
|
||||
@@ -34,6 +41,12 @@ index bd9bcc9..e7e2748 100644
|
||||
+ (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) {
|
||||
|
||||
@@ -16,24 +16,25 @@ 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: 20
|
||||
node-version: 22
|
||||
|
||||
- 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
|
||||
@@ -49,8 +50,11 @@ jobs:
|
||||
|
||||
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
|
||||
|
||||
if [ -n "$use_mail_wasm_parser" ]; then
|
||||
@@ -63,14 +67,17 @@ jobs:
|
||||
if [ "$debug_mode" = "true" ]; then
|
||||
pnpm run deploy
|
||||
else
|
||||
output=$(pnpm run deploy 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
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 }}
|
||||
|
||||
@@ -1,64 +1,50 @@
|
||||
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: 20
|
||||
node-version: 22
|
||||
|
||||
- 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}}
|
||||
- name: Deploy Docs
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
TAG_NAME=$(gh release view --json tagName --jq '.tagName')
|
||||
echo "Deploying docs for tag $TAG_NAME"
|
||||
export TAG_NAME
|
||||
pnpm run deploy
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
@@ -10,55 +10,76 @@ on:
|
||||
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: 20
|
||||
node-version: 22
|
||||
|
||||
- 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
|
||||
export frontend_branch=${{ secrets.FRONTEND_BRANCH }}
|
||||
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying branch $frontend_branch"
|
||||
pnpm run deploy:actions --project-name=$project_name --branch $frontend_branch
|
||||
pnpm run deploy:actions --project-name=$FRONTEND_NAME --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying branch production"
|
||||
pnpm run deploy --project-name=$project_name
|
||||
pnpm run deploy --project-name=$FRONTEND_NAME
|
||||
fi
|
||||
echo "Deploying production 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"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying telegram mini app branch $frontend_branch"
|
||||
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying telegram mini app branch production"
|
||||
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
|
||||
fi
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
fi
|
||||
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: 22
|
||||
|
||||
- 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 }}
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
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
|
||||
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: 20
|
||||
node-version: 22
|
||||
|
||||
- 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 }}
|
||||
@@ -33,7 +54,7 @@ jobs:
|
||||
echo '${{ secrets.PAGE_TOML }}' > wrangler.toml
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm run deploy
|
||||
echo "Deploying prodcution for ${{ github.ref_name }}"
|
||||
echo "Deploying production for ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -21,26 +21,26 @@ 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
|
||||
|
||||
@@ -12,14 +12,7 @@ jobs:
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: dreamhunter2333/cloudflare_temp_email
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
test_mode: false
|
||||
run: gh repo sync ${{ github.repository }} --source dreamhunter2333/cloudflare_temp_email --branch main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -6,24 +6,21 @@ 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: 20
|
||||
node-version: 22
|
||||
|
||||
- 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
|
||||
@@ -32,12 +29,58 @@ jobs:
|
||||
- name: Zip Frontend dist
|
||||
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: 22
|
||||
|
||||
- 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: 22
|
||||
|
||||
- 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
|
||||
|
||||
@@ -57,11 +100,39 @@ jobs:
|
||||
pnpm build
|
||||
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Upload worker.js
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
files: |
|
||||
frontend/frontend.zip
|
||||
frontend/telegram-frontend.zip
|
||||
worker/worker.js
|
||||
worker/worker-with-wasm-mail-parser.zip
|
||||
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 }}
|
||||
|
||||
@@ -137,3 +137,8 @@ dist
|
||||
wrangler.toml
|
||||
.dev.vars
|
||||
pnpm-lock.yaml
|
||||
|
||||
# E2E test artifacts
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
e2e/.e2e-pids
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure
|
||||
- Backend: `worker/` (Workers API; entry `worker/src/worker.ts`, APIs under `worker/src/*_api/`).
|
||||
- Frontend: `frontend/` (Vue 3 app; routes in `frontend/src/router/`).
|
||||
- Pages middleware: `pages/functions/_middleware.js`.
|
||||
- Mail parser: `mail-parser-wasm/` (Rust WASM).
|
||||
- SMTP/IMAP proxy: `smtp_proxy_server/`.
|
||||
- DB schema/migrations: `db/`.
|
||||
- Docs: `vitepress-docs/`.
|
||||
|
||||
## Build & Dev Commands
|
||||
Run inside each folder:
|
||||
- Frontend: `pnpm dev`, `pnpm build`.
|
||||
- Worker: `pnpm dev`, `pnpm lint`, `pnpm build`.
|
||||
- Pages: `pnpm dev`.
|
||||
- Docs: `pnpm dev` in `vitepress-docs/`.
|
||||
- WASM: `wasm-pack build --release` in `mail-parser-wasm/`.
|
||||
- SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
|
||||
|
||||
## Coding Style
|
||||
- Follow existing module style. `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
|
||||
- Keep current naming patterns: `*_api/`, `utils/`, `models/`.
|
||||
- ESM imports only (`type: module`).
|
||||
|
||||
## Testing
|
||||
- No formal test runner. Validate with local dev servers and key flows (login, inbox, send/receive).
|
||||
|
||||
## Commits & PRs
|
||||
- Use Conventional Commits (`feat:`, `fix:`, `docs:`). Recent history includes PR numbers like `(#123)`.
|
||||
- PRs should explain scope and add screenshots for UI changes.
|
||||
|
||||
## Config Tips
|
||||
- Worker settings in `worker/wrangler.toml` (see template for bindings).
|
||||
- Frontend uses `VITE_*` env vars. Don’t commit secrets.
|
||||
+146
-3
@@ -2,11 +2,154 @@
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">🇨🇳 中文</a> |
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.2.1(main)
|
||||
## v1.8.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |API| 新增服务端解析邮件接口 `/api/parsed_mails` 与 `/api/parsed_mail/:id`,直接返回 `sender` / `subject` / `text` / `html` / `attachments` 元信息(复用 `commonParseMail`),AI agent 侧不再需要引入 MIME 解析器
|
||||
- feat: |Skill| 新增仓库内置只读 skill `cf-temp-mail-usage`(`.claude/skills/cf-temp-mail-usage/`),让 OpenClaw / Codex / Cursor 等 AI agent 凭用户提供的 Address JWT + API 地址读取邮箱、轮询验证码,绕开创建邮箱时的 Turnstile 人机验证;可通过 `npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage` 安装
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### 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(main)
|
||||
|
||||
### 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(main)
|
||||
|
||||
### 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(main)
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+146
-3
@@ -2,11 +2,154 @@
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">🇨🇳 中文</a> |
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.2.1(main)
|
||||
## v1.8.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- 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-usage` (`.claude/skills/cf-temp-mail-usage/`) 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/.claude/skills/cf-temp-mail-usage`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### 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(main)
|
||||
|
||||
### 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(main)
|
||||
|
||||
### 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(main)
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -29,37 +29,37 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">🇨🇳 中文文档</a> |
|
||||
<a href="README_EN.md">🇺🇸 English Document</a>
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a>
|
||||
</p>
|
||||
|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
|
||||
**🎉 一个功能完整的临时邮箱服务!**
|
||||
**一个功能完整的临时邮箱服务!**
|
||||
|
||||
- 🆓 **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
||||
- ⚡ **高性能** - Rust WASM 邮件解析,响应速度极快
|
||||
- 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便
|
||||
- 🔐 **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
|
||||
- **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
||||
- **高性能** - Rust WASM 邮件解析,响应速度极快
|
||||
- **现代化界面** - 响应式设计,支持多语言,操作简便
|
||||
- **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
|
||||
|
||||
## 📚 部署文档 - 快速开始
|
||||
## 部署文档 - 快速开始
|
||||
|
||||
[📖 部署文档](https://temp-mail-docs.awsl.uk) | [🚀 Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
|
||||
[部署文档](https://temp-mail-docs.awsl.uk) | [Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
|
||||
|
||||
<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>
|
||||
|
||||
## 📝 更新日志
|
||||
## 更新日志
|
||||
|
||||
查看 [CHANGELOG](CHANGELOG.md) 了解最新更新内容。
|
||||
|
||||
## 🎯 在线体验
|
||||
## 在线体验
|
||||
|
||||
立即体验 → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>📊 服务状态监控(点击收缩/展开)</summary>
|
||||
<summary>服务状态监控(点击收缩/展开)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -69,7 +69,7 @@
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⭐ Star History(点击收缩/展开)</summary>
|
||||
<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" />
|
||||
@@ -80,35 +80,36 @@
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>📖 目录(点击收缩/展开)</summary>
|
||||
<summary>目录(点击收缩/展开)</summary>
|
||||
|
||||
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
|
||||
- [📚 部署文档 - 快速开始](#-部署文档---快速开始)
|
||||
- [📝 更新日志](#-更新日志)
|
||||
- [🎯 在线体验](#-在线体验)
|
||||
- [✨ 核心功能](#-核心功能)
|
||||
- [📧 邮件处理](#-邮件处理)
|
||||
- [👥 用户管理](#-用户管理)
|
||||
- [🔧 管理功能](#-管理功能)
|
||||
- [🌐 多语言与界面](#-多语言与界面)
|
||||
- [🤖 集成与扩展](#-集成与扩展)
|
||||
- [🏗️ 技术架构](#️-技术架构)
|
||||
- [🏛️ 系统架构](#️-系统架构)
|
||||
- [🛠️ 技术栈](#️-技术栈)
|
||||
- [📦 主要组件](#-主要组件)
|
||||
- [🌟 加入社区](#-加入社区)
|
||||
- [部署文档 - 快速开始](#部署文档---快速开始)
|
||||
- [更新日志](#更新日志)
|
||||
- [在线体验](#在线体验)
|
||||
- [核心功能](#核心功能)
|
||||
- [邮件处理](#邮件处理)
|
||||
- [用户管理](#用户管理)
|
||||
- [管理功能](#管理功能)
|
||||
- [多语言与界面](#多语言与界面)
|
||||
- [集成与扩展](#集成与扩展)
|
||||
- [技术架构](#技术架构)
|
||||
- [系统架构](#系统架构)
|
||||
- [技术栈](#技术栈)
|
||||
- [主要组件](#主要组件)
|
||||
- [加入社区](#加入社区)
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ 核心功能
|
||||
## 核心功能
|
||||
|
||||
<details open>
|
||||
<summary>✨ 核心功能详情(点击收缩/展开)</summary>
|
||||
<summary>核心功能详情(点击收缩/展开)</summary>
|
||||
|
||||
### 📧 邮件处理
|
||||
### 邮件处理
|
||||
|
||||
- [x] 使用 `rust wasm` 解析邮件,解析速度快,几乎所有邮件都能解析,node 的解析模块解析邮件失败的邮件,rust wasm 也能解析成功
|
||||
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
|
||||
- [x] 支持为指定基础域名创建随机二级域名邮箱地址,更适合收件隔离场景
|
||||
- [x] 支持发送邮件,支持 `DKIM` 验证
|
||||
- [x] 支持 `SMTP` 和 `Resend` 等多种发送方式
|
||||
- [x] 增加查看 `附件` 功能,支持附件图片显示
|
||||
@@ -116,7 +117,7 @@
|
||||
- [x] 垃圾邮件检测和黑白名单配置
|
||||
- [x] 邮件转发功能,支持全局转发地址
|
||||
|
||||
### 👥 用户管理
|
||||
### 用户管理
|
||||
|
||||
- [x] 使用 `凭证` 重新登录之前的邮箱
|
||||
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
|
||||
@@ -125,7 +126,7 @@
|
||||
- [x] 用户角色管理,支持多角色域名和前缀配置
|
||||
- [x] 用户收件箱查看,支持地址和关键词过滤
|
||||
|
||||
### 🔧 管理功能
|
||||
### 管理功能
|
||||
|
||||
- [x] 完整的 admin 控制台
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
@@ -134,7 +135,7 @@
|
||||
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
|
||||
- [x] 增加访问密码,可作为私人站点
|
||||
|
||||
### 🌐 多语言与界面
|
||||
### 多语言与界面
|
||||
|
||||
- [x] 前后台均支持多语言
|
||||
- [x] 现代化 UI 设计,支持响应式布局
|
||||
@@ -142,29 +143,46 @@
|
||||
- [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 友好**:提供服务端解析的 `/api/parsed_mails` / `/api/parsed_mail/:id`,配合仓库内的 `cf-temp-mail-usage` skill,OpenClaw / Codex / Cursor 等 AI agent 可直接使用用户提供的 JWT 读取验证码 / 链接,无需在客户端引入 MIME 解析器
|
||||
|
||||
</details>
|
||||
|
||||
## 🏗️ 技术架构
|
||||
## 给 AI Agent 使用:`cf-temp-mail-usage` skill
|
||||
|
||||
仓库内置一个只读 skill:`.claude/skills/cf-temp-mail-usage/`,让 AI agent 用用户提供的 `Address JWT + API 地址`直接消费邮箱(列出邮件 / 取单封 / 轮询验证码),规避前端创建邮箱时的 Turnstile 人机验证。
|
||||
|
||||
安装到当前项目的 Claude Code:
|
||||
|
||||
```bash
|
||||
# 方式 1:degit 拷贝子目录
|
||||
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage .claude/skills/cf-temp-mail-usage
|
||||
|
||||
# 方式 2:安装到全局
|
||||
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage ~/.claude/skills/cf-temp-mail-usage
|
||||
```
|
||||
|
||||
细节见 [.claude/skills/cf-temp-mail-usage/SKILL.md](.claude/skills/cf-temp-mail-usage/SKILL.md)。
|
||||
|
||||
## 技术架构
|
||||
|
||||
<details>
|
||||
<summary>🏗️ 技术架构详情(点击收缩/展开)</summary>
|
||||
<summary>技术架构详情(点击收缩/展开)</summary>
|
||||
|
||||
### 🏛️ 系统架构
|
||||
### 系统架构
|
||||
|
||||
- **数据库**: Cloudflare D1 作为主数据库
|
||||
- **前端部署**: 使用 Cloudflare Pages 部署前端
|
||||
- **后端部署**: 使用 Cloudflare Workers 部署后端
|
||||
- **邮件转发**: 使用 Cloudflare Email Routing
|
||||
|
||||
### 🛠️ 技术栈
|
||||
### 技术栈
|
||||
|
||||
- **前端**: Vue 3 + Vite + TypeScript
|
||||
- **后端**: TypeScript + Cloudflare Workers
|
||||
@@ -173,7 +191,7 @@
|
||||
- **存储**: Cloudflare KV + R2 (可选 S3)
|
||||
- **代理服务**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 📦 主要组件
|
||||
### 主要组件
|
||||
|
||||
- **Worker**: 核心后端服务
|
||||
- **Frontend**: Vue 3 用户界面
|
||||
@@ -192,6 +210,6 @@ nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
进行验证。
|
||||
|
||||
## 🌟 加入社区
|
||||
## 加入社区
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
+59
-41
@@ -29,37 +29,37 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">🇨🇳 中文文档</a> |
|
||||
<a href="README_EN.md">🇺🇸 English Document</a>
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</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!**
|
||||
**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 (enabled via `ENABLE_ADDRESS_PASSWORD`)
|
||||
- **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 (enabled via `ENABLE_ADDRESS_PASSWORD`)
|
||||
|
||||
## 📚 Deployment Documentation - Quick Start
|
||||
## 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)
|
||||
[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
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG](CHANGELOG.md) for the latest updates.
|
||||
|
||||
## 🎯 Live Demo
|
||||
## Live Demo
|
||||
|
||||
Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>📊 Service Status Monitoring (Click to expand/collapse)</summary>
|
||||
<summary>Service Status Monitoring (Click to expand/collapse)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -69,7 +69,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⭐ Star History (Click to expand/collapse)</summary>
|
||||
<summary>Star History (Click to expand/collapse)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
@@ -80,35 +80,36 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>📖 Table of Contents (Click to expand/collapse)</summary>
|
||||
<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)
|
||||
- [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
|
||||
## Core Features
|
||||
|
||||
<details open>
|
||||
<summary>✨ Core Features Details (Click to expand/collapse)</summary>
|
||||
<summary>Core Features Details (Click to expand/collapse)</summary>
|
||||
|
||||
### 📧 Email Processing
|
||||
### 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
|
||||
@@ -116,7 +117,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [x] Spam detection and blacklist/whitelist configuration
|
||||
- [x] Email forwarding feature with global forwarding address support
|
||||
|
||||
### 👥 User Management
|
||||
### 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
|
||||
@@ -125,7 +126,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [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
|
||||
### Admin Features
|
||||
|
||||
- [x] Complete admin console
|
||||
- [x] Create mailboxes without prefix in `admin` backend
|
||||
@@ -134,7 +135,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [x] Get mailboxes with custom names, `admin` can configure blacklist
|
||||
- [x] Add access password for use as a private site
|
||||
|
||||
### 🌐 Multi-language & Interface
|
||||
### Multi-language & Interface
|
||||
|
||||
- [x] Both frontend and backend support multi-language
|
||||
- [x] Modern UI design with responsive layout
|
||||
@@ -142,29 +143,46 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [x] Use shadow DOM to prevent style pollution
|
||||
- [x] Support URL JWT parameter auto-login
|
||||
|
||||
### 🤖 Integration & Extensions
|
||||
### 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**: server-side parsed endpoints `/api/parsed_mails` / `/api/parsed_mail/:id`, plus the bundled `cf-temp-mail-usage` skill, let AI agents like OpenClaw / Codex / Cursor consume a mailbox with a user-supplied JWT to read verification codes / magic links — no client-side MIME parser needed, and it sidesteps the Turnstile challenge on mailbox creation
|
||||
|
||||
</details>
|
||||
|
||||
## 🏗️ Technical Architecture
|
||||
## For AI Agents: `cf-temp-mail-usage` skill
|
||||
|
||||
A read-only skill is bundled at `.claude/skills/cf-temp-mail-usage/`. It lets an AI agent consume a mailbox using a user-supplied `Address JWT + API base URL` (list mails / fetch one / poll for verification codes), bypassing the Turnstile challenge required to create a mailbox in the UI.
|
||||
|
||||
Install into a project's Claude Code:
|
||||
|
||||
```bash
|
||||
# Option 1: degit the sub-directory into the current project
|
||||
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage .claude/skills/cf-temp-mail-usage
|
||||
|
||||
# Option 2: install globally for all projects
|
||||
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage ~/.claude/skills/cf-temp-mail-usage
|
||||
```
|
||||
|
||||
See [.claude/skills/cf-temp-mail-usage/SKILL.md](.claude/skills/cf-temp-mail-usage/SKILL.md) for details.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
<details>
|
||||
<summary>🏗️ Technical Architecture Details (Click to expand/collapse)</summary>
|
||||
<summary>Technical Architecture Details (Click to expand/collapse)</summary>
|
||||
|
||||
### 🏛️ System Architecture
|
||||
### 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
|
||||
### Tech Stack
|
||||
|
||||
- **Frontend**: Vue 3 + Vite + TypeScript
|
||||
- **Backend**: TypeScript + Cloudflare Workers
|
||||
@@ -173,7 +191,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- **Storage**: Cloudflare KV + R2 (optional S3)
|
||||
- **Proxy Service**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 📦 Main Components
|
||||
### Main Components
|
||||
|
||||
- **Worker**: Core backend service
|
||||
- **Frontend**: Vue 3 user interface
|
||||
@@ -191,6 +209,6 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
|
||||
## 🌟 Join the Community
|
||||
## Join the Community
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add raw_blob BLOB column for gzip-compressed email storage
|
||||
ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;
|
||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
raw TEXT,
|
||||
raw_blob BLOB,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Keep this version in sync with @playwright/test in package.json
|
||||
FROM mcr.microsoft.com/playwright:v1.58.2-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"]
|
||||
@@ -0,0 +1,47 @@
|
||||
FROM node:20-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"]
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:20-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"]
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
@@ -0,0 +1,271 @@
|
||||
import { APIRequestContext } from '@playwright/test';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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 = ["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
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Generated
+422
@@ -0,0 +1,422 @@
|
||||
{
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"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.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"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.8",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
|
||||
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"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.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"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.3.1",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
|
||||
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.8",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.5",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.7"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"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.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"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": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"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.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"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.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.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.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
Executable
+114
@@ -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 "$@"
|
||||
Executable
+15
@@ -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
|
||||
@@ -0,0 +1,242 @@
|
||||
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('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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } 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');
|
||||
|
||||
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: 'test-password-123' },
|
||||
});
|
||||
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: 'test-password-123' },
|
||||
});
|
||||
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');
|
||||
|
||||
try {
|
||||
// Set a password
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: 'correct-password' },
|
||||
});
|
||||
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: 'wrong-password' },
|
||||
});
|
||||
expect(loginRes.status()).toBe(401);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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.enableSendMail).toBe(true);
|
||||
expect(settings.enableUserCreateEmail).toBe(true);
|
||||
expect(settings.enableUserDeleteEmail).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* SHA-256 hash matching frontend hashPassword utility.
|
||||
*/
|
||||
function hashPassword(password: string): string {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
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: '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: 'addr-pass-123',
|
||||
cf_token: ''
|
||||
},
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const body = await loginRes.json();
|
||||
expect(body.jwt).toBeTruthy();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
import { test, expect, 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 returns 200 when domain is allowed', 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 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${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('persisted false still keeps exact match only', 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: SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
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('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: SUBDOMAIN },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
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}',
|
||||
}),
|
||||
},
|
||||
});
|
||||
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 },
|
||||
});
|
||||
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);
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
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 msg = await client.fetchOne(String(results[0]), { uid: true, flags: true }, { uid: true });
|
||||
expect(msg.uid).toBe(results[0]);
|
||||
} 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+21
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.2.1",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,39 +17,43 @@
|
||||
"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:actions": "npm run build && wrangler pages deploy ./dist"
|
||||
"deploy:actions": "npm run build && wrangler pages deploy ./dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@simplewebauthn/browser": "10.0.0",
|
||||
"@unhead/vue": "^2.1.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
"@unhead/vue": "^2.1.13",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.15.1",
|
||||
"dompurify": "^3.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.1",
|
||||
"naive-ui": "^2.43.2",
|
||||
"postal-mime": "^2.7.3",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"postal-mime": "^2.7.4",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.27",
|
||||
"vue": "^3.5.32",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-i18n": "^11.3.2",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"jsdom": "^28.1.0",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-build": "^7.4.0",
|
||||
"workbox-window": "^7.4.0",
|
||||
"wrangler": "^4.59.2"
|
||||
"wrangler": "^4.83.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
Generated
+1837
-1484
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
maxAddressLen: res["maxAddressLen"] || 30,
|
||||
needAuth: res["needAuth"] || false,
|
||||
defaultDomains: res["defaultDomains"] || [],
|
||||
randomSubdomainDomains: res["randomSubdomainDomains"] || [],
|
||||
domains: res["domains"].map((domain, index) => {
|
||||
return {
|
||||
label: domainLabels.length > index ? domainLabels[index] : domain,
|
||||
@@ -92,6 +93,8 @@ const getOpenSettings = async (message, notification) => {
|
||||
enableWebhook: res["enableWebhook"] || false,
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||
statusUrl: res["statusUrl"] || "",
|
||||
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
||||
});
|
||||
if (openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
|
||||
@@ -3,10 +3,11 @@ import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } from '@vicons/material'
|
||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem } from '../utils/email-parser'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { buildReplyModel, buildForwardModel } from '../utils/mail-actions'
|
||||
import MailContentRenderer from "./MailContentRenderer.vue";
|
||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||
|
||||
@@ -147,6 +148,7 @@ const { t } = useI18n({
|
||||
attachments: 'Show Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
pleaseSelectMail: "Please select mail",
|
||||
emptyInbox: "Your inbox is empty",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
reply: 'Reply',
|
||||
@@ -171,6 +173,7 @@ const { t } = useI18n({
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择邮件",
|
||||
emptyInbox: "收件箱为空",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
reply: '回复',
|
||||
@@ -274,30 +277,12 @@ const deleteMail = async () => {
|
||||
};
|
||||
|
||||
const replyMail = async () => {
|
||||
const emailRegex = /(.+?) <(.+?)>/;
|
||||
let toMail = curMail.value.originalSource;
|
||||
let toName = ""
|
||||
const match = emailRegex.exec(curMail.value.source);
|
||||
if (match) {
|
||||
toName = match[1];
|
||||
toMail = match[2];
|
||||
}
|
||||
Object.assign(sendMailModel.value, {
|
||||
toName: toName,
|
||||
toMail: toMail,
|
||||
subject: `${t('reply')}: ${curMail.value.subject}`,
|
||||
contentType: 'rich',
|
||||
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
|
||||
});
|
||||
Object.assign(sendMailModel.value, buildReplyModel(curMail.value, t('reply')));
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
const forwardMail = async () => {
|
||||
Object.assign(sendMailModel.value, {
|
||||
subject: `${t('forwardMail')}: ${curMail.value.subject}`,
|
||||
contentType: curMail.value.message ? 'html' : 'text',
|
||||
content: curMail.value.message || curMail.value.text,
|
||||
});
|
||||
Object.assign(sendMailModel.value, buildForwardModel(curMail.value, t('forwardMail')));
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
@@ -446,7 +431,7 @@ onBeforeUnmount(() => {
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 50vh; max-height: 100vh;">
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
@@ -506,7 +491,10 @@ onBeforeUnmount(() => {
|
||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
<n-result status="info" :title="count === 0 ? t('emptyInbox') : t('pleaseSelectMail')">
|
||||
<template #icon>
|
||||
<n-icon :component="InboxRound" :size="100" />
|
||||
</template>
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -531,7 +519,7 @@ onBeforeUnmount(() => {
|
||||
<n-input v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" size="small" clearable />
|
||||
</div>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing :title="row.subject">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate } = useGlobalState();
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
@@ -184,22 +184,22 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
<AiExtractInfo :metadata="mail.metadata" />
|
||||
|
||||
<!-- 邮件内容 -->
|
||||
<div class="mail-content">
|
||||
<div class="mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
|
||||
style="height: 100vh;">
|
||||
<n-drawer-content :title="mail.subject" closable>
|
||||
<div class="fullscreen-mail-content">
|
||||
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
@@ -259,6 +259,10 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.dark-mode .mail-text {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.mail-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -266,6 +270,10 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.dark-mode .mail-iframe {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.mail-html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { SendRound } from '@vicons/material'
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -52,6 +53,7 @@ const { t } = useI18n({
|
||||
refresh: 'Refresh',
|
||||
showCode: 'Change View Original Code',
|
||||
pleaseSelectMail: "Please select a mail to view.",
|
||||
emptySent: "No sent emails",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
multiAction: 'Multi Action',
|
||||
@@ -64,6 +66,7 @@ const { t } = useI18n({
|
||||
refresh: '刷新',
|
||||
showCode: '切换查看元数据',
|
||||
pleaseSelectMail: "请选择一封邮件查看。",
|
||||
emptySent: "发件箱为空",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
multiAction: '多选',
|
||||
@@ -239,7 +242,7 @@ onMounted(async () => {
|
||||
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
@@ -297,7 +300,10 @@ onMounted(async () => {
|
||||
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
<n-result status="info" :title="count === 0 ? t('emptySent') : t('pleaseSelectMail')">
|
||||
<template #icon>
|
||||
<n-icon :component="SendRound" :size="100" />
|
||||
</template>
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -312,7 +318,7 @@ onMounted(async () => {
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing :title="row.subject">
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const shadowHost = ref(null);
|
||||
@@ -40,7 +44,13 @@ const renderShadowDom = () => {
|
||||
|
||||
// Update content if Shadow DOM exists
|
||||
if (shadowRoot) {
|
||||
shadowRoot.innerHTML = props.htmlContent;
|
||||
const darkModeStyle = props.isDark
|
||||
? `<style>
|
||||
:host { color: #e0e0e0; }
|
||||
a { color: #A8C7FA; }
|
||||
</style>`
|
||||
: '';
|
||||
shadowRoot.innerHTML = darkModeStyle + props.htmlContent;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to render Shadow DOM, falling back to v-html:', error);
|
||||
@@ -68,8 +78,8 @@ onBeforeUnmount(() => {
|
||||
shadowRoot = null;
|
||||
});
|
||||
|
||||
// Update Shadow DOM when htmlContent changes
|
||||
watch(() => props.htmlContent, () => {
|
||||
// Update Shadow DOM when htmlContent or dark mode changes
|
||||
watch(() => [props.htmlContent, props.isDark], () => {
|
||||
renderShadowDom();
|
||||
}, { flush: 'post' });
|
||||
</script>
|
||||
|
||||
@@ -17,17 +17,21 @@ const { locale, t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}`
|
||||
const cfTurnstileId = ref("")
|
||||
const turnstileLoading = ref(false)
|
||||
|
||||
const refresh = () => checkCfTurnstile(true)
|
||||
defineExpose({ refresh })
|
||||
|
||||
const checkCfTurnstile = async (remove) => {
|
||||
if (!openSettings.value.cfTurnstileSiteKey) return;
|
||||
turnstileLoading.value = true;
|
||||
try {
|
||||
let container = document.getElementById("cf-turnstile");
|
||||
let container = document.getElementById(containerId);
|
||||
let count = 100;
|
||||
while (!container && count-- > 0) {
|
||||
container = document.getElementById("cf-turnstile");
|
||||
container = document.getElementById(containerId);
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
count = 100;
|
||||
@@ -38,7 +42,7 @@ const checkCfTurnstile = async (remove) => {
|
||||
window.turnstile.remove(cfTurnstileId.value);
|
||||
}
|
||||
cfTurnstileId.value = window.turnstile.render(
|
||||
"#cf-turnstile",
|
||||
`#${containerId}`,
|
||||
{
|
||||
sitekey: openSettings.value.cfTurnstileSiteKey,
|
||||
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
|
||||
@@ -68,7 +72,7 @@ onMounted(() => {
|
||||
<n-spin description="loading..." :show="turnstileLoading">
|
||||
<n-form-item-row>
|
||||
<n-flex vertical>
|
||||
<div id="cf-turnstile"></div>
|
||||
<div :id="containerId"></div>
|
||||
<n-button text @click="checkCfTurnstile(true)">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, h } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { DropdownOption } from 'naive-ui'
|
||||
|
||||
const props = defineProps({
|
||||
fetchData: {
|
||||
@@ -32,8 +33,7 @@ const { t } = useI18n({
|
||||
notEnabled: 'Webhook is not enabled for you',
|
||||
urlMissing: 'URL is required',
|
||||
enable: 'Enable',
|
||||
messagePusherDemo: 'Fill with Message Pusher Demo',
|
||||
messagePusherDoc: 'Message Pusher Doc',
|
||||
presets: 'Presets',
|
||||
fillInDemoTip: 'Please modify the URL and other settings to your own',
|
||||
},
|
||||
zh: {
|
||||
@@ -43,8 +43,7 @@ const { t } = useI18n({
|
||||
notEnabled: 'Webhook 未开启,请联系管理员开启',
|
||||
urlMissing: 'URL 不能为空',
|
||||
enable: '启用',
|
||||
messagePusherDemo: '填入MessagePusher示例',
|
||||
messagePusherDoc: 'MessagePusher文档',
|
||||
presets: '示例模板',
|
||||
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
|
||||
}
|
||||
}
|
||||
@@ -58,9 +57,17 @@ class WebhookSettings {
|
||||
body: string = JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
const messagePusherDocLink = "https://github.com/songquanpeng/message-pusher";
|
||||
interface WebhookPreset {
|
||||
name: string
|
||||
doc: string
|
||||
settings: WebhookSettings
|
||||
}
|
||||
|
||||
const messagePusherDemo = {
|
||||
const presets: WebhookPreset[] = [
|
||||
{
|
||||
name: 'Message Pusher',
|
||||
doc: 'https://github.com/songquanpeng/message-pusher',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://msgpusher.com/push/username',
|
||||
method: 'POST',
|
||||
@@ -73,11 +80,108 @@ const messagePusherDemo = {
|
||||
"description": "${subject}",
|
||||
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
|
||||
}, null, 2),
|
||||
} as WebhookSettings;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Bark',
|
||||
doc: 'https://github.com/Finb/Bark',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://api.day.app/YOUR_KEY',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"title": "${subject}",
|
||||
"body": "From: ${from}\nTo: ${to}\n\n${parsedText}",
|
||||
"group": "email"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ntfy',
|
||||
doc: 'https://docs.ntfy.sh/publish/',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://ntfy.sh/YOUR_TOPIC',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"topic": "YOUR_TOPIC",
|
||||
"title": "${subject}",
|
||||
"message": "From: ${from}\nTo: ${to}\n\n${parsedText}",
|
||||
"tags": ["envelope"]
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Telegram Bot',
|
||||
doc: 'https://core.telegram.org/bots/api#sendmessage',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"chat_id": "YOUR_CHAT_ID",
|
||||
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WeChat Work',
|
||||
doc: 'https://developer.work.weixin.qq.com/document/path/91770',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
doc: 'https://discord.com/developers/docs/resources/webhook',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const fillMessagePuhserDemo = () => {
|
||||
Object.assign(webhookSettings.value, messagePusherDemo)
|
||||
const presetDropdownOptions: DropdownOption[] = presets.map((preset, index) => ({
|
||||
label: preset.name,
|
||||
key: index,
|
||||
}))
|
||||
|
||||
const handlePresetSelect = (key: number) => {
|
||||
const preset = presets[key]
|
||||
if (!preset) {
|
||||
message.error('Invalid preset')
|
||||
return
|
||||
}
|
||||
Object.assign(webhookSettings.value, preset.settings)
|
||||
message.success(t('fillInDemoTip'))
|
||||
window.open(preset.doc, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
|
||||
@@ -128,12 +232,11 @@ onMounted(async () => {
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
|
||||
<n-flex justify="end">
|
||||
<n-button tag="a" :href="messagePusherDocLink" target="_blank" secondary>
|
||||
{{ t('messagePusherDoc') }}
|
||||
</n-button>
|
||||
<n-button @click="fillMessagePuhserDemo" secondary>
|
||||
{{ t('messagePusherDemo') }}
|
||||
<n-dropdown :options="presetDropdownOptions" @select="handlePresetSelect">
|
||||
<n-button secondary>
|
||||
{{ t('presets') }}
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
|
||||
{{ t('test') }}
|
||||
</n-button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type UserOauth2Settings = {
|
||||
name: string;
|
||||
icon?: string; // SVG icon string for the provider
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
authorizationURL: string;
|
||||
@@ -9,6 +10,9 @@ export type UserOauth2Settings = {
|
||||
redirectURL: string;
|
||||
logoutURL?: string;
|
||||
userEmailKey: string;
|
||||
enableEmailFormat?: boolean; // Enable email format transformation
|
||||
userEmailFormat?: string; // Regex pattern to match email
|
||||
userEmailReplace?: string; // Replacement template using $1, $2, etc.
|
||||
scope: string;
|
||||
enableMailAllowList?: boolean | undefined;
|
||||
mailAllowList?: string[] | undefined;
|
||||
|
||||
@@ -28,15 +28,20 @@ export const useGlobalState = createGlobalState(
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
defaultDomains: [],
|
||||
/** @type {string[]} */
|
||||
randomSubdomainDomains: [],
|
||||
/** @type {Array<{label: string, value: string}>} */
|
||||
domains: [],
|
||||
copyright: 'Dream Hunter',
|
||||
cfTurnstileSiteKey: '',
|
||||
enableWebhook: false,
|
||||
isS3Enabled: false,
|
||||
enableSendMail: false,
|
||||
showGithub: true,
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
statusUrl: '',
|
||||
enableGlobalTurnstileCheck: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
@@ -83,7 +88,7 @@ export const useGlobalState = createGlobalState(
|
||||
fetched: false,
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
/** @type {{ clientID: string, name: string }[]} */
|
||||
/** @type {{ clientID: string, name: string, icon?: string }[]} */
|
||||
oauth2ClientIDs: [],
|
||||
});
|
||||
const userSettings = ref({
|
||||
@@ -109,8 +114,18 @@ export const useGlobalState = createGlobalState(
|
||||
);
|
||||
const telegramApp = ref(window.Telegram?.WebApp || {});
|
||||
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
|
||||
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
|
||||
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
|
||||
const _oauth2StateSession = useSessionStorage('userOauth2SessionState', '');
|
||||
const _oauth2StateFallback = useStorage('userOauth2SessionState_fb', '');
|
||||
const userOauth2SessionState = computed({
|
||||
get: () => _oauth2StateSession.value || _oauth2StateFallback.value,
|
||||
set: (v) => { _oauth2StateSession.value = v; _oauth2StateFallback.value = v; }
|
||||
});
|
||||
const _oauth2ClientIDSession = useSessionStorage('userOauth2SessionClientID', '');
|
||||
const _oauth2ClientIDFallback = useStorage('userOauth2SessionClientID_fb', '');
|
||||
const userOauth2SessionClientID = computed({
|
||||
get: () => _oauth2ClientIDSession.value || _oauth2ClientIDFallback.value,
|
||||
set: (v) => { _oauth2ClientIDSession.value = v; _oauth2ClientIDFallback.value = v; }
|
||||
});
|
||||
const browserFingerprint = ref('');
|
||||
return {
|
||||
isDark,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildReplyModel, buildForwardModel } from '../mail-actions'
|
||||
|
||||
describe('buildReplyModel', () => {
|
||||
it('uses HTML content in blockquote when message is present', () => {
|
||||
const mail = {
|
||||
source: 'Alice <alice@example.com>',
|
||||
originalSource: 'alice@example.com',
|
||||
subject: 'Hello',
|
||||
message: '<p>HTML body</p>',
|
||||
text: 'Plain body',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe(
|
||||
'<p><br></p><blockquote><p>HTML body</p></blockquote><p><br></p>'
|
||||
)
|
||||
expect(result.contentType).toBe('html')
|
||||
})
|
||||
|
||||
it('falls back to plain text when message is empty string', () => {
|
||||
const mail = {
|
||||
source: 'bob@example.com',
|
||||
originalSource: 'bob@example.com',
|
||||
subject: 'Hi',
|
||||
message: '',
|
||||
text: 'Plain text fallback',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe(
|
||||
'<p><br></p><blockquote>Plain text fallback</blockquote><p><br></p>'
|
||||
)
|
||||
expect(result.contentType).toBe('rich')
|
||||
})
|
||||
|
||||
it('falls back to plain text when message is null', () => {
|
||||
const mail = {
|
||||
source: 'carol@example.com',
|
||||
originalSource: 'carol@example.com',
|
||||
subject: 'Test',
|
||||
message: null,
|
||||
text: 'Fallback text',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe(
|
||||
'<p><br></p><blockquote>Fallback text</blockquote><p><br></p>'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty content when both message and text are empty', () => {
|
||||
const mail = {
|
||||
source: 'dave@example.com',
|
||||
originalSource: 'dave@example.com',
|
||||
subject: 'Empty',
|
||||
message: '',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty content when both message and text are null', () => {
|
||||
const mail = {
|
||||
source: 'eve@example.com',
|
||||
originalSource: 'eve@example.com',
|
||||
subject: 'Null',
|
||||
message: null,
|
||||
text: null,
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
it('parses "Name <email>" format for sender', () => {
|
||||
const mail = {
|
||||
source: 'Alice Smith <alice@example.com>',
|
||||
originalSource: 'alice@example.com',
|
||||
subject: 'Test',
|
||||
message: '<p>body</p>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.toName).toBe('Alice Smith')
|
||||
expect(result.toMail).toBe('alice@example.com')
|
||||
})
|
||||
|
||||
it('uses originalSource as toMail when source is plain email', () => {
|
||||
const mail = {
|
||||
source: 'plain@example.com',
|
||||
originalSource: 'plain@example.com',
|
||||
subject: 'Test',
|
||||
message: '',
|
||||
text: 'body',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.toName).toBe('')
|
||||
expect(result.toMail).toBe('plain@example.com')
|
||||
})
|
||||
|
||||
it('defaults toMail to empty string when originalSource is null', () => {
|
||||
const mail = {
|
||||
source: 'plain@example.com',
|
||||
originalSource: null,
|
||||
subject: 'Test',
|
||||
message: '',
|
||||
text: 'body',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.toMail).toBe('')
|
||||
})
|
||||
|
||||
it('formats subject with reply label', () => {
|
||||
const mail = {
|
||||
source: 'test@example.com',
|
||||
originalSource: 'test@example.com',
|
||||
subject: 'Original Subject',
|
||||
message: '',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.subject).toBe('Reply: Original Subject')
|
||||
})
|
||||
|
||||
it('uses html contentType for HTML email reply', () => {
|
||||
const mail = {
|
||||
source: 'test@example.com',
|
||||
originalSource: 'test@example.com',
|
||||
subject: 'Test',
|
||||
message: '<p>html</p>',
|
||||
text: 'plain',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.contentType).toBe('html')
|
||||
})
|
||||
|
||||
it('uses rich contentType for plain text email reply', () => {
|
||||
const mail = {
|
||||
source: 'test@example.com',
|
||||
originalSource: 'test@example.com',
|
||||
subject: 'Test',
|
||||
message: '',
|
||||
text: 'plain',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.contentType).toBe('rich')
|
||||
})
|
||||
|
||||
it('strips script tags from HTML reply content (XSS)', () => {
|
||||
const mail = {
|
||||
source: 'attacker@example.com',
|
||||
originalSource: 'attacker@example.com',
|
||||
subject: 'XSS',
|
||||
message: '<p>Hello</p><script>alert("xss")</script><p>World</p>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).not.toContain('<script>')
|
||||
expect(result.content).toContain('<p>Hello</p>')
|
||||
expect(result.content).toContain('<p>World</p>')
|
||||
})
|
||||
|
||||
it('strips event handlers from HTML reply content (XSS)', () => {
|
||||
const mail = {
|
||||
source: 'attacker@example.com',
|
||||
originalSource: 'attacker@example.com',
|
||||
subject: 'XSS',
|
||||
message: '<img src=x onerror="alert(1)"><p>Text</p>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).not.toContain('onerror')
|
||||
expect(result.content).toContain('<p>Text</p>')
|
||||
})
|
||||
|
||||
it('escapes HTML chars in plain text reply content', () => {
|
||||
const mail = {
|
||||
source: 'user@example.com',
|
||||
originalSource: 'user@example.com',
|
||||
subject: 'Test',
|
||||
message: '',
|
||||
text: 'a < b & c > d',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toContain('a < b & c > d')
|
||||
expect(result.content).not.toContain('a < b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForwardModel', () => {
|
||||
it('uses html contentType when message is present', () => {
|
||||
const mail = {
|
||||
subject: 'FW Test',
|
||||
message: '<p>HTML content</p>',
|
||||
text: 'Plain content',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.contentType).toBe('html')
|
||||
expect(result.content).toBe('<p>HTML content</p>')
|
||||
})
|
||||
|
||||
it('uses text contentType when message is empty', () => {
|
||||
const mail = {
|
||||
subject: 'FW Test',
|
||||
message: '',
|
||||
text: 'Plain text only',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.contentType).toBe('text')
|
||||
expect(result.content).toBe('Plain text only')
|
||||
})
|
||||
|
||||
it('uses text contentType when message is null', () => {
|
||||
const mail = {
|
||||
subject: 'FW Test',
|
||||
message: null,
|
||||
text: 'Fallback text',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.contentType).toBe('text')
|
||||
expect(result.content).toBe('Fallback text')
|
||||
})
|
||||
|
||||
it('formats subject with forward label', () => {
|
||||
const mail = {
|
||||
subject: 'Original',
|
||||
message: '',
|
||||
text: '',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.subject).toBe('Forward: Original')
|
||||
})
|
||||
|
||||
it('strips script tags from HTML forward content (XSS)', () => {
|
||||
const mail = {
|
||||
subject: 'XSS Test',
|
||||
message: '<div>Safe</div><script>alert("xss")</script>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.content).not.toContain('<script>')
|
||||
expect(result.content).toContain('<div>Safe</div>')
|
||||
})
|
||||
|
||||
it('strips event handlers from HTML forward content (XSS)', () => {
|
||||
const mail = {
|
||||
subject: 'XSS Test',
|
||||
message: '<img src=x onerror="alert(1)"><b>Bold</b>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.content).not.toContain('onerror')
|
||||
expect(result.content).toContain('<b>Bold</b>')
|
||||
})
|
||||
|
||||
it('escapes special chars in plain text forward content', () => {
|
||||
const mail = {
|
||||
subject: 'FW Text',
|
||||
message: '',
|
||||
text: 'a < b & c > d',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.contentType).toBe('text')
|
||||
expect(result.content).toBe('a < b & c > d')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* HTML-escape special characters for plain text content.
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
const text = String(str ?? '');
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize mail content: HTML-escape plain text, whitelist-sanitize HTML.
|
||||
*/
|
||||
function sanitizeContent(mail) {
|
||||
if (mail.message) {
|
||||
return DOMPurify.sanitize(mail.message);
|
||||
}
|
||||
if (mail.text) {
|
||||
return escapeHtml(mail.text);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the send-mail model for replying to an email.
|
||||
* @param {Object} mail - The mail object (curMail)
|
||||
* @param {string} replyLabel - Translated "Reply" label
|
||||
* @returns {Object} Fields to assign onto sendMailModel
|
||||
*/
|
||||
export function buildReplyModel(mail, replyLabel) {
|
||||
const emailRegex = /(.+?) <(.+?)>/;
|
||||
let toMail = mail.originalSource || '';
|
||||
let toName = "";
|
||||
const match = emailRegex.exec(mail.source);
|
||||
if (match) {
|
||||
toName = match[1];
|
||||
toMail = match[2];
|
||||
}
|
||||
const safeContent = sanitizeContent(mail);
|
||||
return {
|
||||
toName,
|
||||
toMail,
|
||||
subject: `${replyLabel}: ${mail.subject}`,
|
||||
contentType: mail.message ? 'html' : 'rich',
|
||||
content: safeContent
|
||||
? `<p><br></p><blockquote>${safeContent}</blockquote><p><br></p>`
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the send-mail model for forwarding an email.
|
||||
* @param {Object} mail - The mail object (curMail)
|
||||
* @param {string} forwardLabel - Translated "Forward" label
|
||||
* @returns {Object} Fields to assign onto sendMailModel
|
||||
*/
|
||||
export function buildForwardModel(mail, forwardLabel) {
|
||||
return {
|
||||
subject: `${forwardLabel}: ${mail.subject}`,
|
||||
contentType: mail.message ? 'html' : 'text',
|
||||
content: sanitizeContent(mail),
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
import { getRouterPathWithLang, hashPassword } from '../utils'
|
||||
import Turnstile from '../components/Turnstile.vue'
|
||||
|
||||
import SenderAccess from './admin/SenderAccess.vue'
|
||||
import Statistics from "./admin/Statistics.vue"
|
||||
@@ -44,12 +45,23 @@ const SendMail = defineAsyncComponent(() => {
|
||||
.finally(() => loading.value = false);
|
||||
});
|
||||
|
||||
const cfToken = ref('')
|
||||
const turnstileRef = ref(null)
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
await api.fetch('/open_api/admin_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(tmpAdminAuth.value),
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
adminAuth.value = tmpAdminAuth.value;
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
turnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +181,8 @@ const currentLoginMethod = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// make sure openSettings is fetched for turnstile check
|
||||
if (!openSettings.value.fetched) await api.getOpenSettings(message);
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
})
|
||||
@@ -180,6 +194,7 @@ onMounted(async () => {
|
||||
preset="dialog" :title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button @click="authFunc" type="primary" :loading="loading">
|
||||
{{ t('ok') }}
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import {
|
||||
DarkModeFilled, LightModeFilled, MenuFilled,
|
||||
AdminPanelSettingsFilled
|
||||
AdminPanelSettingsFilled, MonitorHeartFilled
|
||||
} from '@vicons/material'
|
||||
import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
import { getRouterPathWithLang, hashPassword } from '../utils'
|
||||
import Turnstile from '../components/Turnstile.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
@@ -32,11 +33,22 @@ const menuValue = computed(() => {
|
||||
return "home";
|
||||
});
|
||||
|
||||
const cfToken = ref('')
|
||||
const turnstileRef = ref(null)
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
await api.fetch('/open_api/site_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(auth.value),
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
turnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +71,7 @@ const { locale, t } = useI18n({
|
||||
home: 'Home',
|
||||
menu: 'Menu',
|
||||
user: 'User',
|
||||
status: 'Status',
|
||||
ok: 'OK',
|
||||
},
|
||||
zh: {
|
||||
@@ -70,6 +83,7 @@ const { locale, t } = useI18n({
|
||||
home: '主页',
|
||||
menu: '菜单',
|
||||
user: '用户',
|
||||
status: '状态',
|
||||
ok: '确定',
|
||||
}
|
||||
}
|
||||
@@ -179,6 +193,25 @@ const menuOptions = computed(() => [
|
||||
),
|
||||
key: "lang"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
tag: "a",
|
||||
target: "_blank",
|
||||
href: openSettings.value?.statusUrl,
|
||||
},
|
||||
{
|
||||
default: () => t('status'),
|
||||
icon: () => h(NIcon, { component: MonitorHeartFilled })
|
||||
}
|
||||
),
|
||||
show: !!openSettings.value?.statusUrl,
|
||||
key: "status"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
@@ -266,6 +299,7 @@ onMounted(async () => {
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="auth" type="password" show-password-on="click" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="authFunc" type="primary">
|
||||
{{ t('ok') }}
|
||||
|
||||
@@ -156,15 +156,15 @@ onMounted(() => {
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:deleteMail="deleteSenboxMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendmail" :tab="t('sendmail')">
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
|
||||
|
||||
@@ -114,6 +114,8 @@ const selectedCount = computed(() => checkedRowKeys.value.length);
|
||||
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
|
||||
|
||||
const addressQuery = ref("")
|
||||
const sortBy = ref("")
|
||||
const sortOrder = ref("")
|
||||
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
@@ -290,10 +292,12 @@ const fetchData = async () => {
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
|
||||
+ (sortBy.value ? `&sort_by=${sortBy.value}` : "")
|
||||
+ (sortOrder.value ? `&sort_order=${sortOrder.value}` : "")
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
if (page.value === 1 || addressCount > 0) {
|
||||
count.value = addressCount ?? 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -301,33 +305,77 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const searchData = () => {
|
||||
if (page.value === 1) {
|
||||
fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSorterChange = (sorter) => {
|
||||
sortBy.value = sorter.columnKey || "";
|
||||
sortOrder.value = sorter.order || "";
|
||||
if (page.value === 1) {
|
||||
fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
type: 'selection'
|
||||
},
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
key: "id",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
key: "name",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
key: "created_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
key: "updated_at"
|
||||
key: "updated_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('source_meta'),
|
||||
key: "source_meta"
|
||||
key: "source_meta",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'source_meta' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
const val = row.source_meta;
|
||||
if (!val) return '';
|
||||
const ipv4Regex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
||||
const ipv6Regex = /^[0-9a-fA-F:]+$/;
|
||||
if (ipv4Regex.test(val) || (val.includes(':') && ipv6Regex.test(val) && !val.startsWith('tg:'))) {
|
||||
return h('a', {
|
||||
href: `https://ip.im/${val}`,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}, val);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('mail_count'),
|
||||
key: "mail_count",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'mail_count' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
@@ -354,6 +402,8 @@ const columns = [
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'send_count' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
@@ -483,7 +533,7 @@ const columns = [
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
@@ -546,8 +596,8 @@ onMounted(async () => {
|
||||
</n-modal>
|
||||
<n-input-group style="margin-bottom: 10px;">
|
||||
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
|
||||
@keydown.enter="fetchData" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
@keydown.enter="searchData" />
|
||||
<n-button @click="searchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
@@ -591,7 +641,7 @@ onMounted(async () => {
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
|
||||
:row-key="row => row.id" embedded />
|
||||
:row-key="row => row.id" remote @update:sorter="handleSorterChange" embedded />
|
||||
</div>
|
||||
|
||||
<!-- Multi-action progress modal -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, h } from 'vue';
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
|
||||
|
||||
@@ -21,6 +21,12 @@ const { t } = useI18n({
|
||||
send_address_block_list: 'Address Block Keywords for send email',
|
||||
noLimitSendAddressList: 'No Balance Limit Send Address List',
|
||||
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
|
||||
send_mail_limit: 'Send Mail Limit',
|
||||
send_mail_limit_tip: 'This applies to all send channels. Use -1 for unlimited and 0 to block sending.',
|
||||
send_mail_daily_limit: 'Daily Limit',
|
||||
send_mail_monthly_limit: 'Monthly Limit',
|
||||
send_mail_daily_limit_invalid: 'Daily limit must be an integer greater than or equal to -1',
|
||||
send_mail_monthly_limit_invalid: 'Monthly limit must be an integer greater than or equal to -1',
|
||||
fromBlockList: 'Block Keywords for receive email',
|
||||
block_receive_unknow_address_email: 'Block receive unknow address email',
|
||||
email_forwarding_config: 'Email Forwarding Configuration',
|
||||
@@ -46,6 +52,14 @@ const { t } = useI18n({
|
||||
regex_invalid: 'Invalid regex pattern',
|
||||
forward_address_required: 'Forward address is required',
|
||||
rule_index: 'Rule',
|
||||
create_address_subdomain_match: 'Allow Subdomain Suffix Match When Creating Address',
|
||||
create_address_subdomain_match_tip: 'Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.',
|
||||
create_address_subdomain_match_note: 'This is different from RANDOM_SUBDOMAIN_DOMAINS: this switch allows API callers to specify custom subdomains directly, while random subdomain only auto-generates one during creation.',
|
||||
create_address_subdomain_match_follow_env: 'Follow Environment Variable',
|
||||
create_address_subdomain_match_force_enable: 'Force Enable',
|
||||
create_address_subdomain_match_force_disable: 'Force Disable',
|
||||
create_address_subdomain_match_follow_env_note: 'Choosing "Follow Environment Variable" clears the admin override and returns to the unset state. The effective result is still controlled by the Worker env and the precedence rules.',
|
||||
create_address_subdomain_match_env_locked: 'Worker env ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH is currently false. The saved admin switch can be modified, but it will not take effect until env is enabled or removed.',
|
||||
},
|
||||
zh: {
|
||||
tip: '您可以手动输入以下多选输入框, 回车增加',
|
||||
@@ -57,6 +71,12 @@ const { t } = useI18n({
|
||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||
noLimitSendAddressList: '无余额限制发送地址列表',
|
||||
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
|
||||
send_mail_limit: '发信额度',
|
||||
send_mail_limit_tip: '对全部发信渠道生效。-1 表示无限,0 表示禁止发送。',
|
||||
send_mail_daily_limit: '每日额度',
|
||||
send_mail_monthly_limit: '每月额度',
|
||||
send_mail_daily_limit_invalid: '每日额度必须是大于等于 -1 的整数',
|
||||
send_mail_monthly_limit_invalid: '每月额度必须是大于等于 -1 的整数',
|
||||
fromBlockList: '接收邮件地址屏蔽关键词',
|
||||
block_receive_unknow_address_email: '禁止接收未知地址邮件',
|
||||
email_forwarding_config: '邮件转发配置',
|
||||
@@ -82,6 +102,14 @@ const { t } = useI18n({
|
||||
regex_invalid: '无效的正则表达式',
|
||||
forward_address_required: '转发地址不能为空',
|
||||
rule_index: '规则',
|
||||
create_address_subdomain_match: '创建邮箱时允许子域名后缀匹配',
|
||||
create_address_subdomain_match_tip: '仅影响 /api/new_address 和 /admin/new_address 的域名校验。例如开启后,foo.example.com 可以匹配已配置的基础域名 example.com。',
|
||||
create_address_subdomain_match_note: '这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名;随机子域名功能只是在创建时自动补一个随机子域名。',
|
||||
create_address_subdomain_match_follow_env: '跟随环境变量',
|
||||
create_address_subdomain_match_force_enable: '强制开启',
|
||||
create_address_subdomain_match_force_disable: '强制关闭',
|
||||
create_address_subdomain_match_follow_env_note: '选择“跟随环境变量”会清空后台覆盖,恢复为未设置状态;最终是否开启仍由 Worker env 和优先级规则决定。',
|
||||
create_address_subdomain_match_env_locked: '当前 Worker 环境变量 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH 为 false。后台开关仍可保存,但在 env 打开或移除前不会生效。',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -95,6 +123,44 @@ const emailRuleSettings = ref({
|
||||
blockReceiveUnknowAddressEmail: false,
|
||||
emailForwardingList: []
|
||||
})
|
||||
const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
|
||||
FOLLOW_ENV: 'follow_env',
|
||||
FORCE_ENABLE: 'force_enable',
|
||||
FORCE_DISABLE: 'force_disable'
|
||||
}
|
||||
const DEFAULT_SEND_MAIL_DAILY_LIMIT = 100
|
||||
const DEFAULT_SEND_MAIL_MONTHLY_LIMIT = 3000
|
||||
const addressCreationSubdomainMatchMode = ref(ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV)
|
||||
const sendMailDailyLimitEnabled = ref(false)
|
||||
const sendMailMonthlyLimitEnabled = ref(false)
|
||||
const sendMailDailyLimit = ref(DEFAULT_SEND_MAIL_DAILY_LIMIT)
|
||||
const sendMailMonthlyLimit = ref(DEFAULT_SEND_MAIL_MONTHLY_LIMIT)
|
||||
const addressCreationSubdomainMatchStatus = ref({
|
||||
envConfigured: false,
|
||||
envEnabled: false,
|
||||
storedEnabled: undefined,
|
||||
effectiveEnabled: false
|
||||
})
|
||||
const subdomainMatchEnvLocked = computed(() => {
|
||||
return addressCreationSubdomainMatchStatus.value.envConfigured
|
||||
&& !addressCreationSubdomainMatchStatus.value.envEnabled
|
||||
})
|
||||
const subdomainMatchModeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV,
|
||||
label: t('create_address_subdomain_match_follow_env')
|
||||
},
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE,
|
||||
label: t('create_address_subdomain_match_force_enable')
|
||||
},
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE,
|
||||
label: t('create_address_subdomain_match_force_disable')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const showEmailForwardingModal = ref(false)
|
||||
const emailForwardingList = ref([])
|
||||
@@ -246,8 +312,52 @@ const saveEmailForwardingConfig = () => {
|
||||
showEmailForwardingModal.value = false
|
||||
}
|
||||
|
||||
const getSubdomainMatchModeByStoredValue = (storedEnabled) => {
|
||||
if (storedEnabled === true) {
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE
|
||||
}
|
||||
if (storedEnabled === false) {
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE
|
||||
}
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const getSubdomainMatchPayloadValue = (mode) => {
|
||||
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE) {
|
||||
return true
|
||||
}
|
||||
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getSendMailLimitPayload = () => {
|
||||
return {
|
||||
dailyEnabled: sendMailDailyLimitEnabled.value,
|
||||
monthlyEnabled: sendMailMonthlyLimitEnabled.value,
|
||||
dailyLimit: sendMailDailyLimitEnabled.value ? sendMailDailyLimit.value : null,
|
||||
monthlyLimit: sendMailMonthlyLimitEnabled.value ? sendMailMonthlyLimit.value : null
|
||||
}
|
||||
}
|
||||
|
||||
const isValidSendMailLimit = (value) => {
|
||||
return Number.isInteger(value) && value >= -1
|
||||
}
|
||||
|
||||
const validateSendMailLimit = () => {
|
||||
if (sendMailDailyLimitEnabled.value && !isValidSendMailLimit(sendMailDailyLimit.value)) {
|
||||
message.error(t('send_mail_daily_limit_invalid'))
|
||||
return false
|
||||
}
|
||||
if (sendMailMonthlyLimitEnabled.value && !isValidSendMailLimit(sendMailMonthlyLimit.value)) {
|
||||
message.error(t('send_mail_monthly_limit_invalid'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchData = async ({ suppressErrorMessage = false } = {}) => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/account_settings`)
|
||||
addressBlockList.value = res.blockList || []
|
||||
@@ -259,33 +369,76 @@ const fetchData = async () => {
|
||||
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
|
||||
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
|
||||
}
|
||||
addressCreationSubdomainMatchStatus.value = {
|
||||
envConfigured: !!res.addressCreationSubdomainMatchStatus?.envConfigured,
|
||||
envEnabled: !!res.addressCreationSubdomainMatchStatus?.envEnabled,
|
||||
storedEnabled: typeof res.addressCreationSubdomainMatchStatus?.storedEnabled === 'boolean'
|
||||
? res.addressCreationSubdomainMatchStatus.storedEnabled
|
||||
: undefined,
|
||||
effectiveEnabled: !!res.addressCreationSubdomainMatchStatus?.effectiveEnabled
|
||||
}
|
||||
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
|
||||
addressCreationSubdomainMatchStatus.value.storedEnabled
|
||||
)
|
||||
const sendMailLimitConfig = res.sendMailLimitConfig
|
||||
sendMailDailyLimitEnabled.value = !!sendMailLimitConfig?.dailyEnabled
|
||||
sendMailMonthlyLimitEnabled.value = !!sendMailLimitConfig?.monthlyEnabled
|
||||
sendMailDailyLimit.value = sendMailDailyLimitEnabled.value
|
||||
? sendMailLimitConfig.dailyLimit
|
||||
: DEFAULT_SEND_MAIL_DAILY_LIMIT
|
||||
sendMailMonthlyLimit.value = sendMailMonthlyLimitEnabled.value
|
||||
? sendMailLimitConfig.monthlyLimit
|
||||
: DEFAULT_SEND_MAIL_MONTHLY_LIMIT
|
||||
} catch (error) {
|
||||
if (!suppressErrorMessage) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!validateSendMailLimit()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/admin/account_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
const payload = {
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || [],
|
||||
fromBlockList: fromBlockList.value || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
||||
emailRuleSettings: emailRuleSettings.value,
|
||||
})
|
||||
addressCreationSettings: {
|
||||
enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value)
|
||||
},
|
||||
sendMailLimitConfig: getSendMailLimitPayload()
|
||||
}
|
||||
await api.fetch(`/admin/account_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchData({ suppressErrorMessage: true })
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh account settings after save', error)
|
||||
message.warning(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fetchData();
|
||||
} catch {
|
||||
// 首次加载失败时,错误提示已经在 fetchData 内部统一处理,这里无需重复提示。
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -340,6 +493,35 @@ onMounted(async () => {
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('send_mail_limit')">
|
||||
<n-flex vertical style="width: 100%;">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-text>{{ t('send_mail_daily_limit') }}</n-text>
|
||||
<n-flex align="center">
|
||||
<n-switch v-model:value="sendMailDailyLimitEnabled" :round="false" />
|
||||
<n-input-number
|
||||
v-model:value="sendMailDailyLimit"
|
||||
:disabled="!sendMailDailyLimitEnabled"
|
||||
:min="-1"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-text>{{ t('send_mail_monthly_limit') }}</n-text>
|
||||
<n-flex align="center">
|
||||
<n-switch v-model:value="sendMailMonthlyLimitEnabled" :round="false" />
|
||||
<n-input-number
|
||||
v-model:value="sendMailMonthlyLimit"
|
||||
:disabled="!sendMailMonthlyLimitEnabled"
|
||||
:min="-1"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-text depth="3">
|
||||
{{ t('send_mail_limit_tip') }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('fromBlockList')">
|
||||
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')">
|
||||
<template #empty>
|
||||
@@ -352,6 +534,29 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('block_receive_unknow_address_email')">
|
||||
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('create_address_subdomain_match')">
|
||||
<n-flex vertical style="width: 100%;">
|
||||
<n-radio-group v-model:value="addressCreationSubdomainMatchMode">
|
||||
<n-space vertical size="small">
|
||||
<n-radio v-for="item in subdomainMatchModeOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_tip') }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_note') }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_follow_env_note') }}
|
||||
</n-text>
|
||||
<n-alert v-if="subdomainMatchEnvLocked" type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('create_address_subdomain_match_env_locked') }}
|
||||
</n-alert>
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('email_forwarding_config')">
|
||||
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
|
||||
</n-form-item-row>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
@@ -22,6 +22,8 @@ const { t } = useI18n({
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
addressPassword: 'Address Password',
|
||||
linkWithAddressCredential: 'Open to auto login email link',
|
||||
enableRandomSubdomain: 'Use Random Subdomain',
|
||||
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
@@ -33,11 +35,14 @@ const { t } = useI18n({
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
addressPassword: '地址密码',
|
||||
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
|
||||
enableRandomSubdomain: '启用随机子域名',
|
||||
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const enablePrefix = ref(true)
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const showReultModal = ref(false)
|
||||
@@ -45,6 +50,19 @@ const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false
|
||||
}
|
||||
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value)
|
||||
})
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const newEmail = async () => {
|
||||
if (!emailName.value || !emailDomain.value) {
|
||||
message.error(t('fillInAllFields'))
|
||||
@@ -55,6 +73,7 @@ const newEmail = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enablePrefix: enablePrefix.value,
|
||||
enableRandomSubdomain: enableRandomSubdomain.value,
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
})
|
||||
@@ -119,6 +138,14 @@ onMounted(async () => {
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading">
|
||||
{{ t('creatNewEmail') }}
|
||||
</n-button>
|
||||
|
||||
@@ -17,6 +17,12 @@ const { t } = useI18n({
|
||||
successTip: 'Save Success',
|
||||
enable_ip_blacklist: 'Enable IP Blacklist',
|
||||
enable_tip: 'Block IPs matching blacklist patterns from accessing rate-limited APIs',
|
||||
enable_ip_whitelist: 'Enable IP Whitelist (Strict)',
|
||||
enable_whitelist_tip: 'Strict mode: ONLY IPs matching the whitelist can access rate-limited APIs. All other IPs will be denied.',
|
||||
ip_whitelist: 'IP Whitelist Patterns',
|
||||
ip_whitelist_placeholder: 'Exact IP (e.g., 1.2.3.4) or anchored regex (e.g., ^192\\.168\\.1\\.\\d+$)',
|
||||
tip_whitelist: 'IP Whitelist: Strict allowlist — plain entries must be EXACT IP matches (no substring). Use anchored regex (^...$) for ranges. Whitelisted IPs skip blacklist checks.',
|
||||
whitelist_empty_warning: 'IP whitelist is enabled but the list is empty. This is ignored by the server to prevent lockout. Please add at least one entry before enabling.',
|
||||
ip_blacklist: 'IP Blacklist Patterns',
|
||||
ip_blacklist_placeholder: 'Enter pattern (e.g., 192.168.1 or ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN Organization Blacklist',
|
||||
@@ -40,6 +46,12 @@ const { t } = useI18n({
|
||||
successTip: '保存成功',
|
||||
enable_ip_blacklist: '启用 IP 黑名单',
|
||||
enable_tip: '阻止匹配黑名单的 IP 访问限流 API',
|
||||
enable_ip_whitelist: '启用 IP 白名单(严格模式)',
|
||||
enable_whitelist_tip: '严格模式:仅允许匹配白名单的 IP 访问限流 API,其他所有 IP 将被拒绝',
|
||||
ip_whitelist: 'IP 白名单匹配模式',
|
||||
ip_whitelist_placeholder: '精确 IP(如 1.2.3.4)或锚定正则(如 ^192\\.168\\.1\\.\\d+$)',
|
||||
tip_whitelist: 'IP 白名单: 严格放行名单——纯文本必须是精确 IP(不支持子串匹配), 批量放行请用锚定正则 ^...$. 命中白名单的 IP 将跳过黑名单检查.',
|
||||
whitelist_empty_warning: 'IP 白名单已启用但列表为空,服务端将忽略该开关以防止锁死。请先添加至少一条白名单条目再启用。',
|
||||
ip_blacklist: 'IP 黑名单匹配模式',
|
||||
ip_blacklist_placeholder: '输入匹配模式(例如:192.168.1 或 ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN 组织(运营商)黑名单',
|
||||
@@ -63,6 +75,8 @@ const enabled = ref(false)
|
||||
const ipBlacklist = ref([])
|
||||
const asnBlacklist = ref([])
|
||||
const fingerprintBlacklist = ref([])
|
||||
const enableWhitelist = ref(false)
|
||||
const ipWhitelist = ref([])
|
||||
const enableDailyLimit = ref(false)
|
||||
const dailyRequestLimit = ref(1000)
|
||||
|
||||
@@ -74,6 +88,8 @@ const fetchData = async () => {
|
||||
ipBlacklist.value = res.blacklist || []
|
||||
asnBlacklist.value = res.asnBlacklist || []
|
||||
fingerprintBlacklist.value = res.fingerprintBlacklist || []
|
||||
enableWhitelist.value = res.enableWhitelist || false
|
||||
ipWhitelist.value = res.whitelist || []
|
||||
enableDailyLimit.value = res.enableDailyLimit || false
|
||||
dailyRequestLimit.value = res.dailyRequestLimit || 1000
|
||||
} catch (error) {
|
||||
@@ -84,6 +100,10 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (enableWhitelist.value && (!ipWhitelist.value || ipWhitelist.value.length === 0)) {
|
||||
message.warning(t('whitelist_empty_warning'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
loading.value = true
|
||||
await api.fetch(`/admin/ip_blacklist/settings`, {
|
||||
@@ -93,6 +113,8 @@ const save = async () => {
|
||||
blacklist: ipBlacklist.value || [],
|
||||
asnBlacklist: asnBlacklist.value || [],
|
||||
fingerprintBlacklist: fingerprintBlacklist.value || [],
|
||||
enableWhitelist: enableWhitelist.value,
|
||||
whitelist: ipWhitelist.value || [],
|
||||
enableDailyLimit: enableDailyLimit.value,
|
||||
dailyRequestLimit: dailyRequestLimit.value
|
||||
})
|
||||
@@ -123,6 +145,7 @@ onMounted(async () => {
|
||||
<n-alert :show-icon="false" :bordered="false" type="info">
|
||||
<div style="line-height: 1.8;">
|
||||
<div><strong>{{ t("tip_scope") }}</strong></div>
|
||||
<div>• {{ t("tip_whitelist") }}</div>
|
||||
<div>• {{ t("tip_ip") }}</div>
|
||||
<div>• {{ t("tip_asn") }}</div>
|
||||
<div>• {{ t("tip_fingerprint") }}</div>
|
||||
@@ -130,6 +153,31 @@ onMounted(async () => {
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<n-form-item-row :label="t('enable_ip_whitelist')">
|
||||
<n-switch v-model:value="enableWhitelist" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
{{ t('enable_whitelist_tip') }}
|
||||
</n-text>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('ip_whitelist')">
|
||||
<n-select
|
||||
v-model:value="ipWhitelist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('ip_whitelist_placeholder')"
|
||||
:disabled="!enableWhitelist">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-form-item-row :label="t('enable_ip_blacklist')">
|
||||
<n-switch v-model:value="enabled" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
|
||||
@@ -13,20 +13,20 @@ const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
role: 'Role',
|
||||
maxAddressCount: 'Max Address Count',
|
||||
maxAddressCount: 'Max Address Count (0 = Unlimited)',
|
||||
save: 'Save',
|
||||
successTip: 'Success',
|
||||
noRolesAvailable: 'No roles available in system config',
|
||||
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings.',
|
||||
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings. Set 0 for unlimited.',
|
||||
notConfigured: 'Not Configured (Use Global Settings)',
|
||||
},
|
||||
zh: {
|
||||
role: '角色',
|
||||
maxAddressCount: '最大地址数量',
|
||||
maxAddressCount: '最大地址数量(0 为不限制)',
|
||||
save: '保存',
|
||||
successTip: '成功',
|
||||
noRolesAvailable: '系统配置中没有可用的角色',
|
||||
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。',
|
||||
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。设置为 0 表示不限制。',
|
||||
notConfigured: '未配置(使用全局设置)',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { api } from '../../api'
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
|
||||
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
fromName: "",
|
||||
@@ -33,6 +34,10 @@ const { t } = useI18n({
|
||||
preview: 'Preview',
|
||||
content: 'Content',
|
||||
send: 'Send',
|
||||
fromMailEmpty: 'Sender address is empty',
|
||||
subjectEmpty: 'Subject is empty',
|
||||
toMailEmpty: 'Recipient address is empty',
|
||||
contentEmpty: 'Content is empty',
|
||||
text: 'Text',
|
||||
html: 'HTML',
|
||||
'rich text': 'Rich Text',
|
||||
@@ -48,6 +53,10 @@ const { t } = useI18n({
|
||||
preview: '预览',
|
||||
content: '内容',
|
||||
send: '发送',
|
||||
fromMailEmpty: '发件人地址不能为空',
|
||||
subjectEmpty: '主题不能为空',
|
||||
toMailEmpty: '收件人地址不能为空',
|
||||
contentEmpty: '内容不能为空',
|
||||
text: '文本',
|
||||
html: 'HTML',
|
||||
'rich text': '富文本',
|
||||
@@ -62,21 +71,77 @@ const contentTypes = [
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const hasSendMailContent = (content, contentType) => {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contentType === 'text') {
|
||||
return normalizeSendMailText(content).length > 0
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = content
|
||||
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
|
||||
|
||||
const plainContent = normalizeSendMailText(container.textContent ?? '')
|
||||
if (plainContent.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromMail = `${sendMailModel.value.fromMail ?? ''}`.trim()
|
||||
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
|
||||
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
|
||||
if (!fromMail) {
|
||||
message.error(t('fromMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!subject) {
|
||||
message.error(t('subjectEmpty'))
|
||||
return
|
||||
}
|
||||
if (!toMail) {
|
||||
message.error(t('toMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
|
||||
message.error(t('contentEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_name: sendMailModel.value.fromName,
|
||||
from_mail: fromMail,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: toMail,
|
||||
subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content,
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.fetch(`/admin/send_mail`,
|
||||
{
|
||||
method: 'POST',
|
||||
body:
|
||||
JSON.stringify({
|
||||
from_name: sendMailModel.value.fromName,
|
||||
from_mail: sendMailModel.value.fromMail,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: sendMailModel.value.toMail,
|
||||
subject: sendMailModel.value.subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content: sendMailModel.value.content,
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
@@ -87,10 +152,11 @@ const send = async () => {
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
message.success(t("successSend"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +191,7 @@ const handleCreated = (editor) => {
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
|
||||
@@ -25,7 +25,13 @@ const { t } = useI18n({
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
addOauth2: 'Add Oauth2',
|
||||
name: 'Name',
|
||||
icon: 'Icon (SVG, please ensure trusted source)',
|
||||
iconPreview: 'Preview',
|
||||
oauth2Type: 'Oauth2 Type',
|
||||
enableEmailFormat: 'Enable Email Format',
|
||||
userEmailFormat: 'Email Regex Pattern',
|
||||
userEmailReplace: 'Replace Template',
|
||||
userEmailFormatTip: 'Use regex to transform email. Example: ^(.+)@old\\.com$ with $1@new.com',
|
||||
tip: 'Third-party login will automatically use the user\'s email to register an account (the same email will be regarded as the same account), this account is the same as the registered account, and you can also set the password through the forget password',
|
||||
},
|
||||
zh: {
|
||||
@@ -38,12 +44,24 @@ const { t } = useI18n({
|
||||
mailAllowList: '邮件地址白名单',
|
||||
addOauth2: '添加 Oauth2',
|
||||
name: '名称',
|
||||
icon: '图标 (SVG, 请确保来源可信)',
|
||||
iconPreview: '预览',
|
||||
oauth2Type: 'Oauth2 类型',
|
||||
enableEmailFormat: '启用邮箱格式转换',
|
||||
userEmailFormat: '邮箱正则表达式',
|
||||
userEmailReplace: '替换模板',
|
||||
userEmailFormatTip: '使用正则转换邮箱。示例: ^(.+)@old\\.com$ 配合 $1@new.com',
|
||||
tip: '第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号), 此账号和注册的账号相同, 也可以通过忘记密码设置密码',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const OAUTH2_ICONS: Record<string, string> = {
|
||||
github: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>',
|
||||
linuxdo: '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><g><path d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z" fill="#EFEFEF"/><path d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z" fill="#FEB005"/><path d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z" fill="#1D1D1F"/></g></svg>',
|
||||
authentik: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12zM11 7v6h2V7h-2zm0 8v2h2v-2h-2z"/></svg>',
|
||||
};
|
||||
|
||||
const mailAllowOptions = constant.COMMOM_MAIL.map((item) => {
|
||||
return { label: item, value: item }
|
||||
})
|
||||
@@ -75,80 +93,59 @@ const save = async () => {
|
||||
}
|
||||
|
||||
const addNewOauth2 = () => {
|
||||
const authorizationURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://github.com/login/oauth/authorize'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/authorize/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const accessTokenURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://github.com/login/oauth/access_token'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/token/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const accessTokenFormat = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'json'
|
||||
case 'authentik':
|
||||
return 'urlencoded'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const userInfoURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://api.github.com/user'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/userinfo/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const userEmailKey = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'email'
|
||||
case 'authentik':
|
||||
return 'email'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const scope = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'user:email'
|
||||
case 'authentik':
|
||||
return 'email openid'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
const templates: Record<string, Partial<UserOauth2Settings>> = {
|
||||
github: {
|
||||
authorizationURL: 'https://github.com/login/oauth/authorize',
|
||||
accessTokenURL: 'https://github.com/login/oauth/access_token',
|
||||
accessTokenFormat: 'json',
|
||||
userInfoURL: 'https://api.github.com/user',
|
||||
userEmailKey: 'email',
|
||||
scope: 'user:email',
|
||||
icon: OAUTH2_ICONS.github,
|
||||
},
|
||||
linuxdo: {
|
||||
authorizationURL: 'https://connect.linux.do/oauth2/authorize',
|
||||
accessTokenURL: 'https://connect.linux.do/oauth2/token',
|
||||
accessTokenFormat: 'urlencoded',
|
||||
userInfoURL: 'https://connect.linux.do/api/user',
|
||||
userEmailKey: 'id',
|
||||
scope: 'user',
|
||||
enableEmailFormat: true,
|
||||
userEmailFormat: '^(.+)$',
|
||||
userEmailReplace: 'linux_do_$1@oauth.linux.do',
|
||||
icon: OAUTH2_ICONS.linuxdo,
|
||||
},
|
||||
authentik: {
|
||||
authorizationURL: 'https://youdomain/application/o/authorize/',
|
||||
accessTokenURL: 'https://youdomain/application/o/token/',
|
||||
accessTokenFormat: 'urlencoded',
|
||||
userInfoURL: 'https://youdomain/application/o/userinfo/',
|
||||
userEmailKey: 'email',
|
||||
scope: 'email openid',
|
||||
icon: OAUTH2_ICONS.authentik,
|
||||
},
|
||||
custom: {},
|
||||
}
|
||||
const template = templates[newOauth2Type.value] || {}
|
||||
userOauth2Settings.value.push({
|
||||
name: newOauth2Name.value,
|
||||
icon: '',
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
authorizationURL: authorizationURL(),
|
||||
accessTokenURL: accessTokenURL(),
|
||||
accessTokenFormat: accessTokenFormat(),
|
||||
userInfoURL: userInfoURL(),
|
||||
userEmailKey: userEmailKey(),
|
||||
authorizationURL: '',
|
||||
accessTokenURL: '',
|
||||
accessTokenFormat: '',
|
||||
userInfoURL: '',
|
||||
userEmailKey: '',
|
||||
redirectURL: `${window.location.origin}/user/oauth2/callback`,
|
||||
logoutURL: '',
|
||||
scope: scope(),
|
||||
scope: '',
|
||||
enableEmailFormat: false,
|
||||
userEmailFormat: '',
|
||||
userEmailReplace: '',
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: constant.COMMOM_MAIL
|
||||
mailAllowList: constant.COMMOM_MAIL,
|
||||
...template,
|
||||
} as UserOauth2Settings)
|
||||
newOauth2Name.value = ''
|
||||
showAddOauth2.value = false
|
||||
@@ -174,6 +171,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('oauth2Type')" required>
|
||||
<n-radio-group v-model:value="newOauth2Type">
|
||||
<n-radio-button value="github" label="Github" />
|
||||
<n-radio-button value="linuxdo" label="Linux Do" />
|
||||
<n-radio-button value="authentik" label="Authentik" />
|
||||
<n-radio-button value="custom" label="Custom" />
|
||||
</n-radio-group>
|
||||
@@ -214,11 +212,18 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('name')" required>
|
||||
<n-input v-model:value="item.name" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('icon')">
|
||||
<n-input v-model:value="item.icon" type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }" style="width: 100%;" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="item.icon" :label="t('iconPreview')">
|
||||
<span class="oauth2-icon-preview" v-html="item.icon"></span>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Client ID" required>
|
||||
<n-input v-model:value="item.clientID" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Client Secret" required>
|
||||
<n-input v-model:value="item.clientSecret" type="password" show-password-on="click" />
|
||||
<n-input v-model:value="item.clientSecret" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Authorization URL" required>
|
||||
<n-input v-model:value="item.authorizationURL" />
|
||||
@@ -235,6 +240,27 @@ onMounted(async () => {
|
||||
<n-form-item-row label="User Email Key (Support JSONPATH like $[0].email)" required>
|
||||
<n-input v-model:value="item.userEmailKey" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableEmailFormat')">
|
||||
<n-checkbox v-model:checked="item.enableEmailFormat">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="item.enableEmailFormat" :label="t('userEmailFormat')">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-input v-model:value="item.userEmailFormat" :placeholder="'^(.+)@old\\.com$'" />
|
||||
</template>
|
||||
{{ t('userEmailFormatTip') }}
|
||||
</n-tooltip>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="item.enableEmailFormat" :label="t('userEmailReplace')">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-input v-model:value="item.userEmailReplace" placeholder="$1@new.com" />
|
||||
</template>
|
||||
{{ t('userEmailFormatTip') }}
|
||||
</n-tooltip>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Redirect URL" required>
|
||||
<n-input v-model:value="item.redirectURL" />
|
||||
</n-form-item-row>
|
||||
@@ -271,4 +297,20 @@ onMounted(async () => {
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.oauth2-icon-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.oauth2-icon-preview :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,7 +20,9 @@ const { t } = useI18n({
|
||||
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
maxAddressCount: 'Maximum number of email addresses that can be binded',
|
||||
maxAddressCount: 'Maximum number of email addresses that can be binded (0 = Unlimited)',
|
||||
emailCheckRegex: "Email Check Regex (e.g. ^[^.]+{'@'}.+$ to disallow dots before {'@'})",
|
||||
enableEmailCheckRegex: 'Enable Email Check Regex',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
@@ -32,7 +34,9 @@ const { t } = useI18n({
|
||||
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量(0 为不限制)',
|
||||
emailCheckRegex: "邮箱正则校验 (例如 ^[^.]+{'@'}.+$ 禁止{'@'}前面有.)",
|
||||
enableEmailCheckRegex: '启用邮箱正则校验',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -53,6 +57,8 @@ const userSettings = ref({
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: commonMail,
|
||||
maxAddressCount: 5,
|
||||
enableEmailCheckRegex: false,
|
||||
emailCheckRegex: "",
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -125,6 +131,16 @@ onMounted(async () => {
|
||||
:placeholder="t('maxAddressCount')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableEmailCheckRegex')">
|
||||
<n-flex align="center" :wrap="false" style="width: 100%;">
|
||||
<n-checkbox v-model:checked="userSettings.enableEmailCheckRegex" style="flex: 0 0 auto;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-input v-model:value="userSettings.emailCheckRegex"
|
||||
v-show="userSettings.enableEmailCheckRegex"
|
||||
style="flex: 1 1 auto;" :placeholder="t('emailCheckRegex')" />
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
|
||||
@@ -19,13 +19,14 @@ const props = defineProps({
|
||||
},
|
||||
newAddressPath: {
|
||||
type: Function,
|
||||
default: async (address_name, domain, cf_token) => {
|
||||
default: async (address_name, domain, cf_token, enableRandomSubdomain) => {
|
||||
return await api.fetch("/api/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: address_name,
|
||||
domain: domain,
|
||||
cf_token: cf_token,
|
||||
enableRandomSubdomain: enableRandomSubdomain,
|
||||
}),
|
||||
});
|
||||
},
|
||||
@@ -47,6 +48,9 @@ const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const loginCfToken = ref("")
|
||||
const loginTurnstileRef = ref(null)
|
||||
const loginMethod = ref('credential') // 'credential' or 'password'
|
||||
const loginAddress = ref('')
|
||||
const loginPassword = ref('')
|
||||
@@ -72,7 +76,8 @@ const login = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: loginAddress.value,
|
||||
password: await hashPassword(loginPassword.value)
|
||||
password: await hashPassword(loginPassword.value),
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
jwt.value = res.jwt;
|
||||
@@ -85,6 +90,7 @@ const login = async () => {
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -93,6 +99,13 @@ const login = async () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch('/open_api/credential_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credential: credential.value,
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
jwt.value = credential.value;
|
||||
await api.getSettings();
|
||||
try {
|
||||
@@ -103,6 +116,7 @@ const login = async () => {
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +143,8 @@ const { locale, t } = useI18n({
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
emailPasswordRequired: 'Email and password are required',
|
||||
enableRandomSubdomain: 'Use Random Subdomain',
|
||||
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
|
||||
},
|
||||
zh: {
|
||||
login: '登录',
|
||||
@@ -151,6 +167,8 @@ const { locale, t } = useI18n({
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
emailPasswordRequired: '邮箱和密码不能为空',
|
||||
enableRandomSubdomain: '启用随机子域名',
|
||||
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -203,7 +221,8 @@ const newEmail = async () => {
|
||||
const res = await props.newAddressPath(
|
||||
nameToSend,
|
||||
emailDomain.value,
|
||||
cfToken.value
|
||||
cfToken.value,
|
||||
enableRandomSubdomain.value
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
addressPassword.value = res["password"] || '';
|
||||
@@ -229,6 +248,19 @@ const addressPrefix = computed(() => {
|
||||
return openSettings.value.prefix;
|
||||
});
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false;
|
||||
}
|
||||
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value);
|
||||
});
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const domainsOptions = computed(() => {
|
||||
// if user has role, return role domains
|
||||
if (userSettings.value.user_role) {
|
||||
@@ -289,6 +321,9 @@ onMounted(async () => {
|
||||
</n-form-item-row>
|
||||
</div>
|
||||
|
||||
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck"
|
||||
v-model:value="loginCfToken" />
|
||||
|
||||
<div class="switch-login-button">
|
||||
<n-button v-if="openSettings?.enableAddressPassword"
|
||||
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
||||
@@ -335,6 +370,14 @@ onMounted(async () => {
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="domainsOptions" />
|
||||
</n-input-group>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
||||
<template #icon>
|
||||
|
||||
@@ -21,7 +21,8 @@ const { t } = useI18n({
|
||||
en: {
|
||||
success: 'Success',
|
||||
settings: 'Settings',
|
||||
sourcePrefix: 'Source Mail Prefix',
|
||||
sourcePrefix: 'Sender Filter',
|
||||
sourcePrefixPlaceholder: 'Empty=all, prefix match, or /regex/',
|
||||
name: 'Name',
|
||||
enableAutoReply: 'Enable Auto Reply',
|
||||
subject: 'Subject',
|
||||
@@ -31,7 +32,8 @@ const { t } = useI18n({
|
||||
zh: {
|
||||
success: '成功',
|
||||
settings: '设置',
|
||||
sourcePrefix: '来源邮件前缀',
|
||||
sourcePrefix: '发件人过滤',
|
||||
sourcePrefixPlaceholder: '留空=全部匹配,前缀匹配,或 /正则/',
|
||||
name: '名称',
|
||||
enableAutoReply: '启用自动回复',
|
||||
subject: '主题',
|
||||
@@ -93,7 +95,8 @@ onMounted(async () => {
|
||||
<n-input :disabled="!enableAutoReply" v-model:value="name" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('sourcePrefix')" label-placement="left">
|
||||
<n-input :disabled="!enableAutoReply" v-model:value="sourcePrefix" />
|
||||
<n-input :disabled="!enableAutoReply" v-model:value="sourcePrefix"
|
||||
:placeholder="t('sourcePrefixPlaceholder')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="left">
|
||||
<n-input :disabled="!enableAutoReply" v-model:value="subject" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { api } from '../../api'
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
|
||||
|
||||
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
|
||||
@@ -28,8 +29,11 @@ const { t } = useI18n({
|
||||
preview: 'Preview',
|
||||
content: 'Content',
|
||||
send: 'Send',
|
||||
subjectEmpty: 'Subject is empty',
|
||||
toMailEmpty: 'Recipient address is empty',
|
||||
contentEmpty: 'Content is empty',
|
||||
requestAccess: 'Request Access',
|
||||
requestAccessTip: 'You need to request access to send mail, if have request, please contact admin.',
|
||||
requestAccessTip: 'No send balance yet. If your admin enabled a default balance it should be assigned automatically; otherwise request access or contact the admin.',
|
||||
send_balance: 'Send Mail Balance Left',
|
||||
text: 'Text',
|
||||
html: 'HTML',
|
||||
@@ -46,8 +50,11 @@ const { t } = useI18n({
|
||||
preview: '预览',
|
||||
content: '内容',
|
||||
send: '发送',
|
||||
subjectEmpty: '主题不能为空',
|
||||
toMailEmpty: '收件人地址不能为空',
|
||||
contentEmpty: '内容不能为空',
|
||||
requestAccess: '申请权限',
|
||||
requestAccessTip: '您需要申请权限才能发送邮件, 如果已经申请过, 请联系管理员提升额度。',
|
||||
requestAccessTip: '当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。',
|
||||
send_balance: '剩余发送邮件额度',
|
||||
text: '文本',
|
||||
html: 'HTML',
|
||||
@@ -63,20 +70,71 @@ const contentTypes = [
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const hasSendMailContent = (content, contentType) => {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contentType === 'text') {
|
||||
return normalizeSendMailText(content).length > 0
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = content
|
||||
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
|
||||
|
||||
const plainContent = normalizeSendMailText(container.textContent ?? '')
|
||||
if (plainContent.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
|
||||
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
|
||||
if (!subject) {
|
||||
message.error(t('subjectEmpty'))
|
||||
return
|
||||
}
|
||||
if (!toMail) {
|
||||
message.error(t('toMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
|
||||
message.error(t('contentEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: toMail,
|
||||
subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content,
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.fetch(`/api/send_mail`,
|
||||
{
|
||||
method: 'POST',
|
||||
body:
|
||||
JSON.stringify({
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: sendMailModel.value.toMail,
|
||||
subject: sendMailModel.value.subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content: sendMailModel.value.content,
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
@@ -86,17 +144,19 @@ const send = async () => {
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
indexTab.value = 'sendbox'
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
indexTab.value = 'sendbox'
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const requestAccess = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/requset_send_mail_access`,
|
||||
await api.fetch(`/api/request_send_mail_access`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
@@ -158,7 +218,7 @@ onMounted(async () => {
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
|
||||
@@ -52,13 +52,19 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const newAddressPath = async (address_name: string, domain: string, cf_token: string) => {
|
||||
const newAddressPath = async (
|
||||
address_name: string,
|
||||
domain: string,
|
||||
cf_token: string,
|
||||
enableRandomSubdomain: boolean
|
||||
) => {
|
||||
return await api.fetch("/telegram/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
address: `${address_name}@${domain}`,
|
||||
cf_token: cf_token,
|
||||
enableRandomSubdomain,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,7 +69,12 @@ const user = ref({
|
||||
password: "",
|
||||
code: ""
|
||||
});
|
||||
const cfToken = ref("")
|
||||
const signupCfToken = ref("")
|
||||
const resetCfToken = ref("")
|
||||
const loginCfToken = ref("")
|
||||
const signupTurnstileRef = ref(null)
|
||||
const resetTurnstileRef = ref(null)
|
||||
const loginTurnstileRef = ref(null)
|
||||
|
||||
const emailLogin = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
@@ -82,13 +87,15 @@ const emailLogin = async () => {
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password)
|
||||
password: await hashPassword(user.value.password),
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
userJwt.value = res.jwt;
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
message.error(error.message || "login failed");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,7 +112,8 @@ const sendVerificationCode = async () => {
|
||||
message.error(t('pleaseInputEmail'));
|
||||
return;
|
||||
}
|
||||
if (openSettings.value.cfTurnstileSiteKey && !cfToken.value && userOpenSettings.value.enableMailVerify) {
|
||||
const currentCfToken = showModal.value ? resetCfToken.value : signupCfToken.value;
|
||||
if (openSettings.value.cfTurnstileSiteKey && !currentCfToken && userOpenSettings.value.enableMailVerify) {
|
||||
message.error(t('pleaseCompleteTurnstile'));
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +122,7 @@ const sendVerificationCode = async () => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
cf_token: cfToken.value
|
||||
cf_token: currentCfToken
|
||||
})
|
||||
});
|
||||
if (res && res.expirationTtl) {
|
||||
@@ -131,6 +139,11 @@ const sendVerificationCode = async () => {
|
||||
} catch (error) {
|
||||
message.error(error.message || "send verification code failed");
|
||||
}
|
||||
if (showModal.value) {
|
||||
resetTurnstileRef.value?.refresh?.();
|
||||
} else {
|
||||
signupTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
};
|
||||
|
||||
const emailSignup = async () => {
|
||||
@@ -149,7 +162,8 @@ const emailSignup = async () => {
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password),
|
||||
code: user.value.code
|
||||
code: user.value.code,
|
||||
cf_token: showModal.value ? resetCfToken.value : signupCfToken.value
|
||||
}),
|
||||
message: message
|
||||
});
|
||||
@@ -171,7 +185,7 @@ const passkeyLogin = async () => {
|
||||
domain: location.hostname,
|
||||
})
|
||||
})
|
||||
const credential = await startAuthentication(options)
|
||||
const credential = await startAuthentication({ optionsJSON: options })
|
||||
|
||||
// Send the result to the server and return the promise.
|
||||
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
|
||||
@@ -218,6 +232,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" />
|
||||
<n-button @click="emailLogin" type="primary" block secondary strong>
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
@@ -233,6 +248,9 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
<n-button @click="oauth2Login(item.clientID)" v-for="item in userOpenSettings.oauth2ClientIDs"
|
||||
:key="item.clientID" block secondary strong>
|
||||
<template #icon v-if="item.icon">
|
||||
<span class="oauth2-icon" v-html="item.icon"></span>
|
||||
</template>
|
||||
{{ t('loginWith', { provider: item.name }) }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
@@ -245,7 +263,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
|
||||
<Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
|
||||
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
@@ -256,6 +274,7 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<Turnstile ref="signupTurnstileRef" v-if="!userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
|
||||
</n-form>
|
||||
<n-button @click="emailSignup" type="primary" block secondary strong>
|
||||
{{ t('register') }}
|
||||
@@ -270,7 +289,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" />
|
||||
<n-form-item-row :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
@@ -305,4 +324,17 @@ onMounted(async () => {
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.oauth2-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.oauth2-icon :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user