Compare commits

..
Author SHA1 Message Date
dreamhunter2333andClaude 8e5293f010 fix: enhance input validation with trim() for address creation
- Add trim() handling in newAddress() function to prevent whitespace issues
- Add trim() handling for address prefixes to ensure consistent formatting
- Add trim() handling in Telegram API address parsing for robustness
- Prevents edge cases with whitespace-only or padded input strings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 02:06:53 +08:00
dreamhunter2333 711378cb11 feat: add var DISABLE_CUSTOM_ADDRESS_NAME and CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST 2025-09-04 01:59:15 +08:00
266 changed files with 6590 additions and 24572 deletions
@@ -1,3 +0,0 @@
config.json
__pycache__/
*.py[cod]
@@ -1,30 +0,0 @@
---
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.
@@ -1,5 +0,0 @@
{
"token": "<telegram bot token>",
"chat_id": "@cloudflare_temp_email",
"message_thread_id": 82
}
@@ -1,221 +0,0 @@
#!/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()
@@ -1,28 +0,0 @@
---
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.
@@ -1,42 +0,0 @@
# 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`
@@ -1,43 +0,0 @@
---
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.
-161
View File
@@ -1,161 +0,0 @@
---
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` 1100, `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.
@@ -1,56 +0,0 @@
---
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
```
-11
View File
@@ -1,11 +0,0 @@
**/node_modules/
.git/
vitepress-docs/
smtp_proxy_server/
mail-parser-wasm/
db/
**/.wrangler/
**/dist/
**/test-results/
**/playwright-report/
.DS_Store
+6 -19
View File
@@ -1,14 +1,16 @@
diff --git a/worker/src/common.ts b/worker/src/common.ts
index 9b758f0..e2150b5 100644
index bd9bcc9..e7e2748 100644
--- a/worker/src/common.ts
+++ b/worker/src/common.ts
@@ -469,29 +469,29 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
@@ -273,23 +273,23 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
}
const raw_mail = parsedEmailContext.rawEmail;
// NOTE: WASM parse email
// TODO: 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 || "",
@@ -18,20 +20,11 @@ index 9b758f0..e2150b5 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 || "",
@@ -41,12 +34,6 @@ index 9b758f0..e2150b5 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) {
+13 -27
View File
@@ -16,45 +16,34 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 10
version: 8
run_install: false
- name: Deploy Backend for ${{ github.ref_name }}
run: |
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
if [ -n "$use_worker_assets" ]; then
cd frontend/
pnpm install --no-frozen-lockfile
if [ -n "$use_worker_assets_with_telegram" ]; then
echo "Building with telegram pages"
pnpm build:telegram:pages
else
echo "Building with normal pages"
pnpm build:pages
fi
pnpm build:pages
cd ..
fi
export debug_mode=${{ secrets.DEBUG_MODE }}
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
cd worker/
# ✅ 修复核心:使用环境变量写入,避免 Shell 解析特殊字符
printf '%s\n' "$WRANGLER_TOML_CONTENT" > wrangler.toml
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
pnpm install --no-frozen-lockfile
if [ -n "$use_mail_wasm_parser" ]; then
@@ -64,20 +53,17 @@ jobs:
echo "Applied mail-parser-wasm-worker patch"
fi
if [ "$debug_mode" = "true" ]; then
if [ -n "$debug_mode" ]; then
pnpm run deploy
else
if pnpm run deploy >/dev/null 2>&1; then
echo "Deploy succeeded"
else
code=$?
echo "Command failed with exit code $code"
exit "$code"
output=$(pnpm run deploy 2>&1)
if [ $? -ne 0 ]; then
code=$?
echo "Command failed with exit code $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 }}
+38 -24
View File
@@ -1,50 +1,64 @@
name: Deploy Docs
on:
workflow_run:
workflows: ["Tag Build CI"]
types:
- completed
push:
paths:
- "vitepress-docs/**"
tags:
- "*"
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@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
- name: Install Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 10
version: 8
run_install: false
- name: Deploy Docs
- name: check github release done
run: |
for ((attempt=1; attempt<=10; attempt++)); do
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then
echo "frontend.zip found."
break
else
if [ $attempt -eq 10 ]; then
echo "Exceeded maximum retries. frontend.zip not found."
else
echo "frontend.zip not found. Retrying in 30 seconds..."
sleep 30
fi
fi
done
- name: Deploy Docs for ${{github.ref_name}}
run: |
cd vitepress-docs/
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
pnpm install --no-frozen-lockfile
if [[ ${{github.ref}} == refs/tags/* ]]; then
export TAG_NAME=${{github.ref_name}}
else
export TAG_NAME=$(git describe --tags --abbrev=0)
fi
echo "Deploying docs for tag $TAG_NAME"
pnpm run deploy
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
TAG_NAME=$(gh release view --json tagName --jq '.tagName')
echo "Deploying docs for tag $TAG_NAME"
export TAG_NAME
pnpm run deploy
-34
View File
@@ -1,34 +0,0 @@
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
+25 -46
View File
@@ -10,76 +10,55 @@ on:
workflow_dispatch:
jobs:
deploy-frontend:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 10
version: 8
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=$FRONTEND_NAME --branch $frontend_branch
pnpm run deploy:actions --project-name=$project_name --branch $frontend_branch
else
echo "Deploying branch production"
pnpm run deploy --project-name=$FRONTEND_NAME
pnpm run deploy --project-name=$project_name
fi
echo "Deploying production for ${{ github.ref_name }}"
echo "Deployed for tag ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FRONTEND_NAME: ${{ secrets.FRONTEND_NAME }}
deploy-telegram-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 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
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
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,48 +1,27 @@
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@v6
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 10
version: 8
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}
@@ -54,7 +33,7 @@ jobs:
echo '${{ secrets.PAGE_TOML }}' > wrangler.toml
pnpm install --no-frozen-lockfile
pnpm run deploy
echo "Deploying production for ${{ github.ref_name }}"
echo "Deploying prodcution for ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -3
View File
@@ -3,7 +3,6 @@ name: Codium PR Agent
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
jobs:
pr_agent_job:
if: ${{ github.event.sender.type != 'Bot' }}
@@ -11,12 +10,11 @@ jobs:
permissions:
issues: write
pull-requests: write
contents: write
name: Run pr agent on every pull request, respond to user comments
steps:
- name: PR Agent action step
id: pragent
uses: qodo-ai/pr-agent@main
uses: docker://codiumai/pr-agent:0.29-github_action
env:
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
+7 -10
View File
@@ -21,31 +21,28 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Set lowercase repository name
run: echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
uses: docker/setup-buildx-action@v3
- name: Build and push Docker images
uses: docker/build-push-action@v7
uses: docker/build-push-action@v5
with:
context: ./smtp_proxy_server
file: ./smtp_proxy_server/dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest
+10 -3
View File
@@ -12,7 +12,14 @@ jobs:
if: ${{ github.event.repository.fork }}
steps:
- uses: actions/checkout@v4
- name: Sync upstream changes
run: gh repo sync ${{ github.repository }} --source dreamhunter2333/cloudflare_temp_email --branch main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
+16 -87
View File
@@ -6,21 +6,24 @@ on:
- "*"
jobs:
build-frontend:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v3
name: Install pnpm
id: pnpm-install
with:
version: 10
version: 8
run_install: false
- name: Build Frontend
@@ -29,58 +32,12 @@ 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
@@ -100,39 +57,11 @@ jobs:
pnpm build
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
- name: Upload worker.js
uses: actions/upload-artifact@v7
- name: Upload to Release
uses: softprops/action-gh-release@v2
with:
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 }}
files: |
frontend/frontend.zip
frontend/telegram-frontend.zip
worker/worker.js
worker/worker-with-wasm-mail-parser.zip
-5
View File
@@ -137,8 +137,3 @@ dist
wrangler.toml
.dev.vars
pnpm-lock.yaml
# E2E test artifacts
e2e/test-results/
e2e/playwright-report/
e2e/.e2e-pids
+2 -206
View File
@@ -1,215 +1,11 @@
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
<p align="center">
<a href="CHANGELOG.md">中文</a> |
<a href="CHANGELOG_EN.md">English</a>
</p>
## 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
- fix: |定时任务| 修复定时任务清理报错 `e.get is not a function`,使用可选链安全访问 Context 方法
### Improvements
- style: |AI 提取| 暗色模式下 AI 提取信息使用更柔和的蓝色 (#A8C7FA),减少视觉疲劳
## v1.2.0
### Breaking Changes
- |数据库| 新增 `source_meta` 字段,需执行 `db/2025-12-27-source-meta.sql` 更新数据库或到 admin 维护页面点击数据库更新按钮
### Features
- feat: |Admin| 新增管理员账号页面,显示当前登录方式并支持退出登录(仅限密码登录方式)
- fix: |GitHub Actions| 修复容器镜像名需要全部小写的问题
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
- feat: |地址来源| 新增地址来源追踪功能,记录地址创建来源(Web 记录 IP,Telegram 记录用户 IDAdmin 后台标记)
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
- feat: |前端| 地址切换统一为下拉组件,极简模式支持切换,主页提供地址管理入口
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
- feat: |Admin| 维护页面增加自定义 SQL 清理功能,支持定时任务执行自定义清理语句
- feat: |国际化| 后端 API 错误消息全面支持中英文国际化
- feat: |Telegram| 机器人支持中英文切换,新增 `/lang` 命令设置语言偏好
## v1.1.0
- feat: |AI 提取| 增加 AI 邮件识别功能,使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- 支持优先级提取:验证码 > 认证链接 > 服务链接 > 订阅链接 > 其他链接
- 管理员可配置地址白名单(支持通配符,如 `*@example.com`
- 前端列表和详情页展示提取结果
- 需要配置 `ENABLE_AI_EMAIL_EXTRACT` 环境变量和 AI 绑定
- 需要执行 `db/2025-12-06-metadata.sql` 文件中的 SQL 更新 `D1` 数据库 或者到 admin维护页面点击数据库更新按钮
- feat: |Admin| 维护页面增加清理 n 天前空邮件的邮箱地址功能
- fix: 修复自定义认证密码功能异常的问题 (前端属性名错误 & /open_api 接口被拦截)
## v1.0.7
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
- feat: |Admin| 新增 ASN 组织黑名单功能,支持基于 ASN 组织名称过滤请求(支持文本匹配和正则表达式)
- feat: |Admin| 新增浏览器指纹黑名单功能,支持基于浏览器指纹过滤请求(支持精确匹配和正则表达式)
## v1.0.6
- feat: |DB| update db schema add index
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
## v1.0.5
## main(v1.0.5)
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
- feat: |UI| 主页增加进入极简模式按钮
- feat: |Webhook| 增加白名单开关功能,支持灵活控制访问权限
## v1.0.4
-731
View File
@@ -1,731 +0,0 @@
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
# CHANGE LOG
<p align="center">
<a href="CHANGELOG.md">中文</a> |
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.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
- fix: |Scheduled Tasks| Fix scheduled task cleanup error `e.get is not a function`, use optional chaining for safe access to Context methods
### Improvements
- style: |AI Extraction| Use softer blue color (#A8C7FA) for AI extraction info in dark mode to reduce eye strain
## v1.2.0
### Breaking Changes
- |Database| Add `source_meta` field, need to execute `db/2025-12-27-source-meta.sql` to update database or click database update button on admin maintenance page
### Features
- feat: |Admin| Add admin account page, display current login method and support logout (password login only)
- fix: |GitHub Actions| Fix container image name must be lowercase
- feat: |Email Forwarding| Add source address regex forwarding, filter by sender address, fully backward compatible
- feat: |Address Source| Add address source tracking feature, record address creation source (Web records IP, Telegram records user ID, Admin panel marked)
- feat: |Email Filtering| Remove backend keyword parameter, switch to frontend filtering of current page emails, optimize query performance
- feat: |Frontend| Unify address switching into a dropdown component, support switching in simple mode, add address management entry on the homepage
- feat: |Database| Add index for `message_id` field to optimize email update operations, need to execute `db/2025-12-15-message-id-index.sql` to update database
- feat: |Admin| Add custom SQL cleanup feature to maintenance page, support scheduled task execution of custom cleanup statements
- feat: |i18n| Backend API error messages now fully support Chinese and English internationalization
- feat: |Telegram| Bot supports Chinese/English switching, add `/lang` command to set language preference
## v1.1.0
- feat: |AI Extraction| Add AI email recognition feature, use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
- Support priority extraction: verification codes > authentication links > service links > subscription links > other links
- Admin can configure address whitelist (supports wildcards, e.g. `*@example.com`)
- Frontend list and detail pages display extraction results
- Need to configure `ENABLE_AI_EMAIL_EXTRACT` environment variable and AI binding
- Need to execute SQL in `db/2025-12-06-metadata.sql` file to update `D1` database or click database update button on admin maintenance page
- feat: |Admin| Add feature to cleanup addresses with empty mailboxes older than n days on maintenance page
- fix: Fix custom authentication password function issue (frontend property name error & /open_api interface blocked)
## v1.0.7
- feat: |Admin| Add IP blacklist feature for limiting high-frequency API access
- feat: |Admin| Add ASN organization blacklist feature, support filtering requests based on ASN organization name (supports text matching and regex)
- feat: |Admin| Add browser fingerprint blacklist feature, support filtering requests based on browser fingerprint (supports exact matching and regex)
## v1.0.6
- feat: |DB| Update db schema add index
- feat: |Address Password| Add address password login feature, enabled via `ENABLE_ADDRESS_PASSWORD` configuration, need to execute SQL in `db/2025-09-23-patch.sql` file to update `D1` database
- fix: |GitHub Actions| Fix debug mode configuration, only enable debug mode when DEBUG_MODE is 'true'
- feat: |Admin| Account management page adds multi-select batch operations (batch delete, batch clear inbox, batch clear outbox)
- feat: |Admin| Maintenance page adds feature to cleanup unbound user addresses
- feat: Support configuring different bound address quantity limits for different roles, configurable in admin page
## v1.0.5
- feat: Add `DISABLE_CUSTOM_ADDRESS_NAME` configuration: disable custom email address name feature
- feat: Add `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` configuration: prioritize first domain when creating addresses
- feat: |UI| Add button to enter minimalist mode on homepage
- feat: |Webhook| Add whitelist switch feature, support flexible access control
## v1.0.4
- feat: |UI| Optimize minimalist mode homepage, add all emails page functionality (delete/download/attachments/...), switchable in `Appearance`
- feat: Admin account settings page adds `Email Forwarding Rules` configuration
- feat: Admin account settings page adds `Reject Unknown Address Emails` configuration
- feat: Email page adds Previous/Next buttons
## v1.0.3
- fix: Fix github actions deployment issue
- feat: telegram /new when domain not specified, use random address
## v1.0.2
- fix: Fix oauth2 login failure issue
## v1.0.1
- feat: |UI| Add minimalist mode homepage, switchable in `Appearance`
- fix: Fix oauth2 login default role not taking effect issue
## v1.0.0
- fix: |UI| Fix User inbox viewing, when address not selected, keyword query not working
- fix: Fix auto cleanup task, time 0 not taking effect issue
- feat: Cleanup feature adds cleanup of addresses created n days ago, cleanup of addresses inactive for n days
- fix: |IMAP Proxy| Fix IMAP Proxy server unable to view new emails issue
## v0.10.0
- feat: Support User inbox viewing, `/user_api/mails` interface, support `address` and `keyword` filtering
- fix: Fix Oauth2 login token retrieval, some Oauth2 require `redirect_uri` parameter issue
- feat: When user accesses webpage, if `user token` expires within 7 days, auto refresh
- feat: Add db initialization feature to admin portal
- feat: Add `ALWAYS_SHOW_ANNOUNCEMENT` variable to configure whether to always show announcements
## v0.9.1
- feat: |UI| Support google ads
- feat: |UI| Use shadow DOM to prevent style pollution
- feat: |UI| Support URL jwt parameter auto-login to mailbox, jwt parameter overrides browser jwt
- fix: |CleanUP| Fix cleanup emails when cleanup time exceeds 30 days error bug
- feat: Admin user management page: add user address viewing feature
- feat: | S3 Attachments| Add S3 attachment deletion feature
- feat: | Admin API| Add admin bind user and address api
- feat: | Oauth2 | When Oauth2 gets user info, support `JSONPATH` expressions
## v0.9.0
- feat: | Worker | Support multi-language
- feat: | Worker | `NO_LIMIT_SEND_ROLE` configuration supports multiple roles, comma separated
- feat: | Actions | Add `worker-with-wasm-mail-parser.zip` in build to support UI deployment with `wasm` worker
## v0.8.7
- fix: |UI| Fix mobile device date display issue
- feat: |Worker| Support sending emails via `SMTP`, using [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
## v0.8.6
- feat: |UI| Announcements support html format
- feat: |UI| `COPYRIGHT` supports html format
- feat: |Doc| Optimize deployment documentation, supplement `Github Actions Deployment Documentation`, add `Worker Variable Description`
## v0.8.5
- feat: |mail-parser-wasm-worker| Fix `deprecated` parameter warning when calling `initSync` function
- feat: rpc headers convert & typo (#559)
- fix: telegram mail page use iframe show email (#561)
- feat: |Worker| Add `REMOVE_ALL_ATTACHMENT` and `REMOVE_EXCEED_SIZE_ATTACHMENT` for removing email attachments, due to parsing emails some information will be lost, such as images.
## v0.8.4
- fix: |UI| Fix admin portal delete call api error when no recipient email
- feat: |Telegram Bot| Add telegram bot cleanup invalid address credentials command
- feat: Add worker configuration `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` to disable anonymous user email creation, only allow logged-in users to create email addresses
- feat: Add worker configuration `ENABLE_ANOTHER_WORKER` and `ANOTHER_WORKER_LIST`, for calling other worker rpc interfaces (#547)
- feat: |UI| Auto refresh configuration saved to browser, configurable refresh interval
- feat: Spam detection adds check-when-exists list `JUNK_MAIL_CHECK_LIST` configuration
- feat: | Worker | Add `ParsedEmailContext` class for caching parsed email content, reduce parsing times
- feat: |Github Action| Worker deployment adds `DEBUG_MODE` output logging, `BACKEND_USE_MAIL_WASM_PARSER` configuration for whether to use wasm to parse emails
## v0.8.3
- feat: |Github Action| Add auto update and deploy feature
- feat: |UI| Admin user settings, support oauth2 configuration deletion
- feat: Add spam detection must-pass list `JUNK_MAIL_FORCE_PASS_LIST` configuration
## v0.8.2
- fix: |Doc| Fix some documentation errors
- fix: |Github Action| Fix frontend deployment branch error issue
- feat: Admin send email feature
- feat: Admin backend, account configuration page adds unlimited send email address list
## v0.8.1
- feat: |Doc| Update UI installation documentation
- feat: |UI| Hide mailbox account ID from users
- feat: |UI| Add `Forward` button to email detail page
## v0.8.0
- feat: |UI| Random address generation doesn't exceed max length
- feat: |UI| Email time display in browser timezone, can switch to display UTC time in settings
- feat: Support transferring emails to other users
## v0.7.6
### Breaking Changes
UI deployment worker needs to click Settings -> Runtime, modify Compatibility flags, add `nodejs_compat`
![worker-runtime](vitepress-docs/docs/public/ui_install/worker-runtime.png)
### Changes
- feat: Support pre-setting bot info to reduce telegram callback latency (#441)
- feat: Add telegram mini app build archive
- feat: Add whether to enable spam check `ENABLE_CHECK_JUNK_MAIL` configuration
## v0.7.5
- fix: Fix `name` validation check
## v0.7.4
- feat: UI list page adds minimum width
- fix: Fix `name` validation check
- fix: Fix `DEFAULT_DOMAINS` configuration empty not taking effect issue
## v0.7.3
- feat: Worker adds `ADDRESS_CHECK_REGEX`, address name regex, only for checking, matching will pass check
- fix: UI fix login page tab active icon misalignment
- fix: UI fix admin page refresh popup password input issue
- feat: Support `OAuth2` login, can login via `Github` `Authentik` and other third parties, see details [OAuth2 Third-party Login](https://temp-mail-docs.awsl.uk/en/guide/feature/user-oauth2.html)
## v0.7.2
### Breaking Changes
`webhook` structure adds `enabled` field, existing configurations need to be re-enabled and saved on the page.
### Changes
- fix: Worker adds `NO_LIMIT_SEND_ROLE` configuration, loading failure issue
- feat: Worker adds `# ADDRESS_REGEX = "[^a-z.0-9]"` configuration, regex for replacing illegal symbols, if not set, defaults to [^a-z0-9], use with caution, some symbols may cause receiving issues
- feat: Worker optimizes webhook logic, supports admin configuring global webhook, adds `message pusher` integration example
## v0.7.1
- fix: Fix user role loading failure issue
- feat: Admin account settings adds source email address blacklist configuration
## v0.7.0
### Breaking Changes
DB changes: Add user `passkey` table, need to execute `db/2024-08-10-patch.sql` to update `D1` database
### Changes
- Docs: Update new-address-api.md (#360)
- feat: Worker adds `ADMIN_USER_ROLE` configuration, for configuring admin user role, users with this role can access admin management page (#363)
- feat: Worker adds `DISABLE_SHOW_GITHUB` configuration, for configuring whether to show github link
- feat: Worker adds `NO_LIMIT_SEND_ROLE` configuration, for configuring roles that can send unlimited emails
- feat: User adds `passkey` login method, for user login, no password required
- feat: Worker adds `DISABLE_ADMIN_PASSWORD_CHECK` configuration, for configuring whether to disable admin console password check, if your site is only privately accessible, you can disable the check
## v0.6.1
- pages github actions && fix cleanup emails days 0 not taking effect by @tqjason (#355)
- fix: imap proxy server doesn't support password by @dreamhunter2333 (#356)
- worker adds `ANNOUNCEMENT` configuration, for configuring announcement info by @dreamhunter2333 (#357)
- fix: telegram bot create new address defaults to first domain by @dreamhunter2333 (#358)
## v0.6.0
### Breaking Changes
DB changes: Add user role table, need to execute `db/2024-07-14-patch.sql` to update `D1` database
### Changes
Worker configuration file adds `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, see documentation [worker configuration](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
- Remove `apiV1` related code and related database tables
- Update `admin/statistics` api, add user statistics info
- Update address rules, only allow lowercase+numbers, for historical addresses `lowercase` processing will be performed when querying emails
- Add user role feature, `admin` can set user roles (currently can configure domain and prefix for each role)
- Admin page search optimization, enter key auto search, input content auto trim
## v0.5.4
- Click logo 5 times to enter admin page
- Fix 401 cannot redirect to login page (admin and site authentication)
## v0.5.3
- Fix some bugs in smtp imap proxy server
- Improve user/admin delete inbox/outbox functionality
- Admin can delete send permission records
- Add Chinese email alias configuration `DOMAIN_LABELS` [documentation](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
- Remove `mail channels` related code
- github actions adds `FRONTEND_BRANCH` variable to specify deployment branch (#324)
## v0.5.1
- Add `mail-parser-wasm-worker` for worker email parsing, [documentation](https://temp-mail-docs.awsl.uk/en/guide/feature/mail_parser_wasm_worker.html)
- Add user email length validation configuration `MIN_ADDRESS_LEN` and `MAX_ADDRESS_LEN`
- Fix `pages function` not forwarding `telegram` api issue
## v0.5.0
- UI: Add local cache for address management
- worker: Add `FORWARD_ADDRESS_LIST` global email forwarding address (equivalent to `catch all`)
- UI: Multi-language uses routing for switching
- Add save attachments to S3 feature
- UI: Add received email list `batch delete` and `batch download`
## v0.4.6
- Worker configuration file adds `TITLE = "Custom Title"`, can customize website title
- Fix KV not bound unable to delete address issue
## v0.4.5
- UI lazy load
- telegram bot adds user global push feature (admin users)
- Add support for cloudflare verified user sending emails
- Add using `resend` to send emails, `resend` provides http and smtp api, easier to use, documentation: https://temp-mail-docs.awsl.uk/en/guide/config-send-mail.html
## v0.4.4
- Add telegram mini app
- telegram bot adds `unbind`, `delete` commands
- Fix webhook multiline text issue
## v0.4.3
### Breaking Changes
Configuration file `main = "src/worker.js"` changed to `main = "src/worker.ts"`
### Changes
- `telegram bot` whitelist configuration
- `ENABLE_WEBHOOK` add webhook
- UI: admin page uses two-level tabs
- UI: can directly switch addresses on homepage after login
- UI: outbox also uses split view display (similar to inbox)
- `SMTP IMAP Proxy` add outbox viewing
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
## v0.4.2
- Fix some bugs in smtp imap proxy server
- Fix UI interface text errors, interface adds version number
- Add telegram bot documentation https://temp-mail-docs.awsl.uk/en/guide/feature/telegram.html
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
### v0.4.1
- Username limited to max 30 characters
- Fix `/external/api/send_mail` not returning bug (#222)
- Add `IMAP proxy` service, support `IMAP` viewing emails
- UI interface adds version number display
* feat: use common function handleListQuery when query by page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/220
* fix: typos by @lwd-temp in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
* fix: name max 30 && /external/api/send_mail not return result by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/222
* fix: smtp_proxy_server support decode from mail charset by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/223
* feat: add imap proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/225
* feat: UI show version by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/226
### New Contributors
* @lwd-temp made their first contribution in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
## v0.4.0
### DB Changes/Breaking changes
Added user related tables for storing user information
- `db/2024-05-08-patch.sql`
### config changes
Enable user registration email verification requires `KV`
```toml
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
```
### function changes
- Add user registration feature, can bind email addresses, automatically obtain email JWT credentials after binding
- Add default text display for emails, text and HTML email display mode switch button
- Fix `BUG` randomly generated email names are invalid #211
- `admin` email page supports email content search #210
- Fix bug where emails weren't deleted when deleting addresses #213
- UI adds global tab position configuration, side margin configuration
* feat: update docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/204
* feat: add Deploy to Cloudflare Workers button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/205
* feat: add Deploy to Cloudflare Workers docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/206
* feat: add UserLogin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/209
* feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/214
* feat: UI check openSettings in Login page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/215
* feat: UI move AdminContact to common by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/217
* feat: docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/218
## v0.3.3
- Fix Admin delete email error
- UI: Reply email button, quote original email text #186
- Add send email address blacklist
- Add `CF Turnstile` CAPTCHA configuration
- Add `/external/api/send_mail` send email api, use body verification #194
## v0.3.2
## What's Changed
- UI: Add reply email button
- Add scheduled cleanup feature, configurable in admin page (need to enable scheduled task in config file)
- Fix delete account no response issue
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
## v0.3.1
### DB Changes
Added `settings` table for storing general configuration information
- `db/2024-05-01-patch.sql`
### Changes
- `ENABLE_USER_CREATE_EMAIL` whether to allow users to create emails
- Allow admin to create emails without prefix
- Add `SMTP proxy server`, support SMTP sending emails
- Fix some cases where browsers can't load `wasm` use js to parse emails
- Footer adds `COPYRIGHT`
- UI allows users to switch email display mode `v-html` / `iframe`
- Add `admin` account configuration page, support configuring user registration name blacklist
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
## v0.3.0
### Breaking Changes
The prefix of the `address` table will migrate from code to db, please replace `tmp` in the sql below with your prefix, then execute.
If your data is important, please backup your database first.
**Note: Replace prefix**
```sql
update
address
set
name = 'tmp' || name;
```
### Changes
- Migrate the prefix of the `address` table from code to db
- `admin` account page adds send/receive email counts
- `admin` outbox page defaults to show all
- `admin` send permission page supports search by address
- `admin` email page uses split view UI
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
## v0.2.10
- `ENABLE_USER_DELETE_EMAIL` whether to allow users to delete account and emails
- `ENABLE_AUTO_REPLY` whether to enable auto reply
- fetchAddressError prompt improvement
- Auto refresh shows countdown
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
## v0.2.9
- Add rich text editor
- Admin contact info, won't show if not configured, can configure any string `ADMIN_CONTACT = "xx@xx.xxx"`
- Default send email balance, if not set, will be 0 `DEFAULT_SEND_BALANCE = 1`
## v0.2.8
- Allow users to delete emails
- Admin notifies user by email when modifying send permissions
- Send permission defaults to 1
- Add RATE_LIMITER rate limiting for sending emails and creating new addresses
- Some bug fixes
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
- feat: request_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
- fix: delete_address not delete address_sender by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/153
- fix: send_balance not update when click sendmail by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/155
## v0.2.7
- Added user interface installation documentation
- Support email DKIM
- Rate limiting configuration for `/api/new_address`
## v0.2.6
- Added admin query outbox page
- Add admin data cleaning page
## 2024-04-12 v0.2.5
- Support send email
DB changes:
- `db/2024-04-12-patch.sql`
## 2024-04-10 v0.2.0
### Breaking Changes
- remove `ENABLE_ATTACHMENT` config
- use rust wasm to parse email in frontend
- deprecated api moved to `/api/v1`
### Rust Mail Parser
Due to some problems with nodejs' email parsing library, this version switches to using rust wasm to call rust's mail parsing library.
- Faster speed, good attachment support, can display attachment images of emails
- Parsing supports more rfc specifications
### DB changes
The `mails` table will be discarded, and the `raw` text of the new `mail` will be directly stored in the `raw_mails` table
## Upgrade Step
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
pnpm run deploy
cd ../frontend
pnpm run deploy
```
Note: For historical messages, use the Deploy New web page to view old data.
```bash
git checkout feature/backup
cd frontend
# Create a new pages for accessing old data
pnpm run deploy --project-name temp-email-v1
```
## 2024-04-09 v0.0.0
release v0.0.0
## 2024-04-03
DB changes
- `db/2024-04-03-patch.sql`
Changes:
- add delete account
- add admin panel search
## 2024-01-13
DB changes
- `db/2024-01-13-patch.sql`
-103
View File
@@ -1,103 +0,0 @@
# 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.
+41 -69
View File
@@ -29,37 +29,36 @@
</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 邮件解析,响应速度极快
- 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便
## 部署文档 - 快速开始
## 📚 部署文档 - 快速开始
[部署文档](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 +68,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,44 +79,42 @@
</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] 支持 `SMTP``Resend` 等多种发送方式
- [x] 增加查看 `附件` 功能,支持附件图片显示
- [x] 支持 S3 附件存储和删除功能
- [x] 垃圾邮件检测和黑白名单配置
- [x] 邮件转发功能,支持全局转发地址
### 用户管理
### 👥 用户管理
- [x] 使用 `凭证` 重新登录之前的邮箱
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
@@ -126,7 +123,7 @@
- [x] 用户角色管理,支持多角色域名和前缀配置
- [x] 用户收件箱查看,支持地址和关键词过滤
### 管理功能
### 🔧 管理功能
- [x] 完整的 admin 控制台
- [x] `admin` 后台创建无前缀邮箱
@@ -135,7 +132,7 @@
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
- [x] 增加访问密码,可作为私人站点
### 多语言与界面
### 🌐 多语言与界面
- [x] 前后台均支持多语言
- [x] 现代化 UI 设计,支持响应式布局
@@ -143,46 +140,29 @@
- [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` skillOpenClaw / 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
# 方式 1degit 拷贝子目录
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
@@ -191,7 +171,7 @@ npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usag
- **存储**: Cloudflare KV + R2 (可选 S3)
- **代理服务**: Python SMTP/IMAP Proxy Server
### 主要组件
### 📦 主要组件
- **Worker**: 核心后端服务
- **Frontend**: Vue 3 用户界面
@@ -202,14 +182,6 @@ npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usag
</details>
### 提醒
- 在Resend添加域名记录时,如果您域名解析服务商正在托管您的3级域名a.b.com,请删除Resend生成的默认name中二级域名前缀b,否则将会添加a.b.b.com,导致验证失败。添加记录后,可通过
```bash
nslookup -qt="mx" a.b.com 1.1.1.1
```
进行验证。
## 加入社区
## 🌟 加入社区
- [Telegram](https://t.me/cloudflare_temp_email)
+33 -201
View File
@@ -1,214 +1,46 @@
<!-- markdownlint-disable-file MD033 MD045 -->
# Cloudflare Temp Email - Free Temporary Email Service
# Cloudflare Temp Email
<p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
<a href="README.md">🇨🇳 中文</a> |
<a href="README_EN.md">🇺🇸 English</a>
</p>
**A fully-featured temporary email service built on Cloudflare's free services.**
> This project is for learning and personal use only.
## 🚀 Quick Start
- [📖 Documentation](https://temp-mail-docs.awsl.uk/en/)
- [🎯 Live Demo](https://mail.awsl.uk/)
- [📝 CHANGELOG](CHANGELOG.md)
<p align="center">
<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">
</a>
</p>
<p align="center">
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="FeaturedHelloGitHub" height="30"/>
</a>
</p>
## ✨ Key Features
<p align="center">
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
</p>
- ** Email Processing**: Rust WASM parser, SMTP/IMAP support, attachments, auto-reply
- **👥 User Management**: OAuth2 login, Passkey authentication, role management
- **🌐 Admin Panel**: Complete admin console, user management, scheduled cleanup
- **🤖 Integrations**: Telegram Bot, webhooks, CAPTCHA, rate limiting
- ** Modern UI**: Multi-language, responsive design, JWT auto-login
> 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.
## 🏗️ Tech Stack
**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`)
## Deployment Documentation - Quick Start
[Documentation](https://temp-mail-docs.awsl.uk) | [Github Action Deployment Guide](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
</a>
## Changelog
See [CHANGELOG](CHANGELOG.md) for the latest updates.
## Live Demo
Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<details>
<summary>Service Status Monitoring (Click to expand/collapse)</summary>
| | |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Backend](https://temp-email-api.awsl.uk/) | [![Deploy Backend Production](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/10/status) ![](https://uptime.aks.awsl.icu/api/badge/10/uptime) ![](https://uptime.aks.awsl.icu/api/badge/10/ping) ![](https://uptime.aks.awsl.icu/api/badge/10/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/10/response) |
| [Frontend](https://mail.awsl.uk/) | [![Deploy Frontend](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/12/status) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime) ![](https://uptime.aks.awsl.icu/api/badge/12/ping) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/12/response) |
</details>
<details>
<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" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>
</details>
<details open>
<summary>Table of Contents (Click to expand/collapse)</summary>
- [Cloudflare Temp Email - Free Temporary Email Service](#cloudflare-temp-email---free-temporary-email-service)
- [Deployment Documentation - Quick Start](#deployment-documentation---quick-start)
- [Changelog](#changelog)
- [Live Demo](#live-demo)
- [Core Features](#core-features)
- [Email Processing](#email-processing)
- [User Management](#user-management)
- [Admin Features](#admin-features)
- [Multi-language \& Interface](#multi-language--interface)
- [Integration \& Extensions](#integration--extensions)
- [Technical Architecture](#technical-architecture)
- [System Architecture](#system-architecture)
- [Tech Stack](#tech-stack)
- [Main Components](#main-components)
- [Join the Community](#join-the-community)
</details>
## Core Features
<details open>
<summary>Core Features Details (Click to expand/collapse)</summary>
### Email Processing
- [x] Use `rust wasm` to parse emails, with fast parsing speed. Almost all emails can be parsed. Even emails that Node.js parsing modules fail to parse can be successfully parsed by rust wasm
- [x] **AI Email Recognition** - Use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
- [x] Support optional random second-level subdomain mailbox creation for selected base domains
- [x] Support sending emails with `DKIM` verification
- [x] Support multiple sending methods such as `SMTP` and `Resend`
- [x] Add attachment viewing feature with support for displaying attachment images
- [x] Support S3 attachment storage and deletion
- [x] Spam detection and blacklist/whitelist configuration
- [x] Email forwarding feature with global forwarding address support
### User Management
- [x] Use `credentials` to log in to previously used mailboxes
- [x] Add complete user registration and login functionality. Users can bind email addresses and automatically obtain email JWT credentials to switch between different mailboxes after binding
- [x] Support `OAuth2` third-party login (Github, Authentik, etc.)
- [x] Support `Passkey` passwordless login
- [x] User role management with support for multi-role domain and prefix configuration
- [x] User inbox viewing with address and keyword filtering support
### Admin Features
- [x] Complete admin console
- [x] Create mailboxes without prefix in `admin` backend
- [x] Admin user management page with user address viewing feature
- [x] Scheduled cleanup function with support for multiple cleanup strategies
- [x] Get mailboxes with custom names, `admin` can configure blacklist
- [x] Add access password for use as a private site
### Multi-language & Interface
- [x] Both frontend and backend support multi-language
- [x] Modern UI design with responsive layout
- [x] Google Ads integration support
- [x] Use shadow DOM to prevent style pollution
- [x] Support URL JWT parameter auto-login
### Integration & Extensions
- [x] Complete `Telegram Bot` support, `Telegram` push notifications, and Telegram Bot mini app
- [x] Add `SMTP proxy server` supporting `SMTP` for sending emails and `IMAP` for viewing emails
- [x] Webhook support and message push integration
- [x] Support `CF Turnstile` CAPTCHA verification
- [x] Rate limiting configuration to prevent abuse
- [x] **Agent-friendly**: 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>
## 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>
### System Architecture
- **Database**: Cloudflare D1 as the main database
- **Frontend Deployment**: Deploy frontend using Cloudflare Pages
- **Backend Deployment**: Deploy backend using Cloudflare Workers
- **Email Routing**: Use Cloudflare Email Routing
### Tech Stack
- **Frontend**: Vue 3 + Vite + TypeScript
- **Backend**: TypeScript + Cloudflare Workers
- **Email Parsing**: Rust WASM (mail-parser-wasm)
- **Database**: Cloudflare D1 (SQLite)
- **Frontend**: Vue 3 + TypeScript + Vite
- **Backend**: Cloudflare Workers + D1 Database
- **Email**: Cloudflare Email Routing + Rust WASM Parser
- **Storage**: Cloudflare KV + R2 (optional S3)
- **Proxy Service**: Python SMTP/IMAP Proxy Server
### Main Components
- **Worker**: Core backend service
- **Frontend**: Vue 3 user interface
- **Mail Parser WASM**: Rust email parsing module
- **SMTP Proxy Server**: Python email proxy service
- **Pages Functions**: Cloudflare Pages middleware
- **Documentation**: VitePress documentation site
</details>
### Important Notes
- When adding domain records in Resend, if your DNS provider is hosting your 3rd level domain a.b.com, please remove the 2nd level domain prefix b from the default name generated by Resend, otherwise it will add a.b.b.com, causing verification to fail. After adding the record, you can verify it using:
```bash
nslookup -qt="mx" a.b.com 1.1.1.1
```
## Join the Community
## 🌟 Community
- [Telegram](https://t.me/cloudflare_temp_email)
## 📄 License
MIT License - see [LICENSE](LICENSE) for details.
-4
View File
@@ -1,4 +0,0 @@
ALTER TABLE
address
ADD
password TEXT;
-4
View File
@@ -1,4 +0,0 @@
-- Add metadata column to raw_mails table for storing AI extraction results and other metadata
-- This column stores JSON data with flexible schema for various analysis results
ALTER TABLE raw_mails ADD COLUMN metadata TEXT;
-4
View File
@@ -1,4 +0,0 @@
-- Add index on message_id column in raw_mails table
-- This index improves performance for queries filtering/updating by message_id
-- Example: UPDATE raw_mails SET metadata = ? WHERE message_id = ?
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
-8
View File
@@ -1,8 +0,0 @@
-- Add source_meta column to address table for tracking address creation source
-- For web: stores IP address (e.g., "192.168.1.1") or "web:unknown" as fallback
-- For telegram: stores "tg:{userId}" (e.g., "tg:123456789")
-- For admin: stores "admin"
ALTER TABLE address ADD COLUMN source_meta TEXT;
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
-2
View File
@@ -1,2 +0,0 @@
-- Add raw_blob BLOB column for gzip-compressed email storage
ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;
-16
View File
@@ -4,34 +4,20 @@ CREATE TABLE IF NOT EXISTS raw_mails (
source TEXT,
address TEXT,
raw TEXT,
raw_blob BLOB,
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
password TEXT,
source_meta TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -64,8 +50,6 @@ CREATE TABLE IF NOT EXISTS sendbox (
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
-13
View File
@@ -1,13 +0,0 @@
# 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"]
-47
View File
@@ -1,47 +0,0 @@
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"]
-19
View File
@@ -1,19 +0,0 @@
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"]
-57
View File
@@ -1,57 +0,0 @@
# E2E Tests
End-to-end tests for Cloudflare Temp Email using [Playwright](https://playwright.dev/) and [Mailpit](https://mailpit.axllent.org/), fully containerized with Docker Compose.
## Prerequisites
- **Docker** and **Docker Compose**
## Quick Start
```bash
cd e2e
# Build, start all services, run tests, and exit
npm test
# Clean up containers and volumes
npm run test:down
```
`npm test` runs `docker compose up --build`, which:
1. Starts **Mailpit** (SMTP on :1025, HTTP API on :8025)
2. Builds and starts the **Worker** (wrangler dev on :8787)
3. Builds and starts the **Frontend** (vite dev on :5173)
4. Builds and runs the **E2E runner** (Playwright), which waits for services, initializes the DB, and runs all tests
The exit code reflects the test result.
## Test Structure
| Project | Directory | What it tests |
|---------|-----------|---------------|
| `api` | `tests/api/` | Worker API endpoints — health check, address CRUD, send mail via SMTP |
| `browser` | `tests/browser/` | Frontend UI — login, inbox view, reply with HTML, XSS sanitization |
## Services
| Service | Container | Port | Purpose |
|---------|-----------|------|---------|
| Mailpit SMTP | `mailpit` | 1025 | Captures outgoing emails |
| Mailpit HTTP | `mailpit` | 8025 | API to verify captured emails |
| Worker | `worker` | 8787 | Backend API with E2E config |
| Frontend | `frontend` | 5173 | Vue frontend dev server |
## Test Results
Test results and HTML reports are exported via volumes:
- `e2e/test-results/` — test artifacts
- `e2e/playwright-report/` — HTML report
## Configuration
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
- `E2E_TEST_MODE = true` — enables test seed endpoint
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
- SMTP pointed at Mailpit container (`mailpit:1025`)
-178
View File
@@ -1,178 +0,0 @@
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
-271
View File
@@ -1,271 +0,0 @@
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()}`);
}
}
-33
View File
@@ -1,33 +0,0 @@
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"
-34
View File
@@ -1,34 +0,0 @@
name = "cloudflare_temp_email_env_off"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
[vars]
PREFIX = "tmp"
DEFAULT_DOMAINS = ["test.example.com"]
DOMAINS = ["test.example.com"]
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false
JWT_SECRET = "e2e-test-secret-key-env-off"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = false
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
[[kv_namespaces]]
binding = "KV"
id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
[[d1_databases]]
binding = "DB"
database_name = "e2e-temp-email-env-off"
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
-34
View File
@@ -1,34 +0,0 @@
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"
@@ -1,38 +0,0 @@
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"
-422
View File
@@ -1,422 +0,0 @@
{
"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
}
}
}
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"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"
}
}
-45
View File
@@ -1,45 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
const WORKER_BASE = process.env.WORKER_URL!;
const WORKER_GZIP_BASE = process.env.WORKER_GZIP_URL || '';
const FRONTEND_BASE = process.env.FRONTEND_URL!;
export default defineConfig({
timeout: 30_000,
retries: 0,
workers: 1,
reporter: [['html', { open: 'never' }]],
projects: [
{
name: 'api',
testDir: './tests/api',
use: {
baseURL: WORKER_BASE,
},
},
{
name: 'api-gzip',
testDir: './tests/api-gzip',
use: {
baseURL: WORKER_GZIP_BASE,
},
},
{
name: 'smtp-proxy',
testDir: './tests/smtp-proxy',
use: {
baseURL: WORKER_BASE,
},
},
{
name: 'browser',
testDir: './tests/browser',
use: {
baseURL: FRONTEND_BASE,
...devices['Desktop Chrome'],
// Accept self-signed cert from Docker frontend (HTTPS for WebAuthn)
ignoreHTTPSErrors: true,
},
},
],
});
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "==> Waiting for worker at $WORKER_URL ..."
for i in $(seq 1 60); do
if curl -sf "$WORKER_URL/health_check" > /dev/null 2>&1; then
echo " Worker ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Worker not ready after 60s"
exit 1
fi
sleep 1
done
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
echo "==> Waiting for subdomain worker at $WORKER_URL_SUBDOMAIN ..."
for i in $(seq 1 60); do
if curl -sf "$WORKER_URL_SUBDOMAIN/health_check" > /dev/null 2>&1; then
echo " Subdomain worker ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Subdomain worker not ready after 60s"
exit 1
fi
sleep 1
done
fi
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
echo "==> Waiting for env-off worker at $WORKER_URL_ENV_OFF ..."
for i in $(seq 1 60); do
if curl -sf "$WORKER_URL_ENV_OFF/health_check" > /dev/null 2>&1; then
echo " Env-off worker ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Env-off worker not ready after 60s"
exit 1
fi
sleep 1
done
fi
if [ -n "${WORKER_GZIP_URL:-}" ]; then
echo "==> Waiting for worker-gzip at $WORKER_GZIP_URL ..."
for i in $(seq 1 60); do
if curl -sf "$WORKER_GZIP_URL/health_check" > /dev/null 2>&1; then
echo " Worker-gzip ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Worker-gzip not ready after 60s"
exit 1
fi
sleep 1
done
fi
echo "==> Waiting for frontend at $FRONTEND_URL ..."
for i in $(seq 1 60); do
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
echo " Frontend ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Frontend not ready after 60s"
exit 1
fi
sleep 1
done
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
for i in $(seq 1 30); do
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
echo " smtp-proxy-tls SMTP ready after ${i}s"
break
fi
if [ "$i" -eq 30 ]; then
echo "WARNING: smtp-proxy-tls SMTP not ready after 30s, continuing anyway"
fi
sleep 1
done
echo "==> Initializing database"
curl -sf -X POST "$WORKER_URL/admin/db_initialize" > /dev/null
curl -sf -X POST "$WORKER_URL/admin/db_migration" > /dev/null
echo " Database initialized"
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
echo "==> Initializing subdomain worker database"
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_initialize" > /dev/null
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_migration" > /dev/null
echo " Subdomain worker database initialized"
fi
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
echo "==> Initializing env-off worker database"
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_initialize" > /dev/null
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_migration" > /dev/null
echo " Env-off database initialized"
fi
if [ -n "${WORKER_GZIP_URL:-}" ]; then
echo "==> Initializing gzip worker database"
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_initialize" > /dev/null
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_migration" > /dev/null
echo " Gzip worker database initialized"
fi
echo "==> Running Playwright tests"
exec npx playwright test "$@"
-15
View File
@@ -1,15 +0,0 @@
#!/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
-242
View File
@@ -1,242 +0,0 @@
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);
}
});
});
-29
View File
@@ -1,29 +0,0 @@
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);
});
});
-59
View File
@@ -1,59 +0,0 @@
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);
}
});
});
-48
View File
@@ -1,48 +0,0 @@
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);
});
});
-19
View File
@@ -1,19 +0,0 @@
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');
});
});
-185
View File
@@ -1,185 +0,0 @@
import { test, expect } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Auto Reply Trigger (#459)', () => {
/**
* Bug #459: source_prefix empty string causes auto-reply to never trigger.
* The old condition `results.source_prefix && ...` short-circuits when
* source_prefix is "" (falsy). Fix: empty source_prefix should match all.
*/
test('empty source_prefix triggers auto-reply for any sender', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'auto-reply-trigger');
try {
// Configure auto-reply with empty source_prefix (match all senders)
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Auto Bot',
subject: 'Auto Reply',
source_prefix: '',
message: 'Thanks for your email!',
enabled: true,
},
},
});
expect(saveRes.ok()).toBe(true);
// Send a mail to the address — should trigger auto-reply
const receiveRes = await seedTestMailWithReply(request, address, {
from: 'anyone@other.com',
subject: 'Hello',
text: 'Test message',
});
expect(receiveRes.success).toBe(true);
expect(receiveRes.replyCalled).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('source_prefix startsWith still works (backward compat)', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'auto-reply-prefix');
try {
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Prefix Bot',
subject: 'Prefix Reply',
source_prefix: 'vip@',
message: 'VIP auto-reply',
enabled: true,
},
},
});
expect(saveRes.ok()).toBe(true);
// Matching sender — should trigger
const matchRes = await seedTestMailWithReply(request, address, {
from: 'vip@example.com',
subject: 'VIP mail',
});
expect(matchRes.replyCalled).toBe(true);
// Non-matching sender — should NOT trigger
const noMatchRes = await seedTestMailWithReply(request, address, {
from: 'random@example.com',
subject: 'Random mail',
});
expect(noMatchRes.replyCalled).toBe(false);
} finally {
await deleteAddress(request, jwt);
}
});
test('source_prefix regex match', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'auto-reply-regex');
try {
// Configure regex: match senders from example.com or example.org
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Regex Bot',
subject: 'Regex Reply',
source_prefix: '/@example\\.(com|org)$/',
message: 'Regex auto-reply',
enabled: true,
},
},
});
expect(saveRes.ok()).toBe(true);
// Matching sender
const matchRes = await seedTestMailWithReply(request, address, {
from: 'user@example.com',
subject: 'Match test',
});
expect(matchRes.replyCalled).toBe(true);
// Another matching sender
const matchRes2 = await seedTestMailWithReply(request, address, {
from: 'user@example.org',
subject: 'Match test 2',
});
expect(matchRes2.replyCalled).toBe(true);
// Non-matching sender
const noMatchRes = await seedTestMailWithReply(request, address, {
from: 'user@other.com',
subject: 'No match test',
});
expect(noMatchRes.replyCalled).toBe(false);
} finally {
await deleteAddress(request, jwt);
}
});
test('disabled auto-reply does not trigger', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'auto-reply-disabled');
try {
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Disabled Bot',
subject: 'Should not reply',
source_prefix: '',
message: 'This should never be sent',
enabled: false,
},
},
});
expect(saveRes.ok()).toBe(true);
const receiveRes = await seedTestMailWithReply(request, address, {
from: 'anyone@other.com',
subject: 'Test disabled',
});
expect(receiveRes.success).toBe(true);
expect(receiveRes.replyCalled).toBe(false);
} finally {
await deleteAddress(request, jwt);
}
});
});
/**
* Send a mail via receive_mail endpoint and return the response
* including replyCalled field.
*/
async function seedTestMailWithReply(
ctx: APIRequestContext,
address: string,
opts: { from?: string; subject?: string; text?: string }
): Promise<{ success: boolean; replyCalled: boolean }> {
const from = opts.from || 'sender@test.example.com';
const subject = opts.subject || 'Test Email';
const text = opts.text || 'Hello from E2E';
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
const raw = [
`From: ${from}`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=utf-8`,
``,
text,
].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) {
throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
}
return await res.json();
}
-73
View File
@@ -1,73 +0,0 @@
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);
}
});
});
-69
View File
@@ -1,69 +0,0 @@
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);
}
});
});
-22
View File
@@ -1,22 +0,0 @@
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);
});
});
-274
View File
@@ -1,274 +0,0 @@
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,
},
});
});
});
-132
View File
@@ -1,132 +0,0 @@
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);
}
});
});
});
-181
View File
@@ -1,181 +0,0 @@
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);
}
});
});
-55
View File
@@ -1,55 +0,0 @@
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);
}
});
});
-162
View File
@@ -1,162 +0,0 @@
import { test, expect } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { WORKER_URL } from '../../fixtures/test-helpers';
const TEST_USER_EMAIL = `passkey-e2e-${Date.now()}@test.example.com`;
const TEST_USER_PASSWORD = 'test-password-123';
/**
* Enable user registration via admin API, register a user, and login to get JWT.
*/
async function createTestUser(request: APIRequestContext): Promise<string> {
// Enable user registration (KV setting)
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
enable: true,
enableMailVerify: false,
},
});
expect(enableRes.ok()).toBe(true);
// Register user
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
});
expect(registerRes.ok()).toBe(true);
// Login to get JWT
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
});
expect(loginRes.ok()).toBe(true);
const { jwt } = await loginRes.json();
expect(jwt).toBeTruthy();
return jwt;
}
test.describe('Passkey API', () => {
let userJwt: string;
test.beforeAll(async ({ request }) => {
userJwt = await createTestUser(request);
});
test('register_request returns valid WebAuthn options', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_request`, {
headers: { 'x-user-token': userJwt },
data: { domain: 'localhost' },
});
expect(res.ok()).toBe(true);
const options = await res.json();
// Verify WebAuthn registration options structure
expect(options.rp).toBeDefined();
expect(options.rp.id).toBe('localhost');
expect(options.user).toBeDefined();
expect(options.user.name).toBe(TEST_USER_EMAIL);
expect(options.challenge).toBeTruthy();
expect(options.pubKeyCredParams).toBeInstanceOf(Array);
expect(options.pubKeyCredParams.length).toBeGreaterThan(0);
});
test('authenticate_request returns valid WebAuthn options', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_request`, {
data: { domain: 'localhost' },
});
expect(res.ok()).toBe(true);
const options = await res.json();
// Verify WebAuthn authentication options structure
expect(options.challenge).toBeTruthy();
expect(options.rpId).toBe('localhost');
expect(options.allowCredentials).toBeInstanceOf(Array);
});
test('authenticate_response with invalid credential returns error', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_response`, {
data: {
domain: 'localhost',
origin: 'http://localhost',
credential: { id: 'nonexistent-passkey-id' },
},
});
expect(res.ok()).toBe(false);
expect(res.status()).toBe(404);
});
test('passkey list is empty for new user', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/user_api/passkey`, {
headers: { 'x-user-token': userJwt },
});
expect(res.ok()).toBe(true);
const passkeys = await res.json();
expect(passkeys).toBeInstanceOf(Array);
expect(passkeys.length).toBe(0);
});
test('passkey list remains empty without registration', async ({ request }) => {
const listRes = await request.get(`${WORKER_URL}/user_api/passkey`, {
headers: { 'x-user-token': userJwt },
});
expect(listRes.ok()).toBe(true);
const passkeys = await listRes.json();
expect(passkeys).toBeInstanceOf(Array);
expect(passkeys.length).toBe(0);
});
test('register_response with invalid credential returns 400', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_response`, {
headers: { 'x-user-token': userJwt },
data: {
credential: {
id: 'fake-id',
rawId: 'fake-raw-id',
type: 'public-key',
response: {
attestationObject: 'invalid-data',
clientDataJSON: 'invalid-data',
},
},
origin: 'http://localhost',
passkey_name: 'test-passkey',
},
});
expect(res.ok()).toBe(false);
// Should fail verification
expect(res.status()).toBeGreaterThanOrEqual(400);
});
test('rename nonexistent passkey succeeds silently', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
headers: { 'x-user-token': userJwt },
data: {
passkey_id: 'nonexistent-id',
passkey_name: 'new-name',
},
});
// The SQL UPDATE just affects 0 rows, still returns success
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
});
test('rename with invalid name returns 400', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
headers: { 'x-user-token': userJwt },
data: {
passkey_id: 'any-id',
passkey_name: 'x'.repeat(256),
},
});
expect(res.status()).toBe(400);
});
test('delete nonexistent passkey succeeds silently', async ({ request }) => {
const res = await request.delete(`${WORKER_URL}/user_api/passkey/nonexistent-id`, {
headers: { 'x-user-token': userJwt },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
});
});
-145
View File
@@ -1,145 +0,0 @@
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);
}
});
});
-517
View File
@@ -1,517 +0,0 @@
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);
});
});
-54
View File
@@ -1,54 +0,0 @@
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);
});
});
-198
View File
@@ -1,198 +0,0 @@
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');
});
});
-88
View File
@@ -1,88 +0,0 @@
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);
}
});
});
-161
View File
@@ -1,161 +0,0 @@
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();
}
});
});
-49
View File
@@ -1,49 +0,0 @@
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();
}
}
});
});
-114
View File
@@ -1,114 +0,0 @@
import { test, expect } from '@playwright/test';
import { request as apiRequest } from '@playwright/test';
import { createHash } from 'crypto';
import { WORKER_URL, FRONTEND_URL } from '../../fixtures/test-helpers';
const TEST_USER_EMAIL = `passkey-browser-${Date.now()}@test.example.com`;
const TEST_USER_PASSWORD = 'browser-test-pwd-123';
// Frontend hashes passwords with SHA-256 before sending to the API.
// Register with the hashed password so UI login matches.
const HASHED_PASSWORD = createHash('sha256').update(TEST_USER_PASSWORD).digest('hex');
test.describe('Passkey Browser Flow', () => {
let userJwt: string;
test.beforeAll(async () => {
const api = await apiRequest.newContext();
try {
// Enable user registration
await api.post(`${WORKER_URL}/admin/user_settings`, {
data: { enable: true, enableMailVerify: false },
});
// Register user with hashed password (matching frontend behavior)
await api.post(`${WORKER_URL}/user_api/register`, {
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
});
// Login to get JWT for localStorage injection
const loginRes = await api.post(`${WORKER_URL}/user_api/login`, {
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
});
const body = await loginRes.json();
userJwt = body.jwt;
} finally {
await api.dispose();
}
});
test('register passkey, then login with passkey', async ({ page, context }) => {
// Set up virtual authenticator via CDP
const cdp = await context.newCDPSession(page);
await cdp.send('WebAuthn.enable');
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
try {
// === Step 1: Login via localStorage injection ===
// Inject JWT into localStorage to skip UI login flow.
await page.goto(`${FRONTEND_URL}/en/`);
// VueUse's useStorage with string default stores raw strings (no JSON)
await page.evaluate((jwt) => {
localStorage.setItem('userJwt', jwt);
}, userJwt);
await page.goto(`${FRONTEND_URL}/en/user`);
// Wait for user settings to load (shows user email)
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
// === Step 2: Click "User Settings" tab ===
await page.getByText('User Settings').click();
// === Step 3: Create a passkey ===
await page.getByRole('button', { name: 'Create Passkey' }).click();
// Fill passkey name in the modal
const createModal = page.locator('.n-dialog');
await expect(createModal).toBeVisible({ timeout: 5_000 });
await createModal.getByRole('textbox').fill('E2E Test Passkey');
// Click the Create Passkey button inside the modal
await createModal.getByRole('button', { name: 'Create Passkey' }).click();
// Wait for success — modal should close
await expect(createModal).not.toBeVisible({ timeout: 10_000 });
// === Step 4: Verify passkey appears in the list ===
await page.getByRole('button', { name: 'Show Passkey List' }).click();
const listModal = page.locator('.n-card-header:has-text("Show Passkey List")').locator('..');
await expect(page.getByText('E2E Test Passkey')).toBeVisible({ timeout: 5_000 });
// Close the list modal
await page.keyboard.press('Escape');
// === Step 5: Logout ===
await page.getByRole('button', { name: 'Logout' }).click();
const logoutModal = page.locator('.n-dialog');
await expect(logoutModal).toBeVisible({ timeout: 5_000 });
await logoutModal.getByRole('button', { name: 'Logout' }).click();
// Wait for logout to complete and navigate to user page
await page.waitForTimeout(2000);
await page.goto(`${FRONTEND_URL}/en/user`);
// === Step 6: Login with passkey ===
const passkeyBtn = page.getByRole('button', { name: 'Login with Passkey' });
await expect(passkeyBtn).toBeVisible({ timeout: 10_000 });
await passkeyBtn.click();
// Virtual authenticator handles the WebAuthn ceremony automatically
// Wait for login to complete — user email should appear
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
} finally {
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
await cdp.detach();
}
});
});
-105
View File
@@ -1,105 +0,0 @@
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();
}
}
});
});
-110
View File
@@ -1,110 +0,0 @@
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();
}
}
});
});
-274
View File
@@ -1,274 +0,0 @@
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();
});
});
-81
View File
@@ -1,81 +0,0 @@
import { test, expect } from '@playwright/test';
import { ImapFlow } from 'imapflow';
import { createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
const IMAP_TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls';
const IMAP_TLS_PORT = parseInt(process.env.SMTP_PROXY_TLS_IMAP_PORT || '11144', 10);
function createClient(user: string, pass: string) {
return new ImapFlow({
host: IMAP_TLS_HOST,
port: IMAP_TLS_PORT,
secure: false,
auth: { user, pass },
logger: false,
tls: { rejectUnauthorized: false },
});
}
test.describe('IMAP Proxy — STARTTLS', () => {
let jwt: string;
let address: string;
test.beforeAll(async ({ request }) => {
({ jwt, address } = await createTestAddress(request, 'imap-tls'));
await seedTestMail(request, address, { subject: 'IMAP TLS Test 1', text: 'First TLS test email' });
await seedTestMail(request, address, { subject: 'IMAP TLS Test 2', text: 'Second TLS test email' });
});
test.afterAll(async ({ request }) => {
await deleteAddress(request, jwt);
});
test('login with JWT over STARTTLS', async () => {
const client = createClient(address, jwt);
await client.connect();
expect(client.usable).toBe(true);
await client.logout();
});
test('login with wrong password fails over STARTTLS', async () => {
const client = createClient(address, 'wrong-password');
await expect(client.connect()).rejects.toThrow();
});
test('LIST returns INBOX over STARTTLS', async () => {
const client = createClient(address, jwt);
await client.connect();
const mailboxes = await client.list();
const names = mailboxes.map(m => m.path);
expect(names).toContain('INBOX');
await client.logout();
});
test('SELECT INBOX returns messages over STARTTLS', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
expect(client.mailbox).toBeTruthy();
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2);
} finally {
lock.release();
}
await client.logout();
});
test('FETCH source over STARTTLS contains valid MIME', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const msg = await client.fetchOne('1', { source: true });
const source = msg.source.toString('utf-8');
expect(source).toContain('Content-Type:');
expect(source).toContain('Subject:');
} finally {
lock.release();
}
await client.logout();
});
});
-112
View File
@@ -1,112 +0,0 @@
import { test, expect } from '@playwright/test';
import nodemailer from 'nodemailer';
import {
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
requestSendAccess,
onMailpitMessage,
WORKER_URL,
} from '../../fixtures/test-helpers';
const SMTP_HOST = process.env.SMTP_PROXY_HOST || 'smtp-proxy';
const SMTP_PORT = parseInt(process.env.SMTP_PROXY_SMTP_PORT || '8025', 10);
function createTransport(user: string, pass: string) {
return nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: false,
auth: { user, pass },
tls: { rejectUnauthorized: false },
});
}
test.describe('SMTP Proxy', () => {
let jwt: string;
let address: string;
test.beforeAll(async ({ request }) => {
await deleteAllMailpitMessages(request);
({ jwt, address } = await createTestAddress(request, 'smtp-e2e'));
await requestSendAccess(request, jwt);
});
test.afterAll(async ({ request }) => {
await deleteAddress(request, jwt);
});
test('send plain text email via SMTP', async ({ request }) => {
const subject = `SMTP Plain ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createTransport(address, jwt);
const info = await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
text: 'Hello from SMTP E2E test',
});
expect(info.accepted).toContain('recipient@test.example.com');
const delivered = await listener.message;
expect(delivered.Subject).toBe(subject);
});
test('send HTML email via SMTP', async ({ request }) => {
const subject = `SMTP HTML ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createTransport(address, jwt);
const info = await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
html: '<h1>Hello</h1><p>HTML E2E test</p>',
});
expect(info.accepted).toContain('recipient@test.example.com');
const delivered = await listener.message;
expect(delivered.Subject).toBe(subject);
});
test('auth with wrong password fails', async () => {
const transport = createTransport(address, 'wrong-password');
await expect(
transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject: 'Should fail',
text: 'This should not be sent',
})
).rejects.toThrow();
});
test('sent mail appears in sendbox API', async ({ request }) => {
const subject = `SMTP Sendbox ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createTransport(address, jwt);
await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
text: 'Check sendbox',
});
await listener.message;
const res = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.ok()).toBe(true);
const { results } = await res.json();
const found = results.some((r: any) => {
const raw = JSON.parse(r.raw);
return raw.subject === subject;
});
expect(found).toBe(true);
});
});
-114
View File
@@ -1,114 +0,0 @@
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();
});
});
+22 -28
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.8.0",
"version": "1.0.5",
"private": true,
"type": "module",
"scripts": {
@@ -10,50 +10,44 @@
"build:pages": "vite build -m pages --emptyOutDir",
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
"build:telegram:pages": "VITE_IS_TELEGRAM=true vite build -m pages --emptyOutDir",
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
"preview": "vite preview",
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
"deploy:actions": "npm run build && wrangler pages deploy ./dist",
"test": "vitest run",
"test:watch": "vitest"
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@simplewebauthn/browser": "13.2.2",
"@unhead/vue": "^2.1.13",
"@vueuse/core": "^14.2.1",
"@simplewebauthn/browser": "10.0.0",
"@unhead/vue": "^1.11.20",
"@vueuse/core": "^12.8.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.15.1",
"dompurify": "^3.4.0",
"axios": "^1.11.0",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.4",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.42.0",
"postal-mime": "^2.4.4",
"vooks": "^0.2.12",
"vue": "^3.5.32",
"vue": "^3.5.20",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.3.2",
"vue-router": "^4.6.4"
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@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.2",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-wasm": "^3.6.0",
"vitest": "^3.2.4",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0",
"wrangler": "^4.83.0"
"@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0",
"wrangler": "^4.33.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+2138 -2564
View File
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -22,16 +22,8 @@ const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
const showAd = computed(() => !isMobile.value && adClient && adSlot);
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
// Load Google Ad script at top level (not inside onMounted)
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
})
}
onMounted(async () => {
try {
await api.getUserSettings();
} catch (error) {
@@ -51,6 +43,11 @@ onMounted(async () => {
// check if google ad is enabled
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
});
(window.adsbygoogle = window.adsbygoogle || []).push({});
(window.adsbygoogle = window.adsbygoogle || []).push({});
}
+1 -10
View File
@@ -3,7 +3,6 @@ import { h } from 'vue'
import axios from 'axios'
import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
@@ -21,9 +20,6 @@ const instance = axios.create({
const apiFetch = async (path, options = {}) => {
loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
const response = await instance.request(path, {
method: options.method || 'GET',
data: options.body || null,
@@ -33,7 +29,6 @@ const apiFetch = async (path, options = {}) => {
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'x-fingerprint': fingerprint,
'Authorization': `Bearer ${jwt.value}`,
'Content-Type': 'application/json',
},
@@ -41,7 +36,7 @@ const apiFetch = async (path, options = {}) => {
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
}
if (response.status === 401 && openSettings.value.needAuth) {
if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true;
}
if (response.status >= 300) {
@@ -74,7 +69,6 @@ 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,9 +86,6 @@ const getOpenSettings = async (message, notification) => {
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
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;
-260
View File
@@ -1,260 +0,0 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import useClipboard from 'vue-clipboard3'
import { Copy } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
const props = defineProps({
showCopy: {
type: Boolean,
default: true,
},
size: {
type: String,
default: 'small',
},
})
const message = useMessage()
const { toClipboard } = useClipboard()
const {
jwt, settings, userJwt, isTelegram, openSettings, telegramApp
} = useGlobalState()
const { t } = useI18n({
messages: {
en: {
userAddresses: 'User Addresses',
localAddresses: 'Local Addresses',
address: 'Address',
copy: 'Copy',
copied: 'Copied',
},
zh: {
userAddresses: '用户地址',
localAddresses: '本地地址',
address: '地址',
copy: '复制',
copied: '已复制',
}
}
});
const addressOptions = ref([])
const addressValue = ref(null)
const addressLoading = ref(false)
const localAddressCache = useLocalStorage("LocalAddressCache", [])
const optionValueMap = new Map()
const formatAddressLabel = (address) => {
if (!address) return address;
const domain = address.split('@')[1]
const domainLabel = openSettings.value.domains.find(
d => d.value === domain
)?.label;
if (!domainLabel) return address;
return address.replace('@' + domain, `@${domainLabel}`);
}
const parseJwtAddress = (curJwt) => {
try {
const payload = JSON.parse(
decodeURIComponent(
atob(curJwt.split(".")[1]
.replace(/-/g, "+").replace(/_/g, "/")
)
)
);
return payload.address;
} catch (e) {
return null;
}
}
const getOptionValue = (key, scope, payload, address) => {
if (optionValueMap.has(key)) {
const cached = optionValueMap.get(key)
cached.scope = scope
cached.payload = payload
cached.address = address
return cached
}
const value = { key, scope, payload, address }
optionValueMap.set(key, value)
return value
}
const buildLocalOptions = (excludeAddresses = new Set()) => {
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
localAddressCache.value.push(jwt.value)
}
const children = localAddressCache.value
.map((curJwt) => {
const address = parseJwtAddress(curJwt);
if (!address) return null;
if (excludeAddresses.has(address)) return null;
const label = formatAddressLabel(address);
const key = `local:${curJwt}`;
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
if (settings.value.address && address === settings.value.address) {
addressValue.value = option.value;
}
return option;
})
.filter(Boolean);
return children;
}
const buildUserOptions = async () => {
const children = [];
try {
const { results } = await api.fetch(`/user_api/bind_address`);
for (const row of results || []) {
const address = row.address || row.name;
if (!address) continue;
const label = formatAddressLabel(address);
const key = `user:${row.id}`;
const option = { label, value: getOptionValue(key, 'user', String(row.id), address), address };
if (settings.value.address && address === settings.value.address) {
addressValue.value = option.value;
}
children.push(option);
}
} catch (error) {
message.error(error.message || "error");
}
return children;
}
const buildTelegramOptions = async () => {
const children = [];
try {
const data = await api.fetch(`/telegram/get_bind_address`, {
method: 'POST',
body: JSON.stringify({
initData: telegramApp.value.initData
})
});
for (const row of data || []) {
if (!row?.address || !row?.jwt) continue;
const label = formatAddressLabel(row.address);
const key = `tg:${row.jwt}`;
const option = { label, value: getOptionValue(key, 'tg', row.jwt, row.address), address: row.address };
if (settings.value.address && row.address === settings.value.address) {
addressValue.value = option.value;
}
children.push(option);
}
} catch (error) {
message.error(error.message || "error");
}
return children;
}
const refreshAddressOptions = async () => {
addressLoading.value = true;
addressValue.value = null;
try {
if (isTelegram.value) {
const telegramChildren = await buildTelegramOptions();
addressOptions.value = telegramChildren;
return;
}
const groups = [];
if (userJwt.value) {
const userChildren = await buildUserOptions();
if (userChildren.length > 0) {
groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
}
const userAddressSet = new Set(userChildren.map((item) => item.address));
const localChildren = buildLocalOptions(userAddressSet);
if (localChildren.length > 0) {
groups.push({ type: 'group', label: t('localAddresses'), children: localChildren });
}
} else {
const localChildren = buildLocalOptions();
if (localChildren.length > 0) {
groups.push({ type: 'group', label: t('localAddresses'), children: localChildren });
}
}
addressOptions.value = groups;
} finally {
addressLoading.value = false;
}
}
const onAddressChange = async (value) => {
if (!value) return;
if (value.scope === 'local' || value.scope === 'tg') {
jwt.value = value.payload;
location.reload();
return;
}
if (value.scope === 'user') {
try {
const res = await api.fetch(`/user_api/bind_address_jwt/${value.payload}`);
if (!res?.jwt) {
message.error("jwt not found");
return;
}
jwt.value = res.jwt;
location.reload();
} catch (error) {
message.error(error.message || "error");
}
}
}
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
onMounted(async () => {
await refreshAddressOptions();
});
watch([userJwt, isTelegram, () => settings.value.address], async () => {
await refreshAddressOptions();
});
</script>
<template>
<n-flex class="address-row" align="center" justify="center" :wrap="true">
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
class="address-select" />
<slot name="actions" />
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</n-flex>
</template>
<style scoped>
.address-row {
width: 100%;
gap: 10px;
}
.address-select {
min-width: 220px;
max-width: 420px;
flex: 1 1 220px;
}
.address-copy {
flex: 0 0 auto;
white-space: nowrap;
}
</style>
-176
View File
@@ -1,176 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ContentCopyOutlined, LinkRound, CodeRound } from '@vicons/material';
import { useMessage } from 'naive-ui';
import { useGlobalState } from '../store';
const message = useMessage();
const { isDark } = useGlobalState();
// Dark mode: use Gmail's softer blue (#A8C7FA) for better readability
const alertThemeOverrides = computed(() => {
if (isDark.value) {
return {
colorSuccess: 'rgba(168, 199, 250, 0.15)',
borderSuccess: '1px solid rgba(168, 199, 250, 0.3)',
iconColorSuccess: '#A8C7FA',
titleTextColorSuccess: '#A8C7FA',
}
}
return {}
});
const tagThemeOverrides = computed(() => {
if (isDark.value) {
return {
colorSuccess: 'rgba(168, 199, 250, 0.15)',
borderSuccess: '1px solid rgba(168, 199, 250, 0.3)',
textColorSuccess: '#A8C7FA',
}
}
return {}
});
const { t } = useI18n({
messages: {
en: {
authCode: 'Verification Code',
authLink: 'Authentication Link',
serviceLink: 'Service Link',
subscriptionLink: 'Subscription Link',
otherLink: 'Other Link',
copySuccess: 'Copied successfully',
copyFailed: 'Copy failed',
open: 'Open',
},
zh: {
authCode: '验证码',
authLink: '认证链接',
serviceLink: '服务链接',
subscriptionLink: '订阅链接',
otherLink: '其他链接',
copySuccess: '复制成功',
copyFailed: '复制失败',
open: '打开',
}
}
});
const props = defineProps({
metadata: {
type: String,
default: null
},
compact: {
type: Boolean,
default: false
}
});
const aiExtract = computed(() => {
if (!props.metadata) return null;
try {
const data = JSON.parse(props.metadata);
return data.ai_extract || null;
} catch (e) {
return null;
}
});
const typeLabel = computed(() => {
if (!aiExtract.value) return '';
const typeMap = {
auth_code: t('authCode'),
auth_link: t('authLink'),
service_link: t('serviceLink'),
subscription_link: t('subscriptionLink'),
other_link: t('otherLink'),
};
return typeMap[aiExtract.value.type] || '';
});
const typeIcon = computed(() => {
if (!aiExtract.value) return null;
const iconMap = {
auth_code: CodeRound,
auth_link: LinkRound,
service_link: LinkRound,
subscription_link: LinkRound,
other_link: LinkRound,
};
return iconMap[aiExtract.value.type] || null;
});
const isLink = computed(() => {
return aiExtract.value && aiExtract.value.type !== 'auth_code';
});
const displayText = computed(() => {
if (!aiExtract.value) return '';
// For auth_code, always show the raw result (verification code)
if (aiExtract.value.type === 'auth_code') {
return aiExtract.value.result;
}
// For links, prefer result_text as display label
return aiExtract.value.result_text || aiExtract.value.result;
});
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(aiExtract.value.result);
message.success(t('copySuccess'));
} catch (e) {
message.error(t('copyFailed'));
}
};
const openLink = () => {
if (isLink.value && aiExtract.value.result) {
window.open(aiExtract.value.result, '_blank');
}
};
</script>
<template>
<div v-if="aiExtract && aiExtract.result" class="ai-extract-info">
<n-alert v-if="!compact" type="success" closable :theme-overrides="alertThemeOverrides">
<template #icon>
<n-icon :component="typeIcon" />
</template>
<template #header>
{{ typeLabel }}
</template>
<n-space align="center">
<n-text v-if="aiExtract.type === 'auth_code'" strong style="font-size: 18px; font-family: monospace;">
{{ aiExtract.result }}
</n-text>
<n-ellipsis v-else style="max-width: 400px;">
{{ displayText }}
</n-ellipsis>
<n-button size="small" @click="copyToClipboard" tertiary>
<template #icon>
<n-icon :component="ContentCopyOutlined" />
</template>
</n-button>
<n-button v-if="isLink" size="small" @click="openLink" tertiary type="primary">
{{ t('open') }}
</n-button>
</n-space>
</n-alert>
<n-tag v-else type="success" @click="copyToClipboard" style="cursor: pointer;" size="small" :theme-overrides="tagThemeOverrides">
<template #icon>
<n-icon :component="typeIcon" />
</template>
<n-ellipsis style="max-width: 150px;">
{{ typeLabel }}: {{ displayText }}
</n-ellipsis>
</n-tag>
</div>
</template>
<style scoped>
.ai-extract-info {
margin-bottom: 10px;
}
</style>
+34 -62
View File
@@ -3,13 +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, InboxRound } from '@vicons/material'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } 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";
const message = useMessage()
const isMobile = useIsMobile()
@@ -50,44 +48,20 @@ const props = defineProps({
default: (mail_id, filename, blob) => { },
required: false
},
showFilterInput: {
type: Boolean,
default: false,
required: false
},
})
const localFilterKeyword = ref('')
const {
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState()
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
const rawData = ref([])
const data = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
// Computed property for filtered data (only filter current page)
const data = computed(() => {
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
return rawData.value;
}
const keyword = localFilterKeyword.value.toLowerCase();
return rawData.value.filter(mail => {
// Search in subject, text, message fields
const searchFields = [
mail.subject || '',
mail.text || '',
mail.message || ''
].map(field => field.toLowerCase());
return searchFields.some(field => field.includes(keyword));
});
})
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
@@ -148,7 +122,6 @@ 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',
@@ -162,8 +135,6 @@ const { t } = useI18n({
unselectAll: 'Unselect All',
prevMail: 'Previous',
nextMail: 'Next',
keywordQueryTip: 'Filter current page',
query: 'Query',
},
zh: {
success: '成功',
@@ -173,7 +144,6 @@ const { t } = useI18n({
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择邮件",
emptyInbox: "收件箱为空",
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
reply: '回复',
@@ -187,8 +157,6 @@ const { t } = useI18n({
unselectAll: '取消全选',
prevMail: '上一封',
nextMail: '下一封',
keywordQueryTip: '过滤当前页',
query: '查询',
}
}
});
@@ -228,7 +196,7 @@ const refresh = async () => {
pageSize.value, (page.value - 1) * pageSize.value
);
loading.value = true;
rawData.value = await Promise.all(results.map(async (item) => {
data.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
}));
@@ -277,12 +245,30 @@ const deleteMail = async () => {
};
const replyMail = async () => {
Object.assign(sendMailModel.value, buildReplyModel(curMail.value, t('reply')));
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>` : '',
});
indexTab.value = 'sendmail';
};
const forwardMail = async () => {
Object.assign(sendMailModel.value, buildForwardModel(curMail.value, t('forwardMail')));
Object.assign(sendMailModel.value, {
subject: `${t('forwardMail')}: ${curMail.value.subject}`,
contentType: curMail.value.message ? 'html' : 'text',
content: curMail.value.message || curMail.value.text,
});
indexTab.value = 'sendmail';
};
@@ -383,7 +369,7 @@ onBeforeUnmount(() => {
<div>
<div v-if="!isMobile" class="left">
<div style="margin-bottom: 10px;">
<n-space v-if="multiActionMode" align="center">
<n-space v-if="multiActionMode">
<n-button @click="multiActionModeClick(false)" tertiary>
{{ t('cancelMultiAction') }}
</n-button>
@@ -406,7 +392,7 @@ onBeforeUnmount(() => {
{{ t('downloadMail') }}
</n-button>
</n-space>
<n-space v-else align="center">
<n-space v-else>
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
{{ t('multiAction') }}
</n-button>
@@ -423,15 +409,12 @@ onBeforeUnmount(() => {
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
clearable />
</n-space>
</div>
<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: 60vh; max-height: 100vh;">
<div style="overflow: auto; min-height: 50vh; 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)">
@@ -456,7 +439,6 @@ onBeforeUnmount(() => {
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>
</n-thing>
</n-list-item>
@@ -491,18 +473,17 @@ 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="count === 0 ? t('emptyInbox') : t('pleaseSelectMail')">
<template #icon>
<n-icon :component="InboxRound" :size="100" />
</template>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
</div>
<div class="left" v-else>
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
<n-space justify="center">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
@@ -515,11 +496,7 @@ onBeforeUnmount(() => {
{{ t('refresh') }}
</n-button>
</n-space>
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
<n-input v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" size="small" clearable />
</div>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<div style="overflow: auto; height: 80vh;">
<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">
@@ -531,16 +508,11 @@ onBeforeUnmount(() => {
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
<n-ellipsis style="max-width: 240px;">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-ellipsis>
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
<n-ellipsis style="max-width: 240px;">
TO: {{ row.address }}
</n-ellipsis>
TO: {{ row.address }}
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>
</n-thing>
</n-list-item>
@@ -3,12 +3,11 @@ import { ref } from "vue";
import { useI18n } from 'vue-i18n'
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
import { getDownloadEmlUrl } from '../utils/email-parser';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
const { preferShowTextMail, useIframeShowMail, useUTCDate } = useGlobalState();
const { t } = useI18n({
messages: {
@@ -180,26 +179,23 @@ const handleSaveToS3 = async (filename, blob) => {
</n-button>
</n-space>
<!-- AI 提取信息 -->
<AiExtractInfo :metadata="mail.metadata" />
<!-- 邮件内容 -->
<div class="mail-content" :class="{ 'dark-mode': isDark }">
<div class="mail-content">
<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" :isDark="isDark" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" 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" :class="{ 'dark-mode': isDark }">
<div class="fullscreen-mail-content">
<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" :isDark="isDark" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
</div>
</n-drawer-content>
</n-drawer>
@@ -259,10 +255,6 @@ const handleSaveToS3 = async (filename, blob) => {
line-height: inherit;
}
.dark-mode .mail-text {
color: #e0e0e0;
}
.mail-iframe {
width: 100%;
height: 100%;
@@ -270,10 +262,6 @@ const handleSaveToS3 = async (filename, blob) => {
min-height: 400px;
}
.dark-mode .mail-iframe {
background-color: #fff;
}
.mail-html {
width: 100%;
height: 100%;
+3 -9
View File
@@ -5,7 +5,6 @@ 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()
@@ -53,7 +52,6 @@ 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',
@@ -66,7 +64,6 @@ const { t } = useI18n({
refresh: '刷新',
showCode: '切换查看元数据',
pleaseSelectMail: "请选择一封邮件查看。",
emptySent: "发件箱为空",
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
multiAction: '多选',
@@ -242,7 +239,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; min-height: 60vh; max-height: 100vh;">
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
@@ -300,10 +297,7 @@ 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="count === 0 ? t('emptySent') : t('pleaseSelectMail')">
<template #icon>
<n-icon :component="SendRound" :size="100" />
</template>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
@@ -318,7 +312,7 @@ onMounted(async () => {
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<div style="overflow: auto; height: 80vh;">
<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,10 +11,6 @@ const props = defineProps({
type: String,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
});
const shadowHost = ref(null);
@@ -44,13 +40,7 @@ const renderShadowDom = () => {
// Update content if Shadow DOM exists
if (shadowRoot) {
const darkModeStyle = props.isDark
? `<style>
:host { color: #e0e0e0; }
a { color: #A8C7FA; }
</style>`
: '';
shadowRoot.innerHTML = darkModeStyle + props.htmlContent;
shadowRoot.innerHTML = props.htmlContent;
}
} catch (error) {
console.error('Failed to render Shadow DOM, falling back to v-html:', error);
@@ -78,8 +68,8 @@ onBeforeUnmount(() => {
shadowRoot = null;
});
// Update Shadow DOM when htmlContent or dark mode changes
watch(() => [props.htmlContent, props.isDark], () => {
// Update Shadow DOM when htmlContent changes
watch(() => props.htmlContent, () => {
renderShadowDom();
}, { flush: 'post' });
</script>
+4 -8
View File
@@ -17,21 +17,17 @@ 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(containerId);
let container = document.getElementById("cf-turnstile");
let count = 100;
while (!container && count-- > 0) {
container = document.getElementById(containerId);
container = document.getElementById("cf-turnstile");
await new Promise(r => setTimeout(r, 10));
}
count = 100;
@@ -42,7 +38,7 @@ const checkCfTurnstile = async (remove) => {
window.turnstile.remove(cfTurnstileId.value);
}
cfTurnstileId.value = window.turnstile.render(
`#${containerId}`,
"#cf-turnstile",
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
@@ -72,7 +68,7 @@ onMounted(() => {
<n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row>
<n-flex vertical>
<div :id="containerId"></div>
<div id="cf-turnstile"></div>
<n-button text @click="checkCfTurnstile(true)">
{{ t('refresh') }}
</n-button>
+28 -131
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { onMounted, ref, h } from 'vue'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DropdownOption } from 'naive-ui'
const props = defineProps({
fetchData: {
@@ -33,7 +32,8 @@ const { t } = useI18n({
notEnabled: 'Webhook is not enabled for you',
urlMissing: 'URL is required',
enable: 'Enable',
presets: 'Presets',
messagePusherDemo: 'Fill with Message Pusher Demo',
messagePusherDoc: 'Message Pusher Doc',
fillInDemoTip: 'Please modify the URL and other settings to your own',
},
zh: {
@@ -43,7 +43,8 @@ const { t } = useI18n({
notEnabled: 'Webhook 未开启,请联系管理员开启',
urlMissing: 'URL 不能为空',
enable: '启用',
presets: '示例模板',
messagePusherDemo: '填入MessagePusher示例',
messagePusherDoc: 'MessagePusher文档',
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
}
}
@@ -57,131 +58,26 @@ class WebhookSettings {
body: string = JSON.stringify({}, null, 2)
}
interface WebhookPreset {
name: string
doc: string
settings: WebhookSettings
}
const messagePusherDocLink = "https://github.com/songquanpeng/message-pusher";
const presets: WebhookPreset[] = [
{
name: 'Message Pusher',
doc: 'https://github.com/songquanpeng/message-pusher',
settings: {
enabled: true,
url: 'https://msgpusher.com/push/username',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"token": "token",
"title": "${subject}",
"description": "${subject}",
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
}, null, 2),
},
},
{
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 messagePusherDemo = {
enabled: true,
url: 'https://msgpusher.com/push/username',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"token": "token",
"title": "${subject}",
"description": "${subject}",
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
}, null, 2),
} as WebhookSettings;
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)
const fillMessagePuhserDemo = () => {
Object.assign(webhookSettings.value, messagePusherDemo)
message.success(t('fillInDemoTip'))
window.open(preset.doc, '_blank', 'noopener,noreferrer')
}
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
@@ -232,11 +128,12 @@ onMounted(async () => {
<div class="center">
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-dropdown :options="presetDropdownOptions" @select="handlePresetSelect">
<n-button secondary>
{{ t('presets') }}
</n-button>
</n-dropdown>
<n-button tag="a" :href="messagePusherDocLink" target="_blank" secondary>
{{ t('messagePusherDoc') }}
</n-button>
<n-button @click="fillMessagePuhserDemo" secondary>
{{ t('messagePusherDemo') }}
</n-button>
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
{{ t('test') }}
</n-button>
+1 -1
View File
@@ -1,5 +1,5 @@
import { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import { createHead } from '@unhead/vue'
import App from './App.vue'
import router from './router'
-4
View File
@@ -1,6 +1,5 @@
export type UserOauth2Settings = {
name: string;
icon?: string; // SVG icon string for the provider
clientID: string;
clientSecret: string;
authorizationURL: string;
@@ -10,9 +9,6 @@ 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;
+3 -23
View File
@@ -28,20 +28,14 @@ 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,
@@ -69,7 +63,6 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const addressPassword = useSessionStorage('addressPassword', '');
const adminTab = useSessionStorage('adminTab', "account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
@@ -88,7 +81,7 @@ export const useGlobalState = createGlobalState(
fetched: false,
enable: false,
enableMailVerify: false,
/** @type {{ clientID: string, name: string, icon?: string }[]} */
/** @type {{ clientID: string, name: string }[]} */
oauth2ClientIDs: [],
});
const userSettings = ref({
@@ -114,19 +107,8 @@ export const useGlobalState = createGlobalState(
);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
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('');
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
return {
isDark,
toggleDark,
@@ -163,8 +145,6 @@ export const useGlobalState = createGlobalState(
userOauth2SessionState,
userOauth2SessionClientID,
useSimpleIndex,
addressPassword,
browserFingerprint,
}
},
)
@@ -1,265 +0,0 @@
// @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 &lt; b &amp; c &gt; 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 &lt; b &amp; c &gt; d')
})
})
-30
View File
@@ -1,30 +0,0 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { useGlobalState } from '../store';
const { browserFingerprint } = useGlobalState();
/**
* Get browser fingerprint
* Uses cached value from global state if available to avoid unnecessary computation
* @returns Fingerprint visitor ID, or 'ERROR' if failed
*/
export const getFingerprint = async (): Promise<string> => {
// Return cached fingerprint if available
if (browserFingerprint.value) {
return browserFingerprint.value;
}
try {
const fp = await FingerprintJS.load();
const result = await fp.get();
browserFingerprint.value = result.visitorId;
return browserFingerprint.value;
} catch (error) {
console.error('Failed to get fingerprint:', error);
// Return special error value to prevent blocking requests
const errorValue = 'ERROR';
browserFingerprint.value = errorValue;
return errorValue;
}
};
-68
View File
@@ -1,68 +0,0 @@
import DOMPurify from 'dompurify';
/**
* HTML-escape special characters for plain text content.
*/
function escapeHtml(str) {
const text = String(str ?? '');
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
/**
* 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),
};
}
+2 -108
View File
@@ -1,12 +1,9 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang, hashPassword } from '../utils'
import Turnstile from '../components/Turnstile.vue'
import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
@@ -17,7 +14,6 @@ import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
import RoleAddressConfig from './admin/RoleAddressConfig.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
@@ -28,16 +24,12 @@ import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue';
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
import AiExtractSettings from './admin/AiExtractSettings.vue';
const {
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage, userSettings,
openSettings
globalTabplacement, showAdminPage, userSettings
} = useGlobalState()
const message = useMessage()
const router = useRouter()
const SendMail = defineAsyncComponent(() => {
loading.value = true;
@@ -45,40 +37,16 @@ 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?.();
}
}
const showLogoutModal = ref(false)
const handleLogout = async () => {
// 清空管理员认证
adminAuth.value = '';
// 重置管理员相关状态
showAdminAuth.value = false;
adminTab.value = 'account';
// 显示成功提示并跳转
message.success(t('logoutSuccess'));
await router.push(getRouterPathWithLang('/', locale.value));
}
const { t, locale } = useI18n({
const { t } = useI18n({
messages: {
en: {
accessHeader: 'Admin Password',
@@ -93,7 +61,6 @@ const { t, locale } = useI18n({
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
roleAddressConfig: 'Role Address Config',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
@@ -103,22 +70,10 @@ const { t, locale } = useI18n({
maintenance: 'Maintenance',
database: 'Database',
workerconfig: 'Worker Config',
ipBlacklistSettings: 'IP Blacklist',
aiExtractSettings: 'AI Extract Settings',
appearance: 'Appearance',
about: 'About',
ok: 'OK',
mailWebhook: 'Mail Webhook',
adminAccount: 'Admin',
loginMethod: 'Login Method',
loginViaPassword: 'Admin Password Login',
loginViaUserAdmin: 'User Admin Permission',
loginViaDisabledCheck: 'Disabled Password Check',
logout: 'Logout',
logoutConfirmTitle: 'Confirm Logout',
logoutConfirmContent: 'Are you sure you want to logout from admin panel?',
confirm: 'Confirm',
logoutSuccess: 'Logout successful',
},
zh: {
accessHeader: 'Admin 密码',
@@ -133,7 +88,6 @@ const { t, locale } = useI18n({
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
roleAddressConfig: '角色地址配置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
@@ -143,46 +97,18 @@ const { t, locale } = useI18n({
maintenance: '维护',
database: '数据库',
workerconfig: 'Worker 配置',
ipBlacklistSettings: 'IP 黑名单',
aiExtractSettings: 'AI 提取设置',
appearance: '外观',
about: '关于',
ok: '确定',
mailWebhook: '邮件 Webhook',
adminAccount: '管理员',
loginMethod: '登录方式',
loginViaPassword: 'Admin 密码登录',
loginViaUserAdmin: '用户管理员权限',
loginViaDisabledCheck: '已禁用密码检查',
logout: '退出登录',
logoutConfirmTitle: '确认退出',
logoutConfirmContent: '确定要退出管理员面板吗?',
confirm: '确认',
logoutSuccess: '退出成功',
}
}
});
const showAdminPasswordModal = computed(() => !showAdminPage.value || showAdminAuth.value)
const tmpAdminAuth = ref('')
// 判断是否通过 admin password 登录(而非用户管理员权限)
const isAdminPasswordLogin = computed(() => !!adminAuth.value)
// 获取当前登录方式
const currentLoginMethod = computed(() => {
if (adminAuth.value) {
return t('loginViaPassword');
} else if (userSettings.value.is_admin) {
return t('loginViaUserAdmin');
} else if (openSettings.value.disableAdminPasswordCheck) {
return t('loginViaDisabledCheck');
}
return '';
})
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);
})
@@ -194,7 +120,6 @@ 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') }}
@@ -232,12 +157,6 @@ onMounted(async () => {
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="ipBlacklistSettings" :tab="t('ipBlacklistSettings')">
<IpBlacklistSettings />
</n-tab-pane>
<n-tab-pane name="aiExtractSettings" :tab="t('aiExtractSettings')">
<AiExtractSettings />
</n-tab-pane>
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
@@ -254,9 +173,6 @@ onMounted(async () => {
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
<UserOauth2Settings />
</n-tab-pane>
<n-tab-pane name="roleAddressConfig" :tab="t('roleAddressConfig')">
<RoleAddressConfig />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
@@ -300,32 +216,10 @@ onMounted(async () => {
<n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance />
</n-tab-pane>
<n-tab-pane name="adminAccount" :tab="t('adminAccount')">
<div style="display: flex; justify-content: center; padding: 20px;">
<n-card style="width: 600px;">
<n-space vertical>
<n-text strong>{{ t('loginMethod') }}</n-text>
<n-text>{{ currentLoginMethod }}</n-text>
<n-divider v-if="isAdminPasswordLogin" />
<n-button v-if="isAdminPasswordLogin" type="warning" @click="showLogoutModal = true" block>
{{ t('logout') }}
</n-button>
</n-space>
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="about" :tab="t('about')">
<About />
</n-tab-pane>
</n-tabs>
<n-modal v-model:show="showLogoutModal" preset="dialog" :title="t('logoutConfirmTitle')">
<p>{{ t('logoutConfirmContent') }}</p>
<template #action>
<n-button :loading="loading" @click="handleLogout" size="small" tertiary type="warning">
{{ t('confirm') }}
</n-button>
</template>
</n-modal>
</div>
</template>
+2 -36
View File
@@ -6,14 +6,13 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import {
DarkModeFilled, LightModeFilled, MenuFilled,
AdminPanelSettingsFilled, MonitorHeartFilled
AdminPanelSettingsFilled
} from '@vicons/material'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang, hashPassword } from '../utils'
import Turnstile from '../components/Turnstile.vue'
import { getRouterPathWithLang } from '../utils'
const message = useMessage()
const notification = useNotification()
@@ -33,22 +32,11 @@ 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?.();
}
}
@@ -71,7 +59,6 @@ const { locale, t } = useI18n({
home: 'Home',
menu: 'Menu',
user: 'User',
status: 'Status',
ok: 'OK',
},
zh: {
@@ -83,7 +70,6 @@ const { locale, t } = useI18n({
home: '主页',
menu: '菜单',
user: '用户',
status: '状态',
ok: '确定',
}
}
@@ -193,25 +179,6 @@ 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,
@@ -299,7 +266,6 @@ 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') }}
+4 -4
View File
@@ -156,15 +156,15 @@ onMounted(() => {
</n-button>
</n-input-group>
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:deleteMail="deleteSenboxMail" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
<n-tab-pane name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
+18 -374
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, h, onMounted, watch, computed } from 'vue';
import { NBadge, useMessage } from 'naive-ui'
import { ref, h, onMounted, watch } from 'vue';
import { NBadge } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
@@ -9,7 +9,7 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
loading, adminTab, openSettings,
loading, adminTab,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
@@ -22,7 +22,6 @@ const { t } = useI18n({
updated_at: 'Update At',
mail_count: 'Mail Count',
send_count: 'Send Count',
source_meta: 'Source',
showCredential: 'Show Mail Address Credential',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
@@ -34,25 +33,7 @@ const { t } = useI18n({
itemCount: 'itemCount',
query: 'Query',
addressQueryTip: 'Leave blank to query all addresses',
clearInbox: 'Clear Inbox',
clearSentItems: 'Clear Sent Items',
clearInboxTip: 'Are you sure to clear inbox for this email?',
clearSentItemsTip: 'Are you sure to clear sent items for this email?',
actions: 'Actions',
success: 'Success',
resetPassword: 'Reset Password',
newPassword: 'New Password',
passwordResetSuccess: 'Password reset successfully',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
pleaseSelectAddress: 'Please select address',
selectedItems: 'Selected',
multiDelete: 'Multi Delete',
multiDeleteTip: 'Are you sure to delete selected addresses?',
multiClearInbox: 'Multi Clear Inbox',
multiClearInboxTip: 'Are you sure to clear inbox for selected addresses?',
multiClearSentItems: 'Multi Clear Sent Items',
multiClearSentItemsTip: 'Are you sure to clear sent items for selected addresses?',
actions: 'Actions'
},
zh: {
name: '名称',
@@ -60,7 +41,6 @@ const { t } = useI18n({
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
source_meta: '来源',
showCredential: '查看邮箱地址凭证',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
@@ -72,25 +52,7 @@ const { t } = useI18n({
itemCount: '总数',
query: '查询',
addressQueryTip: '留空查询所有地址',
clearInbox: '清空收件箱',
clearSentItems: '清空发件箱',
clearInboxTip: '确定要清空这个邮箱的收件箱吗?',
clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?',
actions: '操作',
success: '成功',
resetPassword: '重置密码',
newPassword: '新密码',
passwordResetSuccess: '密码重置成功',
selectAll: '全选本页',
unselectAll: '取消全选',
pleaseSelectAddress: '请选择地址',
selectedItems: '已选择',
multiDelete: '批量删除',
multiDeleteTip: '确定要删除选中的邮箱吗?',
multiClearInbox: '批量清空收件箱',
multiClearInboxTip: '确定要清空选中邮箱的收件箱吗?',
multiClearSentItems: '批量清空发件箱',
multiClearSentItemsTip: '确定要清空选中邮箱的发件箱吗?',
}
}
});
@@ -98,32 +60,14 @@ const { t } = useI18n({
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const curClearInboxAddressId = ref(0);
const curClearSentItemsAddressId = ref(0);
const showResetPassword = ref(false);
const curResetPasswordAddressId = ref(0);
const newPassword = ref('');
// Multi-action mode state
const checkedRowKeys = ref([]);
const showMultiActionModal = ref(false);
const multiActionProgress = ref({ percentage: 0, tip: '0/0' });
const multiActionTitle = ref('');
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)
const page = ref(1)
const pageSize = ref(20)
const showDeleteAccount = ref(false)
const showClearInbox = ref(false)
const showClearSentItems = ref(false)
const showCredential = async (id) => {
try {
@@ -139,7 +83,7 @@ const showCredential = async (id) => {
const deleteEmail = async () => {
try {
await api.adminDeleteAddress(curDeleteAddressId.value)
message.success(t("success"));
message.success("success");
await fetchData()
} catch (error) {
message.error(error.message || "error");
@@ -148,142 +92,6 @@ const deleteEmail = async () => {
}
}
const clearInbox = async () => {
try {
await api.fetch(`/admin/clear_inbox/${curClearInboxAddressId.value}`, {
method: 'DELETE'
});
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showClearInbox.value = false
}
}
const clearSentItems = async () => {
try {
await api.fetch(`/admin/clear_sent_items/${curClearSentItemsAddressId.value}`, {
method: 'DELETE'
});
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showClearSentItems.value = false
}
}
const resetPassword = async () => {
try {
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
method: 'POST',
body: JSON.stringify({
password: newPassword.value
})
});
message.success(t("passwordResetSuccess"));
newPassword.value = '';
showResetPassword.value = false;
} catch (error) {
message.error(error.message || "error");
}
}
// Multi-action mode functions
const multiActionSelectAll = () => {
checkedRowKeys.value = data.value.map(item => item.id);
}
const multiActionUnselectAll = () => {
checkedRowKeys.value = [];
}
// 通用批量操作函数
const executeBatchOperation = async ({
shouldSkip = () => false,
apiCall,
title,
operationName = 'operation'
}) => {
try {
loading.value = true;
const selectedAddresses = data.value.filter((item) =>
checkedRowKeys.value.includes(item.id)
);
if (selectedAddresses.length === 0) {
message.error(t('pleaseSelectAddress'));
return;
}
const failedIds = [];
const totalCount = selectedAddresses.length;
multiActionProgress.value = {
percentage: 0,
tip: `0/${totalCount}`
};
multiActionTitle.value = title;
showMultiActionModal.value = true;
for (const [index, address] of selectedAddresses.entries()) {
try {
if (!shouldSkip(address)) {
await apiCall(address.id);
}
} catch (error) {
console.error(`${operationName} failed for address ${address.id}:`, error);
failedIds.push(address.id);
}
multiActionProgress.value = {
percentage: Math.floor((index + 1) / totalCount * 100),
tip: `${index + 1}/${totalCount}`
};
}
await fetchData();
checkedRowKeys.value = failedIds;
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
}
}
const multiActionDeleteAccounts = async () => {
await executeBatchOperation({
apiCall: (id) => api.adminDeleteAddress(id),
title: t('multiDelete') + ' ' + t('success'),
operationName: 'Delete'
});
}
const multiActionClearInbox = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.mail_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_inbox/${id}`, {
method: 'DELETE'
}),
title: t('multiClearInbox') + ' ' + t('success'),
operationName: 'ClearInbox'
});
}
const multiActionClearSentItems = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.send_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_sent_items/${id}`, {
method: 'DELETE'
}),
title: t('multiClearSentItems') + ' ' + t('success'),
operationName: 'ClearSentItems'
});
}
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim()
@@ -292,90 +100,37 @@ 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 (page.value === 1 || addressCount > 0) {
count.value = addressCount ?? 0;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.error(error);
console.log(error)
message.error(error.message || "error");
}
}
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'
},
const columns = [
{
title: "ID",
key: "id",
sorter: true,
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
key: "id"
},
{
title: t('name'),
key: "name",
sorter: true,
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
key: "name"
},
{
title: t('created_at'),
key: "created_at",
sorter: true,
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
key: "created_at"
},
{
title: t('updated_at'),
key: "updated_at",
sorter: true,
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
},
{
title: t('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;
}
key: "updated_at"
},
{
title: t('mail_count'),
key: "mail_count",
sorter: true,
sortOrder: sortBy.value === 'mail_count' ? sortOrder.value : false,
render(row) {
return h(NButton,
{
@@ -402,8 +157,6 @@ const columns = computed(() => [
{
title: t('send_count'),
key: "send_count",
sorter: true,
sortOrder: sortBy.value === 'send_count' ? sortOrder.value : false,
render(row) {
return h(NButton,
{
@@ -475,45 +228,6 @@ const columns = computed(() => [
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curClearInboxAddressId.value = row.id;
showClearInbox.value = true;
}
},
{ default: () => t('clearInbox') }
),
show: row.mail_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curClearSentItemsAddressId.value = row.id;
showClearSentItems.value = true;
}
},
{ default: () => t('clearSentItems') }
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curResetPasswordAddressId.value = row.id;
showResetPassword.value = true;
}
},
{ default: () => t('resetPassword') }
),
show: openSettings.value?.enableAddressPassword
},
{
label: () => h(NButton,
{
@@ -533,7 +247,7 @@ const columns = computed(() => [
])
}
}
])
]
watch([page, pageSize], async () => {
await fetchData()
@@ -567,70 +281,13 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
<p>{{ t('clearInboxTip') }}</p>
<template #action>
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="error">
{{ t('clearInbox') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
<p>{{ t('clearSentItemsTip') }}</p>
<template #action>
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="error">
{{ t('clearSentItems') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
</n-form-item>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">
{{ t('resetPassword') }}
</n-button>
</template>
</n-modal>
<n-input-group style="margin-bottom: 10px;">
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
@keydown.enter="searchData" />
<n-button @click="searchData" type="primary" tertiary>
@keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<n-space v-if="showMultiActionBar" style="margin-bottom: 10px;">
<n-button @click="multiActionSelectAll" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionUnselectAll" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm @positive-click="multiActionDeleteAccounts">
<template #trigger>
<n-button tertiary type="error">{{ t('multiDelete') }}</n-button>
</template>
{{ t('multiDeleteTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearInbox">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearInbox') }}</n-button>
</template>
{{ t('multiClearInboxTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearSentItems">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearSentItems') }}</n-button>
</template>
{{ t('multiClearSentItemsTip') }}
</n-popconfirm>
<n-tag type="info">
{{ t('selectedItems') }}: {{ selectedCount }}
</n-tag>
</n-space>
<div style="overflow: auto;">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
@@ -640,21 +297,8 @@ onMounted(async () => {
</template>
</n-pagination>
</div>
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
:row-key="row => row.id" remote @update:sorter="handleSorterChange" embedded />
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
<!-- Multi-action progress modal -->
<n-modal v-model:show="showMultiActionModal" preset="dialog" :title="multiActionTitle" negative-text="OK">
<n-space justify="center">
<n-progress type="circle" status="info" :percentage="multiActionProgress.percentage">
<span style="text-align: center">
{{ multiActionProgress.tip }}
</span>
</n-progress>
</n-space>
</n-modal>
</div>
</template>
+28 -358
View File
@@ -1,7 +1,7 @@
<script setup>
import { computed, onMounted, ref, h } from 'vue';
import { onMounted, ref, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
import { NButton, NPopconfirm, NInput, NSelect } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
@@ -13,7 +13,6 @@ const { t } = useI18n({
messages: {
en: {
tip: 'You can manually input the following multiple select input and enter',
manualInputPrompt: 'Type and press Enter to add',
save: 'Save',
successTip: 'Save Success',
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
@@ -21,16 +20,10 @@ 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',
domain_list: 'Domain List (Optional)',
domain_list: 'Domain List',
forward_address: 'Forward Address',
actions: 'Actions',
select_domain: 'Select Domain',
@@ -38,32 +31,13 @@ const { t } = useI18n({
delete_rule: 'Delete',
delete_rule_confirm: 'Are you sure you want to delete this rule?',
delete_success: 'Delete Success',
forwarding_rule_warning: 'Each rule will run independently. Forward address needs to be a verified address.',
forwarding_rule_warning: 'Each rule will run, if domains is empty, all emails will be forwarded, forward address needs to be a verified address',
add: 'Add',
cancel: 'Cancel',
config: 'Config',
source_patterns: 'Source Address Regex (Optional)',
source_patterns_placeholder: 'e.g. gmail.com',
source_match_mode: 'Match Mode',
match_any: 'Any',
match_all: 'All',
source_patterns_tip: 'Domain list filters by recipient address, source regex filters by sender address. Both conditions must match for forwarding (AND logic). Leave either empty to skip that filter.',
regex_too_long: 'Regex pattern too long (max 200 characters)',
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: '您可以手动输入以下多选输入框, 回车增加',
manualInputPrompt: '输入后按回车键添加',
save: '保存',
successTip: '保存成功',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
@@ -71,16 +45,10 @@ 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: '邮件转发配置',
domain_list: '域名列表(可选)',
domain_list: '域名列表',
forward_address: '转发地址',
actions: '操作',
select_domain: '选择域名',
@@ -88,28 +56,10 @@ const { t } = useI18n({
delete_rule: '删除',
delete_rule_confirm: '确定要删除这条规则吗?',
delete_success: '删除成功',
forwarding_rule_warning: '每条规则独立运行,转发地址需要为已验证的地址',
forwarding_rule_warning: '每条规则都会运行,如果 domains 为空,则转发所有邮件,转发地址需要为已验证的地址',
add: '添加',
cancel: '取消',
config: '配置',
source_patterns: '来源地址正则(可选)',
source_patterns_placeholder: '例如: gmail.com',
source_match_mode: '匹配模式',
match_any: '任一',
match_all: '全部',
source_patterns_tip: '域名列表按收件地址过滤,来源正则按发件地址过滤,两者均为可选。同时配置时需同时满足(AND 逻辑),留空则跳过该条件。',
regex_too_long: '正则表达式过长(最大200字符)',
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 打开或移除前不会生效。',
}
}
});
@@ -123,44 +73,6 @@ 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([])
@@ -184,39 +96,6 @@ const emailForwardingColumns = [
})
}
},
{
title: t('source_patterns'),
key: 'sourcePatterns',
render: (row, index) => {
return h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' }, [
h(NSelect, {
value: Array.isArray(row.sourcePatterns) ? row.sourcePatterns : [],
onUpdateValue: (val) => {
emailForwardingList.value[index].sourcePatterns = val
},
multiple: true,
filterable: true,
tag: true,
placeholder: t('source_patterns_placeholder')
}, {
empty: () => h('span', { style: 'color: #999; font-size: 12px;' }, t('manualInputPrompt'))
}),
h(NRadioGroup, {
value: row.sourceMatchMode || 'any',
onUpdateValue: (val) => {
emailForwardingList.value[index].sourceMatchMode = val
},
size: 'small',
style: 'margin-top: 4px;'
}, {
default: () => [
h(NRadio, { value: 'any' }, { default: () => t('match_any') }),
h(NRadio, { value: 'all' }, { default: () => t('match_all') })
]
})
])
}
},
{
title: t('forward_address'),
key: 'forward',
@@ -264,100 +143,18 @@ const addNewEmailForwardingItem = () => {
...emailForwardingList.value,
{
domains: [],
forward: '',
sourcePatterns: [],
sourceMatchMode: 'any'
forward: ''
}
]
}
const MAX_REGEX_LENGTH = 200
const validateForwardingRules = () => {
for (let i = 0; i < emailForwardingList.value.length; i++) {
const rule = emailForwardingList.value[i]
//
if (!rule.forward || rule.forward.trim() === '') {
message.error(`${t('forward_address_required')} (${t('rule_index')} ${i + 1})`)
return false
}
//
if (rule.sourcePatterns && rule.sourcePatterns.length > 0) {
for (const pattern of rule.sourcePatterns) {
//
if (pattern.length > MAX_REGEX_LENGTH) {
message.error(`${t('regex_too_long')}: ${pattern.substring(0, 30)}...`)
return false
}
//
try {
new RegExp(pattern, 'i')
} catch (e) {
message.error(`${t('regex_invalid')}: ${pattern}`)
return false
}
}
}
}
return true
}
const saveEmailForwardingConfig = () => {
if (!validateForwardingRules()) {
return
}
emailRuleSettings.value.emailForwardingList = [...emailForwardingList.value]
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 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 } = {}) => {
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/account_settings`)
addressBlockList.value = res.blockList || []
@@ -365,80 +162,37 @@ const fetchData = async ({ suppressErrorMessage = false } = {}) => {
verifiedAddressList.value = res.verifiedAddressList || []
fromBlockList.value = res.fromBlockList || []
noLimitSendAddressList.value = res.noLimitSendAddressList || []
emailRuleSettings.value = {
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
emailRuleSettings.value = res.emailRuleSettings || {
blockReceiveUnknowAddressEmail: false,
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
message.error(error.message || "error");
}
}
const save = async () => {
if (!validateSendMailLimit()) {
return
}
try {
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)
body: JSON.stringify({
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
noLimitSendAddressList: noLimitSendAddressList.value || [],
emailRuleSettings: emailRuleSettings.value,
})
})
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
}
await fetchData();
})
</script>
@@ -455,107 +209,25 @@ onMounted(async () => {
</n-flex>
<n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
:placeholder="t('address_block_list_placeholder')" />
</n-form-item-row>
<n-form-item-row :label="t('send_address_block_list')">
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
:placeholder="t('address_block_list_placeholder')" />
</n-form-item-row>
<n-form-item-row :label="t('noLimitSendAddressList')">
<n-select v-model:value="noLimitSendAddressList" filterable multiple tag
:placeholder="t('noLimitSendAddressList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
:placeholder="t('noLimitSendAddressList')" />
</n-form-item-row>
<n-form-item-row :label="t('verified_address_list')">
<n-select v-model:value="verifiedAddressList" filterable multiple tag
:placeholder="t('verified_address_list')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</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>
:placeholder="t('verified_address_list')" />
</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>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')" />
</n-form-item-row>
<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-checkbox v-model:checked="emailRuleSettings.blockReceiveUnknowAddressEmail" />
</n-form-item-row>
<n-form-item-row :label="t('email_forwarding_config')">
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
@@ -565,12 +237,10 @@ onMounted(async () => {
<!-- 邮件转发配置弹窗 -->
<n-modal v-model:show="showEmailForwardingModal" preset="card" :title="t('email_forwarding_config')"
style="max-width: 1000px;">
style="max-width: 800px;">
<n-space vertical>
<n-alert :show-icon="false" :bordered="false" type="warning">
<span>{{ t('forwarding_rule_warning') }}</span>
<br />
<span>{{ t('source_patterns_tip') }}</span>
</n-alert>
<n-space justify="end">
<n-button @click="addNewEmailForwardingItem">{{ t('add') }}</n-button>
@@ -1,125 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
// @ts-ignore
import { api } from '../../api'
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
title: 'AI Email Extraction Settings',
successTip: 'Success',
save: 'Save',
enableAllowList: 'Enable Address Allowlist',
enableAllowListTip: 'When enabled, AI extraction will only process emails sent to addresses in the allowlist',
allowList: 'Address Allowlist (Enter address and press Enter, wildcards supported)',
allowListTip: "Wildcard * matches any characters, e.g. *{'@'}example.com matches all addresses under example.com domain",
manualInputPrompt: 'Type and press Enter to add',
disabledTip: 'When disabled, AI extraction will process all email addresses',
},
zh: {
title: 'AI 邮件提取设置',
successTip: '成功',
save: '保存',
enableAllowList: '启用地址白名单',
enableAllowListTip: '启用后,AI 提取功能仅对白名单中的邮箱地址生效',
allowList: '地址白名单 (请输入地址并回车,支持通配符)',
allowListTip: "通配符 * 可匹配任意字符,如 *{'@'}example.com 可匹配 example.com 域名下的所有地址",
manualInputPrompt: '输入后按回车键添加',
disabledTip: '未启用时,所有邮箱地址都可使用 AI 提取功能',
}
}
});
type AiExtractSettings = {
enableAllowList: boolean
allowList: string[]
}
const settings = ref<AiExtractSettings>({
enableAllowList: false,
allowList: []
})
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/ai_extract/settings`) as AiExtractSettings
Object.assign(settings.value, res)
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
try {
await api.fetch(`/admin/ai_extract/settings`, {
method: 'POST',
body: JSON.stringify(settings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button @click="saveSettings" type="primary">
{{ t('save') }}
</n-button>
</n-flex>
<n-form-item-row :label="t('enableAllowList')">
<n-switch v-model:value="settings.enableAllowList" :round="false" />
</n-form-item-row>
<n-alert v-if="!settings.enableAllowList" type="info" style="margin-bottom: 16px;">
{{ t('disabledTip') }}
</n-alert>
<div v-if="settings.enableAllowList">
<n-alert type="warning" style="margin-bottom: 16px;">
{{ t('enableAllowListTip') }}
</n-alert>
<n-form-item-row :label="t('allowList')">
<n-select v-model:value="settings.allowList" filterable multiple tag
:placeholder="t('allowListTip')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-text depth="3" style="font-size: 12px;">
{{ t('allowListTip') }}
</n-text>
</div>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>
+5 -61
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
@@ -15,15 +15,10 @@ const { t } = useI18n({
en: {
address: 'Address',
enablePrefix: 'If enable Prefix',
creatNewEmail: 'Create New Email',
creatNewEmail: 'Get New Email',
fillInAllFields: 'Please fill in all fields',
successTip: 'Success Created',
addressCredential: 'Mail Address Credential',
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: '地址',
@@ -32,36 +27,15 @@ const { t } = useI18n({
fillInAllFields: '请填写完整信息',
successTip: '创建成功',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
addressPassword: '地址密码',
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
enableRandomSubdomain: '启用随机子域名',
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
}
}
});
const enablePrefix = ref(true)
const enableRandomSubdomain = ref(false)
const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
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) {
@@ -73,14 +47,11 @@ const newEmail = async () => {
method: 'POST',
body: JSON.stringify({
enablePrefix: enablePrefix.value,
enableRandomSubdomain: enableRandomSubdomain.value,
name: emailName.value,
domain: emailDomain.value,
})
})
result.value = res["jwt"];
addressPassword.value = res["password"] || '';
createdAddress.value = res["address"] || '';
message.success(t('successTip'))
showReultModal.value = true
} catch (error) {
@@ -88,10 +59,6 @@ const newEmail = async () => {
}
}
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${result.value}`
}
onMounted(async () => {
if (openSettings.prefix) {
enablePrefix.value = true
@@ -103,29 +70,14 @@ onMounted(async () => {
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<p>{{ t('addressCredential') }}</p>
<n-card :bordered="false" embedded>
<b>{{ result }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ createdAddress }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-switch v-model:value="enablePrefix" :round="false" />
<n-checkbox v-model:checked="enablePrefix" />
</n-form-item-row>
<n-form-item-row :label="t('address')">
<n-input-group>
@@ -138,14 +90,6 @@ 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>

Some files were not shown because too many files have changed in this diff Show More