mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-02 12:52:12 +08:00
Compare commits
44 Commits
03965f3612
...
feat/email
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e32cf472b | ||
|
|
296ddb8619 | ||
|
|
a5b64e1dc9 | ||
|
|
fa19dbbe02 | ||
|
|
ebeb94ed23 | ||
|
|
d1fb1f773b | ||
|
|
5c40eeec80 | ||
|
|
000cd0ddfa | ||
|
|
e772db8c3e | ||
|
|
a5aa475380 | ||
|
|
3221f5ae30 | ||
|
|
15e339282d | ||
|
|
e15b1b83d0 | ||
|
|
c297a49b2a | ||
|
|
de3f9e92ed | ||
|
|
832e996dd8 | ||
|
|
e81c9d0d9c | ||
|
|
163df5c908 | ||
|
|
c3058817ff | ||
|
|
16c4e43871 | ||
|
|
68cbfb9c32 | ||
|
|
e18285d3ef | ||
|
|
1584851a36 | ||
|
|
1cafbbf220 | ||
|
|
873a10ddb1 | ||
|
|
9689a1cbca | ||
|
|
ef475bab21 | ||
|
|
e6ef110ec9 | ||
|
|
42281cdc49 | ||
|
|
5248c03b6c | ||
|
|
b86d1faac4 | ||
|
|
a0db913952 | ||
|
|
4746983780 | ||
|
|
f08d062b32 | ||
|
|
8885948291 | ||
|
|
7c6d0d7c8a | ||
|
|
53c35062c8 | ||
|
|
1a7cfb8c95 | ||
|
|
d2c940aa2c | ||
|
|
db93828a81 | ||
|
|
be1bf71a47 | ||
|
|
424991a165 | ||
|
|
c97a9a278b | ||
|
|
a45d01f9fd |
3
.claude/skills/cf-temp-mail-release-notify/.gitignore
vendored
Normal file
3
.claude/skills/cf-temp-mail-release-notify/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
config.json
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
30
.claude/skills/cf-temp-mail-release-notify/SKILL.md
Normal file
30
.claude/skills/cf-temp-mail-release-notify/SKILL.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: cf-temp-mail-release-notify
|
||||
description: Announce a cloudflare_temp_email GitHub release to the project's Telegram channel topic. Use when the user asks to notify/announce/broadcast a release to Telegram, push release notes to the channel, or send a release to the topic after running cf-temp-mail-release. Posts bilingual (中文 + English) changelog excerpts plus the release URL.
|
||||
---
|
||||
|
||||
# Release Notify Workflow
|
||||
|
||||
Post an existing GitHub release's notes to the project's Telegram channel topic.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `config.json` exists in this skill directory with `token`, `chat_id`, `message_thread_id` (gitignored, never commit).
|
||||
- `gh` CLI authenticated.
|
||||
- `uv` installed (`brew install uv` / `curl -LsSf https://astral.sh/uv/install.sh | sh`). Script uses PEP 723 inline metadata; `uv` auto-installs deps.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Resolve tag**: If the user didn't give one, use the latest release: `gh release list --limit 1 --json tagName --jq '.[0].tagName'`.
|
||||
2. **Run the script**:
|
||||
```bash
|
||||
uv run scripts/send_release_to_telegram.py vX.Y.Z
|
||||
```
|
||||
The script fetches the release via `gh`, splits the body into zh/en sections, strips PR collapsibles and the cache-clearing link, truncates to fit Telegram's 4096-char limit, and posts to the configured `chat_id` + `message_thread_id`.
|
||||
3. **Verify**: The script prints `ok: message_id=<id>` on success. Report the message id.
|
||||
|
||||
## Notes
|
||||
|
||||
- Message uses `parse_mode: MarkdownV2`; all content is escaped (via `md_escape`) to avoid parse errors on reserved chars `_ * [ ] ( ) ~ \` > # + - = | { } . !`.
|
||||
- Only the zh/en changelog sections are posted. PRs list and the cache-clearing discussion link are stripped to keep the message concise.
|
||||
- For very long release bodies, zh and en are each truncated to ~half of the 3500-char body budget.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"token": "<telegram bot token>",
|
||||
"chat_id": "@cloudflare_temp_email",
|
||||
"message_thread_id": 82
|
||||
}
|
||||
221
.claude/skills/cf-temp-mail-release-notify/scripts/send_release_to_telegram.py
Executable file
221
.claude/skills/cf-temp-mail-release-notify/scripts/send_release_to_telegram.py
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["httpx>=0.27"]
|
||||
# ///
|
||||
"""Send a cloudflare_temp_email release announcement to a Telegram channel topic.
|
||||
|
||||
Usage:
|
||||
uv run scripts/send_release_to_telegram.py <tag>
|
||||
|
||||
Reads skill config from ../config.json (relative to this script):
|
||||
{
|
||||
"token": "...",
|
||||
"chat_id": "@channel_or_-100...",
|
||||
"message_thread_id": 82
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
TG_API = "https://api.telegram.org"
|
||||
TG_HARD_LIMIT = 4096
|
||||
BODY_BUDGET = 3500 # leave room for header + footer
|
||||
EN_MARKER_RE = re.compile(r"<details>\s*<summary>English</summary>", re.IGNORECASE)
|
||||
MDV2_ESCAPE_RE = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
|
||||
MDV2_CODE_ESCAPE_RE = re.compile(r"([`\\])")
|
||||
MD_INLINE_RE = re.compile(r"\*\*(.+?)\*\*|`([^`]+)`")
|
||||
MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
||||
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def md_escape(text: str) -> str:
|
||||
"""Escape all MarkdownV2 reserved characters."""
|
||||
return MDV2_ESCAPE_RE.sub(r"\\\1", text)
|
||||
|
||||
|
||||
def md_render(text: str) -> str:
|
||||
"""Convert source Markdown (changelog) to Telegram MarkdownV2.
|
||||
|
||||
Handles:
|
||||
- `### Heading` -> bold line
|
||||
- `**bold**` -> `*bold*`
|
||||
- `` `code` `` -> `` `code` `` (only ` and \\ escaped inside)
|
||||
- everything else: literal text with MDV2 specials escaped
|
||||
"""
|
||||
out: list[str] = []
|
||||
for raw in text.splitlines():
|
||||
m = MD_HEADING_RE.match(raw)
|
||||
if m:
|
||||
out.append(f"*{md_escape(m.group(2).strip())}*")
|
||||
continue
|
||||
segments: list[str] = []
|
||||
last = 0
|
||||
for im in MD_INLINE_RE.finditer(raw):
|
||||
segments.append(md_escape(raw[last:im.start()]))
|
||||
if im.group(1) is not None:
|
||||
segments.append(f"*{md_escape(im.group(1))}*")
|
||||
else:
|
||||
segments.append(f"`{MDV2_CODE_ESCAPE_RE.sub(r'\\\\\1', im.group(2))}`")
|
||||
last = im.end()
|
||||
segments.append(md_escape(raw[last:]))
|
||||
out.append("".join(segments))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg_path = Path(__file__).resolve().parent.parent / "config.json"
|
||||
if not cfg_path.exists():
|
||||
die(f"config missing: {cfg_path}")
|
||||
try:
|
||||
cfg = json.loads(cfg_path.read_text())
|
||||
except json.JSONDecodeError as e:
|
||||
die(f"config.json is not valid JSON: {e}")
|
||||
for k in ("token", "chat_id", "message_thread_id"):
|
||||
if k not in cfg:
|
||||
die(f"config.json missing key: {k}")
|
||||
return cfg
|
||||
|
||||
|
||||
def fetch_release(tag: str) -> dict:
|
||||
out = subprocess.run(
|
||||
["gh", "release", "view", tag, "--json", "tagName,name,body,url"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
die(f"gh release view failed: {out.stderr.strip()}")
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def extract_sections(body: str) -> tuple[str, str]:
|
||||
m = EN_MARKER_RE.search(body)
|
||||
if not m:
|
||||
return body.strip(), ""
|
||||
zh = body[: m.start()]
|
||||
rest = body[m.end():]
|
||||
close = rest.find("</details>")
|
||||
if close < 0:
|
||||
die("malformed release body: missing </details> after English marker")
|
||||
en = rest[:close]
|
||||
return zh.strip(), en.strip()
|
||||
|
||||
|
||||
def strip_noise(text: str) -> str:
|
||||
"""Drop PR collapsibles, cache-clearing link, and Full Changelog line."""
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
depth = 0
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("<details>"):
|
||||
depth += 1
|
||||
continue
|
||||
if stripped.startswith("</details>"):
|
||||
depth = max(0, depth - 1)
|
||||
continue
|
||||
if depth > 0:
|
||||
continue
|
||||
if "discussions/487" in stripped:
|
||||
continue
|
||||
if stripped.startswith("**Full Changelog**"):
|
||||
continue
|
||||
out.append(line)
|
||||
result: list[str] = []
|
||||
blanks = 0
|
||||
for line in out:
|
||||
if not line.strip():
|
||||
blanks += 1
|
||||
if blanks <= 1:
|
||||
result.append(line)
|
||||
else:
|
||||
blanks = 0
|
||||
result.append(line)
|
||||
return "\n".join(result).strip()
|
||||
|
||||
|
||||
def truncate(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 3].rstrip() + "..."
|
||||
|
||||
|
||||
def _budget(zh: str, en: str, total: int) -> tuple[int, int]:
|
||||
"""Split budget between zh and en based on actual length. Short side keeps full, long side absorbs the rest."""
|
||||
if not en:
|
||||
return total, 0
|
||||
if len(zh) + len(en) <= total:
|
||||
return len(zh), len(en)
|
||||
half = total // 2
|
||||
if len(zh) <= half:
|
||||
return len(zh), total - len(zh)
|
||||
if len(en) <= half:
|
||||
return total - len(en), len(en)
|
||||
return half, total - half
|
||||
|
||||
|
||||
def build_message(tag: str, name: str, url: str, body: str) -> str:
|
||||
zh, en = extract_sections(body)
|
||||
zh = strip_noise(zh)
|
||||
en = strip_noise(en)
|
||||
|
||||
zh_limit, en_limit = _budget(zh, en, BODY_BUDGET)
|
||||
zh = truncate(zh, zh_limit)
|
||||
en = truncate(en, en_limit) if en else ""
|
||||
|
||||
title = md_escape(name or tag)
|
||||
header = f"🚀 *{title} 已发布 / Released*"
|
||||
parts = [header, "", md_render(zh)]
|
||||
if en:
|
||||
parts.extend(["", "__English__", "", md_render(en)])
|
||||
parts.extend(["", f"🔗 {md_escape(url)}"])
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def send(cfg: dict, text: str) -> None:
|
||||
payload = {
|
||||
"chat_id": cfg["chat_id"],
|
||||
"message_thread_id": cfg["message_thread_id"],
|
||||
"text": text,
|
||||
"parse_mode": "MarkdownV2",
|
||||
"disable_web_page_preview": False,
|
||||
}
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{TG_API}/bot{cfg['token']}/sendMessage",
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
die(f"Telegram network error: {e}")
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
die(f"Telegram API returned non-JSON ({resp.status_code}): {resp.text[:200]!r}")
|
||||
if resp.status_code != 200 or not data.get("ok"):
|
||||
die(f"Telegram API rejected ({resp.status_code}): {data}")
|
||||
print(f"ok: message_id={data['result'].get('message_id')}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
die("usage: send_release_to_telegram.py <tag>")
|
||||
cfg = load_config()
|
||||
rel = fetch_release(sys.argv[1])
|
||||
text = build_message(rel["tagName"], rel.get("name", ""), rel["url"], rel.get("body", ""))
|
||||
send(cfg, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: release
|
||||
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.
|
||||
---
|
||||
|
||||
@@ -17,7 +17,8 @@ description: Create a GitHub release for cloudflare_temp_email project. Use when
|
||||
```
|
||||
Sort by PR number ascending.
|
||||
4. **Compose release body**: Follow the template in [references/release-template.md](references/release-template.md). Key rules:
|
||||
- Copy changelog sections verbatim (Features, Bug Fixes, Testing, Improvements). Omit empty sections.
|
||||
- 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.
|
||||
43
.claude/skills/cf-temp-mail-upgrade-dependencies/SKILL.md
Normal file
43
.claude/skills/cf-temp-mail-upgrade-dependencies/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: cf-temp-mail-upgrade-dependencies
|
||||
description: Upgrade npm dependencies across all sub-packages of the project. Use when the user asks to upgrade/update dependencies, bump deps, refresh lockfiles, or update wrangler. Runs pnpm upgrades on frontend/, worker/, pages/, and vitepress-docs/.
|
||||
---
|
||||
|
||||
# Upgrade Dependencies
|
||||
|
||||
Upgrade npm dependencies for the cloudflare_temp_email sub-packages.
|
||||
|
||||
## How to run
|
||||
|
||||
Execute the project-root script:
|
||||
|
||||
```bash
|
||||
bash scripts/update-dependencies.sh
|
||||
```
|
||||
|
||||
The script runs the following in order:
|
||||
|
||||
| Directory | Commands |
|
||||
|-----------|----------|
|
||||
| `frontend/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||
| `worker/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||
| `pages/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||
| `vitepress-docs/` | `pnpm up --latest` + `pnpm add -D wrangler@latest` |
|
||||
|
||||
Note: `vitepress-docs/` uses `--latest` (crosses semver ranges); other packages upgrade within ranges only.
|
||||
|
||||
## Post-upgrade checklist
|
||||
|
||||
1. Inspect `git diff` on `package.json` / `pnpm-lock.yaml` files for reasonable changes.
|
||||
2. Verify builds in each sub-package:
|
||||
- `cd frontend && pnpm build`
|
||||
- `cd worker && pnpm build && pnpm lint`
|
||||
- `cd vitepress-docs && pnpm build`
|
||||
3. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
|
||||
4. Commit with Conventional Commits format, e.g. `chore: upgrade dependencies`.
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Do not manually `pnpm add` each package instead of running the script.
|
||||
- Do not run `pnpm deploy` locally — deployments go through GitHub Actions.
|
||||
- Do not update CHANGELOG for routine dep bumps unless the user explicitly requests it.
|
||||
161
.claude/skills/cf-temp-mail-usage/SKILL.md
Normal file
161
.claude/skills/cf-temp-mail-usage/SKILL.md
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: cf-temp-mail-usage
|
||||
description: Read mails from a cloudflare_temp_email mailbox using a user-supplied Address JWT and API base URL. Use when the user (or an agent such as OpenClaw / Codex / Cursor) needs to list the inbox, fetch a specific message, or extract a verification code / magic link. Prefers the server-parsed endpoints so the agent gets subject/text/html/attachments directly. Does NOT handle mailbox creation — the user provides the JWT themselves.
|
||||
---
|
||||
|
||||
# Temp-Mail Read-Only Usage
|
||||
|
||||
Consume an existing mailbox. The user hands over the JWT (obtained in a browser after creating an address); the agent only reads mail.
|
||||
|
||||
## Inputs the user must provide
|
||||
|
||||
- `BASE` — API base URL, e.g. `https://mail.example.com` or the Worker's `*.workers.dev` host.
|
||||
- `JWT` — Address JWT. In the frontend it is stored in `localStorage` under the key `jwt` (raw string, no JSON wrap).
|
||||
- *(optional)* `SITE_PASSWORD` — only if the deployment enabled `x-custom-auth`.
|
||||
|
||||
If anything is missing, ask the user before making requests.
|
||||
|
||||
## Required headers
|
||||
|
||||
- `Authorization: Bearer <JWT>` — on every `/api/*` request.
|
||||
- `x-custom-auth: <SITE_PASSWORD>` — only when the site requires it.
|
||||
- `x-lang: en` or `zh` — optional, error-message language.
|
||||
|
||||
Do not send the Address JWT as `x-user-token` — that is a different JWT type and will yield `401 InvalidAddressCredentialMsg`.
|
||||
|
||||
## Endpoints (read-only)
|
||||
|
||||
| Task | Method | Path | Returns |
|
||||
| --------------------------- | ------ | ---------------------------------- | ----------------------------------------- |
|
||||
| Address info | GET | `/api/settings` | `{ address, send_balance }` |
|
||||
| **List parsed mails** | GET | `/api/parsed_mails?limit=&offset=` | `{ results: [parsedMail], count }` |
|
||||
| **Get one parsed mail** | GET | `/api/parsed_mail/:id` | `parsedMail` |
|
||||
| List raw mails | GET | `/api/mails?limit=&offset=` | `{ results: [{...,raw}], count }` |
|
||||
| Get one raw mail | GET | `/api/mail/:id` | `{ ..., raw }` |
|
||||
|
||||
`limit` 1–100, `offset` 0-based. On `429`, back off.
|
||||
|
||||
**Prefer the `parsed_*` endpoints.** They run the same `commonParseMail` (postal-mime) the frontend uses and return structured fields directly, so the agent does not need to ship a MIME parser.
|
||||
|
||||
`parsedMail` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"message_id": "<...>",
|
||||
"source": "noreply@foo.com",
|
||||
"to": "abc@yourdomain.com",
|
||||
"created_at": "2026-04-21 10:00:00",
|
||||
"sender": "Foo <noreply@foo.com>",
|
||||
"subject": "Your code is 123456",
|
||||
"text": "Your code is 123456\n",
|
||||
"html": "<p>Your code is <b>123456</b></p>",
|
||||
"attachments": [
|
||||
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Attachment **binary content is not included** in `parsed_*` responses — only metadata. If you need the bytes, fetch the raw mail via `/api/mail/:id` and parse it client-side (see below).
|
||||
|
||||
## Recipes
|
||||
|
||||
### 1. Smoke-test the JWT
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/settings" -H "Authorization: Bearer $JWT"
|
||||
# → { "address": "abc123@example.com", "send_balance": 0 }
|
||||
```
|
||||
|
||||
If this returns `401`, JWT is wrong / expired / mismatched with `BASE` — ask the user for a fresh one.
|
||||
|
||||
### 2. List the inbox (parsed)
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
### 3. Get one mail (parsed)
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/parsed_mail/<id>" -H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
### 4. Extract a verification code (end-to-end, parsed API)
|
||||
|
||||
```python
|
||||
import re, time, requests
|
||||
|
||||
BASE, JWT = "<BASE>", "<JWT>"
|
||||
H = {"Authorization": f"Bearer {JWT}"}
|
||||
|
||||
def wait_for_code(pattern=r"\b\d{4,8}\b", timeout=120, poll=3):
|
||||
deadline = time.time() + timeout
|
||||
seen = set()
|
||||
while time.time() < deadline:
|
||||
lst = requests.get(f"{BASE}/api/parsed_mails?limit=5&offset=0", headers=H).json()
|
||||
for m in lst.get("results", []):
|
||||
if m["id"] in seen: continue
|
||||
seen.add(m["id"])
|
||||
body = (m.get("subject") or "") + "\n" + (m.get("text") or "") + "\n" + (m.get("html") or "")
|
||||
hit = re.search(pattern, body)
|
||||
if hit:
|
||||
return hit.group(0)
|
||||
time.sleep(poll)
|
||||
raise TimeoutError("no matching mail within window")
|
||||
|
||||
print(wait_for_code())
|
||||
```
|
||||
|
||||
## Raw endpoints (fallback — only if you need attachment bytes or the original MIME)
|
||||
|
||||
`/api/mails` and `/api/mail/:id` return the gzip-resolved RFC822 source in `raw`. Parse it client-side.
|
||||
|
||||
### Node.js (postal-mime, pure JS)
|
||||
|
||||
```bash
|
||||
npm i postal-mime
|
||||
```
|
||||
|
||||
```js
|
||||
import PostalMime from 'postal-mime';
|
||||
|
||||
const mail = await (await fetch(`${BASE}/api/mail/${id}`, {
|
||||
headers: { Authorization: `Bearer ${JWT}` },
|
||||
})).json();
|
||||
const parsed = await PostalMime.parse(mail.raw);
|
||||
// parsed.subject / parsed.from / parsed.text / parsed.html
|
||||
// parsed.attachments[i].content is a Uint8Array
|
||||
```
|
||||
|
||||
### Python (stdlib, no deps)
|
||||
|
||||
```python
|
||||
import email, requests
|
||||
from email import policy
|
||||
|
||||
r = requests.get(f"{BASE}/api/mail/{mid}", headers={"Authorization": f"Bearer {JWT}"}).json()
|
||||
msg = email.message_from_string(r["raw"], policy=policy.default)
|
||||
subject = msg["subject"]
|
||||
text = (msg.get_body(preferencelist=("plain",)) or None) and msg.get_body(preferencelist=("plain",)).get_content()
|
||||
html = (msg.get_body(preferencelist=("html",)) or None) and msg.get_body(preferencelist=("html",)).get_content()
|
||||
for part in msg.iter_attachments():
|
||||
name, mime, data = part.get_filename(), part.get_content_type(), part.get_content()
|
||||
```
|
||||
|
||||
The frontend's reference implementation is `frontend/src/utils/email-parser.js` — tries `mail-parser-wasm` first, falls back to `postal-mime`. The server uses `postal-mime` only.
|
||||
|
||||
## Polling discipline
|
||||
|
||||
- Start at `poll=3s`, exponential backoff capped at 10s.
|
||||
- Dedupe by mail `id`.
|
||||
- Never poll faster than once per second.
|
||||
- Respect `429` — sleep and retry.
|
||||
|
||||
## Common errors
|
||||
|
||||
- `401 InvalidAddressCredentialMsg` — JWT wrong/expired/sent via wrong header. Ask the user for a fresh JWT.
|
||||
- `401 CustomAuthPasswordMsg` — site requires `x-custom-auth`; attach `SITE_PASSWORD`.
|
||||
- `400 InvalidLimitMsg` / `InvalidOffsetMsg` — `limit` must be 1..100, `offset ≥ 0`.
|
||||
- `429` — rate limited; back off.
|
||||
56
.claude/skills/cf-temp-mail-version-upgrade/SKILL.md
Normal file
56
.claude/skills/cf-temp-mail-version-upgrade/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: cf-temp-mail-version-upgrade
|
||||
description: Upgrade the project version number. Use when the user asks to bump the version, upgrade the version, or prepare a new release version. Supports major, minor, and patch upgrades.
|
||||
---
|
||||
|
||||
# Version Upgrade
|
||||
|
||||
Upgrade the version number of the cloudflare_temp_email project.
|
||||
|
||||
## Files to modify
|
||||
|
||||
1. `frontend/package.json` — `version` field
|
||||
2. `worker/package.json` — `version` field
|
||||
3. `worker/src/constants.ts` — `VERSION` constant (format: `VERSION: 'v' + '1.4.0'`)
|
||||
4. `pages/package.json` — `version` field
|
||||
5. `vitepress-docs/package.json` — `version` field
|
||||
6. `CHANGELOG.md` — add new version placeholder
|
||||
7. `CHANGELOG_EN.md` — add new version placeholder (English)
|
||||
|
||||
## Upgrade workflow
|
||||
|
||||
1. Read `frontend/package.json` to get the current version.
|
||||
2. Compute the new version based on the upgrade type:
|
||||
- major: 1.3.0 → 2.0.0
|
||||
- minor: 1.3.0 → 1.4.0
|
||||
- patch: 1.3.0 → 1.3.1
|
||||
3. Update the `version` field in every `package.json` listed above.
|
||||
4. Update the `VERSION` constant in `worker/src/constants.ts`.
|
||||
5. Insert a new version placeholder at the top of `CHANGELOG.md`.
|
||||
6. Insert a new version placeholder at the top of `CHANGELOG_EN.md`.
|
||||
|
||||
## CHANGELOG format
|
||||
|
||||
In `CHANGELOG.md`, insert before the existing `## v{OLD_VERSION}(main)` line (i.e. right after the closing `</p>` of the language-switch link):
|
||||
|
||||
```markdown
|
||||
## v{VERSION}(main)
|
||||
|
||||
### Features
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
```
|
||||
|
||||
`CHANGELOG_EN.md` uses the same format.
|
||||
|
||||
## Commit message format
|
||||
|
||||
```
|
||||
feat: upgrade version to v{VERSION}
|
||||
|
||||
- Update version number to {VERSION} in all package.json files
|
||||
- Add v{VERSION} placeholder in CHANGELOG.md
|
||||
```
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
name: version-upgrade
|
||||
description: 升级项目版本号。当用户要求升级版本、更新版本号、发布新版本时使用此 skill。支持 major(主版本)、minor(次版本)、patch(补丁版本)三种升级方式。
|
||||
---
|
||||
|
||||
# Version Upgrade
|
||||
|
||||
升级 cloudflare_temp_email 项目版本号。
|
||||
|
||||
## 需要修改的文件
|
||||
|
||||
1. `frontend/package.json` - version 字段
|
||||
2. `worker/package.json` - version 字段
|
||||
3. `worker/src/constants.ts` - VERSION 常量(格式:`VERSION: 'v' + '1.4.0'`)
|
||||
4. `pages/package.json` - version 字段
|
||||
5. `vitepress-docs/package.json` - version 字段
|
||||
6. `CHANGELOG.md` - 添加新版本占位符
|
||||
7. `CHANGELOG_EN.md` - 添加新版本占位符(英文)
|
||||
|
||||
## 版本升级流程
|
||||
|
||||
1. 读取 `frontend/package.json` 获取当前版本号
|
||||
2. 根据升级类型计算新版本号:
|
||||
- major: 1.3.0 → 2.0.0
|
||||
- minor: 1.3.0 → 1.4.0
|
||||
- patch: 1.3.0 → 1.3.1
|
||||
3. 更新所有 package.json 文件中的 version 字段
|
||||
4. 在 CHANGELOG.md 顶部添加新版本占位符
|
||||
5. 在 CHANGELOG_EN.md 顶部添加新版本占位符
|
||||
|
||||
## CHANGELOG 格式
|
||||
|
||||
中文 (CHANGELOG.md) - 在 `## v{OLD_VERSION}(main)` 之前插入:
|
||||
```markdown
|
||||
## v{VERSION}(main)
|
||||
|
||||
### Features
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
```
|
||||
|
||||
英文 (CHANGELOG_EN.md) - 同样格式。
|
||||
|
||||
## 提交信息格式
|
||||
|
||||
```
|
||||
feat: upgrade version to v{VERSION}
|
||||
|
||||
- Update version number to {VERSION} in all package.json files
|
||||
- Add v{VERSION} placeholder in CHANGELOG.md
|
||||
```
|
||||
15
.github/workflows/backend_deploy.yaml
vendored
15
.github/workflows/backend_deploy.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
@@ -67,11 +67,12 @@ jobs:
|
||||
if [ "$debug_mode" = "true" ]; then
|
||||
pnpm run deploy
|
||||
else
|
||||
output=$(pnpm run deploy 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
code=$?
|
||||
echo "Command failed with exit code $code"
|
||||
exit $code
|
||||
if pnpm run deploy >/dev/null 2>&1; then
|
||||
echo "Deploy succeeded"
|
||||
else
|
||||
code=$?
|
||||
echo "Command failed with exit code $code"
|
||||
exit "$code"
|
||||
fi
|
||||
fi
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
@@ -79,4 +80,4 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
# ✅ 将 secret 映射到环境变量中
|
||||
WRANGLER_TOML_CONTENT: ${{ secrets.BACKEND_TOML }}
|
||||
WRANGLER_TOML_CONTENT: ${{ secrets.BACKEND_TOML }}
|
||||
|
||||
54
.github/workflows/docs_deploy.yml
vendored
54
.github/workflows/docs_deploy.yml
vendored
@@ -1,16 +1,19 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "vitepress-docs/**"
|
||||
tags:
|
||||
- "*"
|
||||
workflow_run:
|
||||
workflows: ["Tag Build CI"]
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v'))
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -18,47 +21,30 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: check github release done
|
||||
run: |
|
||||
for ((attempt=1; attempt<=10; attempt++)); do
|
||||
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then
|
||||
echo "frontend.zip found."
|
||||
break
|
||||
else
|
||||
if [ $attempt -eq 10 ]; then
|
||||
echo "Exceeded maximum retries. frontend.zip not found."
|
||||
else
|
||||
echo "frontend.zip not found. Retrying in 30 seconds..."
|
||||
sleep 30
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Deploy Docs for ${{github.ref_name}}
|
||||
run: |
|
||||
cd vitepress-docs/
|
||||
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
||||
pnpm install --no-frozen-lockfile
|
||||
if [[ ${{github.ref}} == refs/tags/* ]]; then
|
||||
export TAG_NAME=${{github.ref_name}}
|
||||
else
|
||||
export TAG_NAME=$(git describe --tags --abbrev=0)
|
||||
fi
|
||||
echo "Deploying docs for tag $TAG_NAME"
|
||||
pnpm run deploy
|
||||
- name: Deploy Docs
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
|
||||
4
.github/workflows/frontend_deploy.yaml
vendored
4
.github/workflows/frontend_deploy.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
|
||||
13
.github/workflows/sync.yaml
vendored
13
.github/workflows/sync.yaml
vendored
@@ -12,14 +12,7 @@ jobs:
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: dreamhunter2333/cloudflare_temp_email
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
test_mode: false
|
||||
run: gh repo sync ${{ github.repository }} --source dreamhunter2333/cloudflare_temp_email --branch main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
37
.github/workflows/tag_build.yml
vendored
37
.github/workflows/tag_build.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend
|
||||
path: frontend/frontend.zip
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: cd frontend/dist/ && zip -r telegram-frontend.zip * && mv telegram-frontend.zip ../
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: telegram-frontend
|
||||
path: frontend/telegram-frontend.zip
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
@@ -101,13 +101,13 @@ jobs:
|
||||
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
|
||||
|
||||
- name: Upload worker.js
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: worker-js
|
||||
path: worker/worker.js
|
||||
|
||||
- name: Upload wasm worker
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: worker-wasm
|
||||
path: worker/worker-with-wasm-mail-parser.zip
|
||||
@@ -118,16 +118,21 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
artifacts/frontend/frontend.zip
|
||||
artifacts/telegram-frontend/telegram-frontend.zip
|
||||
artifacts/worker-js/worker.js
|
||||
artifacts/worker-wasm/worker-with-wasm-mail-parser.zip
|
||||
- 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 }}
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -2,17 +2,78 @@
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">🇨🇳 中文</a> |
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.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
|
||||
|
||||
@@ -22,10 +83,13 @@
|
||||
|
||||
### 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 三个模板
|
||||
|
||||
@@ -2,17 +2,78 @@
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">🇨🇳 中文</a> |
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.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
|
||||
|
||||
@@ -22,10 +83,13 @@
|
||||
|
||||
### 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
|
||||
|
||||
18
README.md
18
README.md
@@ -109,6 +109,7 @@
|
||||
|
||||
- [x] 使用 `rust wasm` 解析邮件,解析速度快,几乎所有邮件都能解析,node 的解析模块解析邮件失败的邮件,rust wasm 也能解析成功
|
||||
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
|
||||
- [x] 支持为指定基础域名创建随机二级域名邮箱地址,更适合收件隔离场景
|
||||
- [x] 支持发送邮件,支持 `DKIM` 验证
|
||||
- [x] 支持 `SMTP` 和 `Resend` 等多种发送方式
|
||||
- [x] 增加查看 `附件` 功能,支持附件图片显示
|
||||
@@ -149,9 +150,26 @@
|
||||
- [x] Webhook 支持,消息推送集成
|
||||
- [x] 支持 `CF Turnstile` 人机验证
|
||||
- [x] 限流配置,防止滥用
|
||||
- [x] **Agent 友好**:提供服务端解析的 `/api/parsed_mails` / `/api/parsed_mail/:id`,配合仓库内的 `cf-temp-mail-usage` skill,OpenClaw / Codex / Cursor 等 AI agent 可直接使用用户提供的 JWT 读取验证码 / 链接,无需在客户端引入 MIME 解析器
|
||||
|
||||
</details>
|
||||
|
||||
## 给 AI Agent 使用:`cf-temp-mail-usage` skill
|
||||
|
||||
仓库内置一个只读 skill:`.claude/skills/cf-temp-mail-usage/`,让 AI agent 用用户提供的 `Address JWT + API 地址`直接消费邮箱(列出邮件 / 取单封 / 轮询验证码),规避前端创建邮箱时的 Turnstile 人机验证。
|
||||
|
||||
安装到当前项目的 Claude Code:
|
||||
|
||||
```bash
|
||||
# 方式 1:degit 拷贝子目录
|
||||
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage .claude/skills/cf-temp-mail-usage
|
||||
|
||||
# 方式 2:安装到全局
|
||||
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage ~/.claude/skills/cf-temp-mail-usage
|
||||
```
|
||||
|
||||
细节见 [.claude/skills/cf-temp-mail-usage/SKILL.md](.claude/skills/cf-temp-mail-usage/SKILL.md)。
|
||||
|
||||
## 技术架构
|
||||
|
||||
<details>
|
||||
|
||||
18
README_EN.md
18
README_EN.md
@@ -109,6 +109,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
- [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
|
||||
@@ -149,9 +150,26 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [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>
|
||||
|
||||
2
db/2026-04-03-raw-blob.sql
Normal file
2
db/2026-04-03-raw-blob.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add raw_blob BLOB column for gzip-compressed email storage
|
||||
ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;
|
||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
raw TEXT,
|
||||
raw_blob BLOB,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -11,7 +11,8 @@ RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falli
|
||||
|
||||
COPY worker/src/ src/
|
||||
COPY worker/tsconfig.json ./
|
||||
COPY e2e/fixtures/wrangler.toml.e2e wrangler.toml
|
||||
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
||||
COPY ${WRANGLER_TOML} wrangler.toml
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
|
||||
@@ -20,6 +20,76 @@ services:
|
||||
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: ..
|
||||
@@ -73,6 +143,10 @@ services:
|
||||
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
|
||||
@@ -85,6 +159,14 @@ services:
|
||||
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:
|
||||
|
||||
@@ -2,6 +2,10 @@ 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';
|
||||
@@ -16,7 +20,7 @@ export async function createTestAddress(
|
||||
ctx: APIRequestContext,
|
||||
name: string,
|
||||
domain: string = TEST_DOMAIN
|
||||
): Promise<{ jwt: string; address: string }> {
|
||||
): 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 },
|
||||
@@ -25,7 +29,7 @@ export async function createTestAddress(
|
||||
throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return { jwt: body.jwt, address: body.address };
|
||||
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,8 +183,9 @@ export function onMailpitMessage(
|
||||
|
||||
/**
|
||||
* Request send mail access for an address.
|
||||
* Must be called before sending mail — creates the address_sender row
|
||||
* with the DEFAULT_SEND_BALANCE configured in the worker.
|
||||
* 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,
|
||||
@@ -194,6 +199,62 @@ export async function requestSendAccess(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
34
e2e/fixtures/wrangler.toml.e2e.env-off
Normal file
34
e2e/fixtures/wrangler.toml.e2e.env-off
Normal file
@@ -0,0 +1,34 @@
|
||||
name = "cloudflare_temp_email_env_off"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false
|
||||
JWT_SECRET = "e2e-test-secret-key-env-off"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = false
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-env-off"
|
||||
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
||||
34
e2e/fixtures/wrangler.toml.e2e.gzip
Normal file
34
e2e/fixtures/wrangler.toml.e2e.gzip
Normal file
@@ -0,0 +1,34 @@
|
||||
name = "cloudflare_temp_email_gzip"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
ENABLE_MAIL_GZIP = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-gzip"
|
||||
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
|
||||
38
e2e/fixtures/wrangler.toml.e2e.send-mail-domain
Normal file
38
e2e/fixtures/wrangler.toml.e2e.send-mail-domain
Normal file
@@ -0,0 +1,38 @@
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
send_email = [
|
||||
{ name = "SEND_MAIL" },
|
||||
]
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
SEND_MAIL_DOMAINS = ["test.example.com"]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email"
|
||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||
32
e2e/package-lock.json
generated
32
e2e/package-lock.json
generated
@@ -6,8 +6,8 @@
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.2.12",
|
||||
"nodemailer": "^8.0.1"
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
@@ -129,22 +129,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.12.tgz",
|
||||
"integrity": "sha512-UX8qCKXZk2xExe/x8KPTSbhROdtUGP13bSLSjT9Sb3YwGuryD4aFNlGhbWBW5B1GtgHMRxVv9yvl61RqXgIQtQ==",
|
||||
"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.7",
|
||||
"libmime": "5.3.8",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.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",
|
||||
@@ -191,9 +203,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"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"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.2.12",
|
||||
"nodemailer": "^8.0.1"
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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({
|
||||
@@ -16,6 +17,13 @@ export default defineConfig({
|
||||
baseURL: WORKER_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'api-gzip',
|
||||
testDir: './tests/api-gzip',
|
||||
use: {
|
||||
baseURL: WORKER_GZIP_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtp-proxy',
|
||||
testDir: './tests/smtp-proxy',
|
||||
|
||||
@@ -14,6 +14,51 @@ for i in $(seq 1 60); do
|
||||
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
|
||||
@@ -44,5 +89,26 @@ 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 "$@"
|
||||
|
||||
242
e2e/tests/api-gzip/mail-gzip.spec.ts
Normal file
242
e2e/tests/api-gzip/mail-gzip.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_GZIP_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
/**
|
||||
* These tests run against a worker instance with ENABLE_MAIL_GZIP=true.
|
||||
* They verify gzip-compressed storage and backward-compatible reading.
|
||||
*/
|
||||
|
||||
// Helper: create address on the gzip worker
|
||||
async function createGzipAddress(ctx: any, name: string) {
|
||||
const uniqueName = `${name}${Date.now()}`;
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/api/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
|
||||
}
|
||||
|
||||
// Helper: seed mail via receiveMail (goes through email() handler → gzip compression)
|
||||
async function receiveGzipMail(
|
||||
ctx: any, address: string,
|
||||
opts: { subject?: string; html?: string; text?: string; from?: string }
|
||||
) {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Test Email';
|
||||
const boundary = `----E2E${Date.now()}`;
|
||||
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
|
||||
const textPart = opts.text || 'Hello from E2E';
|
||||
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
textPart,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/html; charset=utf-8`,
|
||||
``,
|
||||
htmlPart,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
if (!body.success) throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
|
||||
}
|
||||
|
||||
// Helper: seed mail via seedMail (direct INSERT → plaintext raw, no gzip)
|
||||
async function seedPlaintextMail(
|
||||
ctx: any, address: string,
|
||||
opts: { subject?: string; text?: string; from?: string }
|
||||
) {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Plaintext Mail';
|
||||
const messageId = `<e2e-plain-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
opts.text || 'Hello plaintext from E2E',
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
|
||||
data: { address, source: from, raw, message_id: messageId },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Helper: delete address on gzip worker
|
||||
async function deleteGzipAddress(ctx: any, jwt: string) {
|
||||
await ctx.delete(`${WORKER_GZIP_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Mail Gzip Storage', () => {
|
||||
test.beforeEach(() => {
|
||||
test.skip(!WORKER_GZIP_URL, 'WORKER_GZIP_URL not set — skipping gzip tests');
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable in list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-list');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip List Test',
|
||||
text: 'compressed content here',
|
||||
});
|
||||
|
||||
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].raw).toContain('Gzip List Test');
|
||||
expect(results[0].raw).toContain('compressed content here');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable in detail', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-detail');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip Detail Test',
|
||||
html: '<b>bold gzip</b>',
|
||||
});
|
||||
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${mailId}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.raw).toContain('Gzip Detail Test');
|
||||
expect(mail.raw).toContain('<b>bold gzip</b>');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
|
||||
try {
|
||||
// 1. Direct INSERT plaintext (simulates pre-gzip data)
|
||||
await seedPlaintextMail(request, address, {
|
||||
subject: 'Old Plaintext Mail',
|
||||
text: 'legacy plain content',
|
||||
});
|
||||
|
||||
// 2. receiveMail → goes through email() handler → gzip compressed
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'New Gzip Mail',
|
||||
text: 'new compressed content',
|
||||
});
|
||||
|
||||
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
// Both mails should have readable raw content
|
||||
const subjects = results.map((r: any) => r.raw);
|
||||
expect(subjects.some((r: string) => r.includes('Old Plaintext Mail'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('New Gzip Mail'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('legacy plain content'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('new compressed content'))).toBe(true);
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin internal mail (sendAdminInternalMail) is gzip-compressed and readable', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-mail');
|
||||
try {
|
||||
// 1. Request send access → creates address_sender row
|
||||
const reqAccessRes = await request.post(`${WORKER_GZIP_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(reqAccessRes.ok()).toBe(true);
|
||||
|
||||
// 2. Get address_sender id
|
||||
const senderListRes = await request.get(
|
||||
`${WORKER_GZIP_URL}/admin/address_sender?limit=10&offset=0&address=${encodeURIComponent(address)}`,
|
||||
);
|
||||
expect(senderListRes.ok()).toBe(true);
|
||||
const senderList = await senderListRes.json();
|
||||
expect(senderList.results.length).toBeGreaterThanOrEqual(1);
|
||||
const senderId = senderList.results[0].id;
|
||||
|
||||
// 3. Update send access via admin API → triggers sendAdminInternalMail
|
||||
const updateRes = await request.post(`${WORKER_GZIP_URL}/admin/address_sender`, {
|
||||
data: { address, address_id: senderId, balance: 99, enabled: true },
|
||||
});
|
||||
expect(updateRes.ok()).toBe(true);
|
||||
|
||||
// 4. Verify the internal mail is readable
|
||||
const mailsRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const { results } = await mailsRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// mimetext base64-encodes the Subject header, so match on body content instead
|
||||
const internalMail = results.find((m: any) => m.raw?.includes('balance: 99'));
|
||||
expect(internalMail).toBeDefined();
|
||||
expect(internalMail.raw).toContain('admin@internal');
|
||||
expect(internalMail.raw).toContain('balance: 99');
|
||||
expect(internalMail).not.toHaveProperty('raw_blob');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('raw_blob field is not exposed in API response', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-noblob');
|
||||
try {
|
||||
await receiveGzipMail(request, address, { subject: 'No Blob Leak' });
|
||||
|
||||
// Check list response
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0]).not.toHaveProperty('raw_blob');
|
||||
|
||||
// Check detail response
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${results[0].id}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const mail = await detailRes.json();
|
||||
expect(mail).not.toHaveProperty('raw_blob');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, requestSendAccess } from '../../fixtures/test-helpers';
|
||||
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Address Lifecycle', () => {
|
||||
test('create address, request send access, fetch settings, then delete', async ({ request }) => {
|
||||
test('create address, auto-init send balance via settings, then delete', async ({ request }) => {
|
||||
// Create address
|
||||
const { jwt, address } = await createTestAddress(request, 'lifecycle-test');
|
||||
const { jwt, address, address_id } = await createTestAddress(request, 'lifecycle-test');
|
||||
expect(address).toContain('@' + TEST_DOMAIN);
|
||||
expect(jwt).toBeTruthy();
|
||||
expect(address_id).toBeGreaterThan(0);
|
||||
|
||||
// Request send access (creates address_sender row with DEFAULT_SEND_BALANCE)
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
// Fetch address settings — balance should match DEFAULT_SEND_BALANCE=10
|
||||
// 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}` },
|
||||
});
|
||||
|
||||
48
e2e/tests/api/admin-address-query.spec.ts
Normal file
48
e2e/tests/api/admin-address-query.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN, createTestAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
// Regression tests for #956: long admin search queries must not trigger
|
||||
// D1's "LIKE or GLOB pattern too complex" error.
|
||||
test.describe('Admin Address Query (#956)', () => {
|
||||
test('short query (subdomain fragment) returns matching address via LIKE', async ({ request }) => {
|
||||
const created = await createTestAddress(request, 'q956short');
|
||||
const fragment = created.address.split('@')[0].slice(0, 8);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { limit: '20', offset: '0', query: fragment },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
const names: string[] = body.results.map((r: any) => r.name);
|
||||
expect(names).toContain(created.address);
|
||||
});
|
||||
|
||||
test('long query (>50-byte pattern) does not crash with D1 LIKE error', async ({ request }) => {
|
||||
const longQuery = 'a48r893s@5hx7zb.nationalgeographic.algomindtrade.com';
|
||||
expect(new TextEncoder().encode(`%${longQuery}%`).length).toBeGreaterThan(50);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { limit: '20', offset: '0', query: longQuery },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
expect(body.results.length).toBe(0);
|
||||
expect(body.count).toBe(0);
|
||||
});
|
||||
|
||||
test('long query also works for /admin/users', async ({ request }) => {
|
||||
const longQuery = 'no-such-user-' + 'x'.repeat(40) + `@${TEST_DOMAIN}`;
|
||||
expect(new TextEncoder().encode(`%${longQuery}%`).length).toBeGreaterThan(50);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/users`, {
|
||||
params: { limit: '20', offset: '0', query: longQuery },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
expect(body.results.length).toBe(0);
|
||||
expect(body.count).toBe(0);
|
||||
});
|
||||
});
|
||||
19
e2e/tests/api/admin-new-address.spec.ts
Normal file
19
e2e/tests/api/admin-new-address.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Admin New Address', () => {
|
||||
test('should return address_id in response', async ({ request }) => {
|
||||
const uniqueName = `admin-test${Date.now()}`;
|
||||
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.address).toContain('@' + TEST_DOMAIN);
|
||||
expect(body.jwt).toBeTruthy();
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
expect(typeof body.address_id).toBe('number');
|
||||
});
|
||||
});
|
||||
274
e2e/tests/api/ip-whitelist.spec.ts
Normal file
274
e2e/tests/api/ip-whitelist.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||
|
||||
const RESET_SETTINGS = {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
};
|
||||
|
||||
test.describe('IP Whitelist Settings', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: RESET_SETTINGS,
|
||||
});
|
||||
});
|
||||
|
||||
test('get default IP whitelist settings returns disabled with empty list', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enableWhitelist).toBeFalsy();
|
||||
expect(settings.whitelist).toEqual([]);
|
||||
});
|
||||
|
||||
test('save and retrieve IP whitelist settings', async ({ request }) => {
|
||||
// Save whitelist settings
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: ['1.2.3.4', '^192\\.168\\.1\\.\\d+$'],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
const saveBody = await saveRes.json();
|
||||
expect(saveBody.success).toBe(true);
|
||||
|
||||
// Retrieve and verify
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enableWhitelist).toBe(true);
|
||||
expect(settings.whitelist).toEqual(['1.2.3.4', '^192\\.168\\.1\\.\\d+$']);
|
||||
});
|
||||
|
||||
test('whitelist rejects empty list when enabled', async ({ request }) => {
|
||||
// Note: Frontend blocks this, but backend allows it (empty list = ignored)
|
||||
// This test verifies backend behavior
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
// Backend accepts empty whitelist (it will be ignored at runtime)
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('whitelist validates array type', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: 'not-an-array', // Invalid type
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('whitelist enforces max size limit', async ({ request }) => {
|
||||
const largeList = Array.from({ length: 1001 }, (_, i) => `1.2.3.${i % 256}`);
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: largeList,
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
const body = await saveRes.text();
|
||||
expect(body).toContain('whitelist');
|
||||
expect(body).toContain('1000');
|
||||
});
|
||||
|
||||
test('backward compatibility: old frontend without whitelist fields', async ({ request }) => {
|
||||
// Simulate old frontend that doesn't send enableWhitelist/whitelist
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: true,
|
||||
blacklist: ['10.0.0.1'],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
// enableWhitelist and whitelist omitted
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
// Should succeed with defaults applied
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Verify defaults were applied
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enableWhitelist).toBe(false);
|
||||
expect(settings.whitelist).toEqual([]);
|
||||
});
|
||||
|
||||
test('whitelist sanitizes patterns (trims and removes empty)', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [' 1.2.3.4 ', '', ' ', '5.6.7.8'],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
// Empty strings should be filtered out, whitespace trimmed
|
||||
expect(settings.whitelist).toEqual(['1.2.3.4', '5.6.7.8']);
|
||||
});
|
||||
|
||||
test('whitelist rejects invalid regex pattern', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: { ...RESET_SETTINGS, whitelist: ['^[1.2.3.4$'] }, // invalid regex
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
expect(await saveRes.text()).toContain('whitelist');
|
||||
});
|
||||
|
||||
test('whitelist rejects non-string elements', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: { ...RESET_SETTINGS, whitelist: [1, null] },
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IP Whitelist Runtime Behavior', () => {
|
||||
test('whitelist with empty list allows requests (protection mode)', async ({ request }) => {
|
||||
// Enable whitelist with empty list
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to create address (rate-limited endpoint)
|
||||
// Should succeed because empty whitelist is ignored
|
||||
const res = await createTestAddress(request, 'whitelist-empty');
|
||||
expect(res.jwt).toBeTruthy();
|
||||
expect(res.address).toBeTruthy();
|
||||
});
|
||||
|
||||
test('whitelist blocks requests when IP does not match whitelist', async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
...RESET_SETTINGS,
|
||||
enableWhitelist: true,
|
||||
whitelist: ['1.2.3.4'],
|
||||
},
|
||||
});
|
||||
|
||||
// In e2e, cf-connecting-ip is absent → fail-closed → 403
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: `whitelist-block-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('IP');
|
||||
});
|
||||
|
||||
test('fingerprint blacklist blocks even when cf-connecting-ip is absent', async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
...RESET_SETTINGS,
|
||||
enabled: true,
|
||||
fingerprintBlacklist: ['blocked-fingerprint-123'],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
headers: { 'x-fingerprint': 'blocked-fingerprint-123' },
|
||||
data: { name: `fp-block-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
expect(await res.text()).toContain('fingerprint');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Reset whitelist to disabled after each test
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,108 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
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');
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, requestSendAccess, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
requestSendAccess,
|
||||
deleteAddress,
|
||||
deleteAddressSender,
|
||||
getAddressSender,
|
||||
updateAddressSender,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Send Access', () => {
|
||||
test('request send access succeeds once, duplicate returns 400', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, '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
|
||||
// First request — should succeed even if balance will also auto-init elsewhere.
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
// Verify balance is set via settings
|
||||
@@ -17,13 +25,119 @@ test.describe('Send Access', () => {
|
||||
const settings = await settingsRes.json();
|
||||
expect(settings.send_balance).toBe(10);
|
||||
|
||||
// Duplicate request — should fail with 400
|
||||
// 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.status()).toBe(400);
|
||||
const dupBody = await dupRes.text();
|
||||
expect(dupBody).toContain('Already');
|
||||
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
e2e/tests/api/send-mail-limit.spec.ts
Normal file
517
e2e/tests/api/send-mail-limit.spec.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { test, expect, APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
WORKER_URL_SEND_MAIL_DOMAIN,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD };
|
||||
|
||||
const DEFAULT_ACCOUNT_SETTINGS = {
|
||||
blockList: [],
|
||||
sendBlockList: [],
|
||||
verifiedAddressList: [],
|
||||
fromBlockList: [],
|
||||
noLimitSendAddressList: [],
|
||||
emailRuleSettings: {},
|
||||
addressCreationSettings: {},
|
||||
};
|
||||
|
||||
const DISABLED_LIMIT_CONFIG = {
|
||||
dailyEnabled: false,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: null as number | null,
|
||||
monthlyLimit: null as number | null,
|
||||
};
|
||||
|
||||
async function saveLimitConfig(
|
||||
request: APIRequestContext,
|
||||
sendMailLimitConfig: Record<string, unknown>
|
||||
) {
|
||||
return request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { ...DEFAULT_ACCOUNT_SETTINGS, sendMailLimitConfig },
|
||||
});
|
||||
}
|
||||
|
||||
async function resetLimitConfig(request: APIRequestContext) {
|
||||
const res = await saveLimitConfig(request, DISABLED_LIMIT_CONFIG);
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function sendOneMail(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
tag: string,
|
||||
opts: { expectDelivery?: boolean; lang?: string } = {}
|
||||
) {
|
||||
const { expectDelivery = true, lang } = opts;
|
||||
const subject = `limit-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${jwt}` };
|
||||
if (lang) headers['x-lang'] = lang;
|
||||
|
||||
let listener: ReturnType<typeof onMailpitMessage> | undefined;
|
||||
if (expectDelivery) {
|
||||
listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
}
|
||||
const res = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers,
|
||||
data: {
|
||||
from_name: 'Limit E2E',
|
||||
to_name: 'Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: `Limit test body ${tag}`,
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
return { res, listener, subject };
|
||||
}
|
||||
|
||||
async function probeLimitBaseline(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
config: {
|
||||
dailyEnabled: boolean;
|
||||
monthlyEnabled: boolean;
|
||||
dailyLimit: number | null;
|
||||
monthlyLimit: number | null;
|
||||
},
|
||||
subjectPrefix: string,
|
||||
maxProbeLimit: number = 50
|
||||
): Promise<number> {
|
||||
for (let limit = 1; limit <= maxProbeLimit; limit++) {
|
||||
const save = await saveLimitConfig(request, {
|
||||
...config,
|
||||
dailyLimit: config.dailyEnabled ? limit : null,
|
||||
monthlyLimit: config.monthlyEnabled ? limit : null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const probe = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: 'probe',
|
||||
to_name: '',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `${subjectPrefix}-${limit}-${Date.now()}`,
|
||||
content: 'probe',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
if (probe.ok()) {
|
||||
return limit;
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to probe send mail limit baseline within ${maxProbeLimit}`);
|
||||
}
|
||||
|
||||
async function probeDailyBaseline(
|
||||
request: APIRequestContext,
|
||||
jwt: string
|
||||
): Promise<number> {
|
||||
return probeLimitBaseline(request, jwt, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: null,
|
||||
}, 'probe-daily');
|
||||
}
|
||||
|
||||
async function probeMonthlyBaseline(
|
||||
request: APIRequestContext,
|
||||
jwt: string
|
||||
): Promise<number> {
|
||||
return probeLimitBaseline(request, jwt, {
|
||||
dailyEnabled: false,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: null,
|
||||
monthlyLimit: 1,
|
||||
}, 'probe-monthly');
|
||||
}
|
||||
|
||||
test.describe('Send Mail Limit', () => {
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
await resetLimitConfig(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await resetLimitConfig(request);
|
||||
});
|
||||
|
||||
test('save + read roundtrip preserves all fields', async ({ request }) => {
|
||||
const config = {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: 7,
|
||||
monthlyLimit: 1234,
|
||||
};
|
||||
const save = await saveLimitConfig(request, config);
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(read.ok()).toBe(true);
|
||||
const body = await read.json();
|
||||
expect(body.sendMailLimitConfig).toEqual(config);
|
||||
});
|
||||
|
||||
test('disabled flags coerce numeric limits to null', async ({ request }) => {
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: false,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: 10,
|
||||
monthlyLimit: 20,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
const body = await read.json();
|
||||
expect(body.sendMailLimitConfig).toEqual(DISABLED_LIMIT_CONFIG);
|
||||
});
|
||||
|
||||
test('minus one is accepted as unlimited', async ({ request }) => {
|
||||
const config = {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: -1,
|
||||
monthlyLimit: -1,
|
||||
};
|
||||
const save = await saveLimitConfig(request, config);
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
const body = await read.json();
|
||||
expect(body.sendMailLimitConfig).toEqual(config);
|
||||
});
|
||||
|
||||
test('invalid payloads rejected with 400', async ({ request }) => {
|
||||
const cases: Array<Record<string, unknown>> = [
|
||||
{ dailyEnabled: 'yes', monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
|
||||
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: -2, monthlyLimit: null },
|
||||
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: 1.5, monthlyLimit: null },
|
||||
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
|
||||
{ dailyEnabled: false, monthlyEnabled: true, dailyLimit: null, monthlyLimit: null },
|
||||
];
|
||||
for (const bad of cases) {
|
||||
const res = await saveLimitConfig(request, bad);
|
||||
expect(res.status(), `payload: ${JSON.stringify(bad)}`).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled limit allows unlimited sends', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-off');
|
||||
await requestSendAccess(request, jwt);
|
||||
await resetLimitConfig(request);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { res, listener } = await sendOneMail(request, jwt, `off${i}`);
|
||||
expect(res.ok()).toBe(true);
|
||||
await listener!.message;
|
||||
}
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('zero limit blocks sending immediately', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-zero');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: 0,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const { res } = await sendOneMail(request, jwt, 'zero', {
|
||||
expectDelivery: false,
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('Server daily send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('daily limit blocks once reached and returns English message', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-daily');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const allowed = 2;
|
||||
const limit = baseline + allowed;
|
||||
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: limit,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
for (let i = 0; i < allowed; i++) {
|
||||
const { res, listener } = await sendOneMail(request, jwt, `d${i}`);
|
||||
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||
await listener!.message;
|
||||
}
|
||||
|
||||
const { res: blocked } = await sendOneMail(request, jwt, 'd-over', {
|
||||
expectDelivery: false,
|
||||
});
|
||||
expect(blocked.ok()).toBe(false);
|
||||
const text = await blocked.text();
|
||||
expect(text).toContain('Server daily send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('monthly limit blocks once reached', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-monthly');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeMonthlyBaseline(request, jwt);
|
||||
const allowed = 2;
|
||||
const limit = baseline + allowed;
|
||||
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: false,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: null,
|
||||
monthlyLimit: limit,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
for (let i = 0; i < allowed; i++) {
|
||||
const { res, listener } = await sendOneMail(request, jwt, `m${i}`);
|
||||
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||
await listener!.message;
|
||||
}
|
||||
|
||||
const { res: blocked } = await sendOneMail(request, jwt, 'm-over', {
|
||||
expectDelivery: false,
|
||||
});
|
||||
expect(blocked.ok()).toBe(false);
|
||||
const text = await blocked.text();
|
||||
expect(text).toContain('Server monthly send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('zh-lang header returns Chinese daily limit message', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-zh');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: baseline,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const { res } = await sendOneMail(request, jwt, 'zh-over', {
|
||||
expectDelivery: false,
|
||||
lang: 'zh',
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('服务器今日发信次数已达上限');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('validation failures (missing subject) do not consume quota', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-noconsume');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: baseline + 1,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
// Empty subject → rejected by validation BEFORE the counter increments.
|
||||
const badRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: '',
|
||||
to_name: '',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: '',
|
||||
content: 'no subject',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(badRes.ok()).toBe(false);
|
||||
|
||||
const { res, listener } = await sendOneMail(request, jwt, 'after-bad');
|
||||
expect(res.ok()).toBe(true);
|
||||
await listener!.message;
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('both daily + monthly enabled: tighter daily limit wins', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-both');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: baseline,
|
||||
monthlyLimit: baseline + 10_000,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const { res } = await sendOneMail(request, jwt, 'both-over', {
|
||||
expectDelivery: false,
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('Server daily send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('minus one means unlimited at runtime', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-unlimited');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: -1,
|
||||
monthlyLimit: -1,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { res, listener } = await sendOneMail(request, jwt, `unl${i}`);
|
||||
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||
await listener!.message;
|
||||
}
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding returns 400 when SEND_MAIL binding is missing', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: 'admin@test.example.com',
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: 'no-binding',
|
||||
text: 'body',
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding returns 200 when domain is allowed', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: 'admin@test.example.com',
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: `send-mail-domain-ok-${Date.now()}`,
|
||||
text: 'body',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
expect(await res.json()).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding returns 400 when domain is not allowed', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: 'admin@blocked.example.com',
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: `send-mail-domain-blocked-${Date.now()}`,
|
||||
text: 'body',
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(400);
|
||||
expect(await res.text()).toContain('Please enable SEND_MAIL for this domain first');
|
||||
});
|
||||
|
||||
test('daily and monthly counters both increment on successful send', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'limit-both-inc');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const dailyBaseline = await probeDailyBaseline(request, jwt);
|
||||
const monthlyBaseline = await probeMonthlyBaseline(request, jwt);
|
||||
|
||||
// Give plenty of headroom so sends succeed.
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: true,
|
||||
dailyLimit: dailyBaseline + 10,
|
||||
monthlyLimit: monthlyBaseline + 10,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const { res, listener } = await sendOneMail(request, jwt, 'inc');
|
||||
expect(res.ok()).toBe(true);
|
||||
await listener!.message;
|
||||
|
||||
// Re-probe to confirm both counters moved forward after the successful send.
|
||||
const dailyAfter = await probeDailyBaseline(request, jwt);
|
||||
expect(dailyAfter).toBeGreaterThanOrEqual(dailyBaseline + 1);
|
||||
const monthlyAfter = await probeMonthlyBaseline(request, jwt);
|
||||
expect(monthlyAfter).toBeGreaterThanOrEqual(monthlyBaseline + 1);
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('admin /admin/send_mail also respects daily limit', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'limit-admin');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
// Probe via a user-facing send to establish baseline.
|
||||
const baseline = await probeDailyBaseline(request, jwt);
|
||||
const save = await saveLimitConfig(request, {
|
||||
dailyEnabled: true,
|
||||
monthlyEnabled: false,
|
||||
dailyLimit: baseline,
|
||||
monthlyLimit: null,
|
||||
});
|
||||
expect(save.ok()).toBe(true);
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/send_mail`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from_name: '',
|
||||
from_mail: address,
|
||||
to_name: '',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `admin-over-${Date.now()}`,
|
||||
content: 'admin blocked body',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('Server daily send quota has been reached');
|
||||
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
WORKER_URL,
|
||||
} from '../../fixtures/test-helpers';
|
||||
@@ -15,10 +14,6 @@ test.describe('Send Mail via SMTP', () => {
|
||||
|
||||
test('send HTML email and verify in Mailpit', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'sender-test');
|
||||
|
||||
// Must request send access before sending (creates address_sender row)
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const subject = `E2E Test ${Date.now()}`;
|
||||
const htmlContent = '<h1>Hello</h1><p>This is an <b>E2E test</b> email.</p>';
|
||||
|
||||
@@ -45,6 +40,14 @@ test.describe('Send Mail via SMTP', () => {
|
||||
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
e2e/tests/api/subdomain-create.spec.ts
Normal file
198
e2e/tests/api/subdomain-create.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
|
||||
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
||||
let originalCreateAddressStoredEnabled: boolean | undefined;
|
||||
let originalEnvOffStoredEnabled: boolean | undefined;
|
||||
|
||||
async function getAccountSettings(request: any, workerUrl: string) {
|
||||
const res = await request.get(`${workerUrl}/admin/account_settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function buildAccountSettingsPayload(
|
||||
current: any,
|
||||
addressCreationSettings?: { enableSubdomainMatch?: boolean | null },
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
blockList: current.blockList || [],
|
||||
sendBlockList: current.sendBlockList || [],
|
||||
verifiedAddressList: current.verifiedAddressList || [],
|
||||
fromBlockList: current.fromBlockList || [],
|
||||
noLimitSendAddressList: current.noLimitSendAddressList || [],
|
||||
emailRuleSettings: current.emailRuleSettings || {},
|
||||
...(typeof addressCreationSettings !== 'undefined'
|
||||
? { addressCreationSettings }
|
||||
: {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSubdomainMatchSetting(
|
||||
request: any,
|
||||
workerUrl: string,
|
||||
enableSubdomainMatch: boolean | null
|
||||
) {
|
||||
const current = await getAccountSettings(request, workerUrl);
|
||||
const res = await request.post(`${workerUrl}/admin/account_settings`, {
|
||||
data: buildAccountSettingsPayload(current, {
|
||||
enableSubdomainMatch,
|
||||
}),
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function restoreSubdomainMatchSetting(
|
||||
request: any,
|
||||
workerUrl: string,
|
||||
originalValue: boolean | undefined
|
||||
) {
|
||||
if (typeof originalValue === 'boolean') {
|
||||
await saveSubdomainMatchSetting(request, workerUrl, originalValue);
|
||||
return;
|
||||
}
|
||||
await saveSubdomainMatchSetting(request, workerUrl, null);
|
||||
}
|
||||
|
||||
test.describe('Create Address Subdomain Match', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const createAddressSettings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
originalCreateAddressStoredEnabled = createAddressSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||
|
||||
if (WORKER_URL_ENV_OFF) {
|
||||
const envOffSettings = await getAccountSettings(request, WORKER_URL_ENV_OFF);
|
||||
originalEnvOffStoredEnabled = envOffSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await restoreSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, originalCreateAddressStoredEnabled);
|
||||
if (WORKER_URL_ENV_OFF) {
|
||||
await restoreSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, originalEnvOffStoredEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin can clear override and return to env fallback', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, null);
|
||||
|
||||
const settings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
expect(settings.addressCreationSubdomainMatchStatus?.storedEnabled).toBeUndefined();
|
||||
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subenvfb${Date.now()}`, domain: SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('invalid addressCreationSettings payload does not partially persist earlier settings', async ({ request }) => {
|
||||
const current = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
const uniqueBlockedKeyword = `should-not-persist-${Date.now()}`;
|
||||
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/account_settings`, {
|
||||
data: buildAccountSettingsPayload(
|
||||
current,
|
||||
{ enableSubdomainMatch: 'invalid-value' as any },
|
||||
{
|
||||
blockList: [...(current.blockList || []), uniqueBlockedKeyword],
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const after = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
expect(after.blockList || []).toEqual(current.blockList || []);
|
||||
expect(after.addressCreationSubdomainMatchStatus?.storedEnabled).toBe(
|
||||
current.addressCreationSubdomainMatchStatus?.storedEnabled
|
||||
);
|
||||
});
|
||||
|
||||
test('persisted false still keeps exact match only', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const uniqueName = `subdomain-default-${Date.now()}`;
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||
|
||||
const adminName = `subdomain-admin-${Date.now()}`;
|
||||
const adminRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: adminName, domain: SUBDOMAIN },
|
||||
});
|
||||
expect(adminRes.ok()).toBe(true);
|
||||
const adminBody = await adminRes.json();
|
||||
expect(adminBody.address).toContain(`@${SUBDOMAIN}`);
|
||||
expect(adminBody.address_id).toBeGreaterThan(0);
|
||||
|
||||
const userName = `subdomain-user-${Date.now()}`;
|
||||
const userRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name: userName, domain: NESTED_SUBDOMAIN },
|
||||
});
|
||||
expect(userRes.ok()).toBe(true);
|
||||
const userBody = await userRes.json();
|
||||
expect(userBody.address).toContain(`@${NESTED_SUBDOMAIN}`);
|
||||
expect(userBody.address_id).toBeGreaterThan(0);
|
||||
|
||||
const mixedCaseRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subcase${Date.now()}`, domain: MIXED_CASE_SUBDOMAIN },
|
||||
});
|
||||
expect(mixedCaseRes.ok()).toBe(true);
|
||||
const mixedCaseBody = await mixedCaseRes.json();
|
||||
expect(mixedCaseBody.address).toContain(`@${SUBDOMAIN}`);
|
||||
|
||||
const invalidRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subinvalid${Date.now()}`, domain: INVALID_LOOKALIKE_DOMAIN },
|
||||
});
|
||||
expect(invalidRes.ok()).toBe(false);
|
||||
expect(await invalidRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidEmptyPrefixRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subempty${Date.now()}`, domain: INVALID_EMPTY_PREFIX_DOMAIN },
|
||||
});
|
||||
expect(invalidEmptyPrefixRes.ok()).toBe(false);
|
||||
expect(await invalidEmptyPrefixRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidEmptyLabelRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `sublabel${Date.now()}`, domain: INVALID_EMPTY_LABEL_DOMAIN },
|
||||
});
|
||||
expect(invalidEmptyLabelRes.ok()).toBe(false);
|
||||
expect(await invalidEmptyLabelRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidOverlongRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `sublong${Date.now()}`, domain: INVALID_OVERLONG_DOMAIN },
|
||||
});
|
||||
expect(invalidOverlongRes.ok()).toBe(false);
|
||||
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
||||
|
||||
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.5.0",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -22,39 +22,38 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.1.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
"@unhead/vue": "^2.1.12",
|
||||
"@unhead/vue": "^2.1.13",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.13.6",
|
||||
"dompurify": "^3.3.3",
|
||||
"axios": "^1.15.1",
|
||||
"dompurify": "^3.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"postal-mime": "^2.7.3",
|
||||
"postal-mime": "^2.7.4",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.30",
|
||||
"vue": "^3.5.32",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-i18n": "^11.3.2",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"jsdom": "^28.1.0",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-build": "^7.4.0",
|
||||
"workbox-window": "^7.4.0",
|
||||
"wrangler": "^4.72.0"
|
||||
"wrangler": "^4.83.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
1669
frontend/pnpm-lock.yaml
generated
1669
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
maxAddressLen: res["maxAddressLen"] || 30,
|
||||
needAuth: res["needAuth"] || false,
|
||||
defaultDomains: res["defaultDomains"] || [],
|
||||
randomSubdomainDomains: res["randomSubdomainDomains"] || [],
|
||||
domains: res["domains"].map((domain, index) => {
|
||||
return {
|
||||
label: domainLabels.length > index ? domainLabels[index] : domain,
|
||||
|
||||
@@ -28,6 +28,8 @@ export const useGlobalState = createGlobalState(
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
defaultDomains: [],
|
||||
/** @type {string[]} */
|
||||
randomSubdomainDomains: [],
|
||||
/** @type {Array<{label: string, value: string}>} */
|
||||
domains: [],
|
||||
copyright: 'Dream Hunter',
|
||||
|
||||
@@ -114,6 +114,8 @@ const selectedCount = computed(() => checkedRowKeys.value.length);
|
||||
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
|
||||
|
||||
const addressQuery = ref("")
|
||||
const sortBy = ref("")
|
||||
const sortOrder = ref("")
|
||||
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
@@ -290,10 +292,12 @@ const fetchData = async () => {
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
|
||||
+ (sortBy.value ? `&sort_by=${sortBy.value}` : "")
|
||||
+ (sortOrder.value ? `&sort_order=${sortOrder.value}` : "")
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
if (page.value === 1 || addressCount > 0) {
|
||||
count.value = addressCount ?? 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -301,29 +305,57 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const searchData = () => {
|
||||
if (page.value === 1) {
|
||||
fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSorterChange = (sorter) => {
|
||||
sortBy.value = sorter.columnKey || "";
|
||||
sortOrder.value = sorter.order || "";
|
||||
if (page.value === 1) {
|
||||
fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
type: 'selection'
|
||||
},
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
key: "id",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
key: "name",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
key: "created_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
key: "updated_at"
|
||||
key: "updated_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('source_meta'),
|
||||
key: "source_meta",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'source_meta' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
const val = row.source_meta;
|
||||
if (!val) return '';
|
||||
@@ -342,6 +374,8 @@ const columns = [
|
||||
{
|
||||
title: t('mail_count'),
|
||||
key: "mail_count",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'mail_count' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
@@ -368,6 +402,8 @@ const columns = [
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'send_count' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
@@ -497,7 +533,7 @@ const columns = [
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
@@ -560,8 +596,8 @@ onMounted(async () => {
|
||||
</n-modal>
|
||||
<n-input-group style="margin-bottom: 10px;">
|
||||
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
|
||||
@keydown.enter="fetchData" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
@keydown.enter="searchData" />
|
||||
<n-button @click="searchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
@@ -605,7 +641,7 @@ onMounted(async () => {
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
|
||||
:row-key="row => row.id" embedded />
|
||||
:row-key="row => row.id" remote @update:sorter="handleSorterChange" embedded />
|
||||
</div>
|
||||
|
||||
<!-- Multi-action progress modal -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, h } from 'vue';
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
|
||||
|
||||
@@ -21,6 +21,12 @@ const { t } = useI18n({
|
||||
send_address_block_list: 'Address Block Keywords for send email',
|
||||
noLimitSendAddressList: 'No Balance Limit Send Address List',
|
||||
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
|
||||
send_mail_limit: 'Send Mail Limit',
|
||||
send_mail_limit_tip: 'This applies to all send channels. Use -1 for unlimited and 0 to block sending.',
|
||||
send_mail_daily_limit: 'Daily Limit',
|
||||
send_mail_monthly_limit: 'Monthly Limit',
|
||||
send_mail_daily_limit_invalid: 'Daily limit must be an integer greater than or equal to -1',
|
||||
send_mail_monthly_limit_invalid: 'Monthly limit must be an integer greater than or equal to -1',
|
||||
fromBlockList: 'Block Keywords for receive email',
|
||||
block_receive_unknow_address_email: 'Block receive unknow address email',
|
||||
email_forwarding_config: 'Email Forwarding Configuration',
|
||||
@@ -46,6 +52,14 @@ const { t } = useI18n({
|
||||
regex_invalid: 'Invalid regex pattern',
|
||||
forward_address_required: 'Forward address is required',
|
||||
rule_index: 'Rule',
|
||||
create_address_subdomain_match: 'Allow Subdomain Suffix Match When Creating Address',
|
||||
create_address_subdomain_match_tip: 'Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.',
|
||||
create_address_subdomain_match_note: 'This is different from RANDOM_SUBDOMAIN_DOMAINS: this switch allows API callers to specify custom subdomains directly, while random subdomain only auto-generates one during creation.',
|
||||
create_address_subdomain_match_follow_env: 'Follow Environment Variable',
|
||||
create_address_subdomain_match_force_enable: 'Force Enable',
|
||||
create_address_subdomain_match_force_disable: 'Force Disable',
|
||||
create_address_subdomain_match_follow_env_note: 'Choosing "Follow Environment Variable" clears the admin override and returns to the unset state. The effective result is still controlled by the Worker env and the precedence rules.',
|
||||
create_address_subdomain_match_env_locked: 'Worker env ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH is currently false. The saved admin switch can be modified, but it will not take effect until env is enabled or removed.',
|
||||
},
|
||||
zh: {
|
||||
tip: '您可以手动输入以下多选输入框, 回车增加',
|
||||
@@ -57,6 +71,12 @@ const { t } = useI18n({
|
||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||
noLimitSendAddressList: '无余额限制发送地址列表',
|
||||
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
|
||||
send_mail_limit: '发信额度',
|
||||
send_mail_limit_tip: '对全部发信渠道生效。-1 表示无限,0 表示禁止发送。',
|
||||
send_mail_daily_limit: '每日额度',
|
||||
send_mail_monthly_limit: '每月额度',
|
||||
send_mail_daily_limit_invalid: '每日额度必须是大于等于 -1 的整数',
|
||||
send_mail_monthly_limit_invalid: '每月额度必须是大于等于 -1 的整数',
|
||||
fromBlockList: '接收邮件地址屏蔽关键词',
|
||||
block_receive_unknow_address_email: '禁止接收未知地址邮件',
|
||||
email_forwarding_config: '邮件转发配置',
|
||||
@@ -82,6 +102,14 @@ const { t } = useI18n({
|
||||
regex_invalid: '无效的正则表达式',
|
||||
forward_address_required: '转发地址不能为空',
|
||||
rule_index: '规则',
|
||||
create_address_subdomain_match: '创建邮箱时允许子域名后缀匹配',
|
||||
create_address_subdomain_match_tip: '仅影响 /api/new_address 和 /admin/new_address 的域名校验。例如开启后,foo.example.com 可以匹配已配置的基础域名 example.com。',
|
||||
create_address_subdomain_match_note: '这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名;随机子域名功能只是在创建时自动补一个随机子域名。',
|
||||
create_address_subdomain_match_follow_env: '跟随环境变量',
|
||||
create_address_subdomain_match_force_enable: '强制开启',
|
||||
create_address_subdomain_match_force_disable: '强制关闭',
|
||||
create_address_subdomain_match_follow_env_note: '选择“跟随环境变量”会清空后台覆盖,恢复为未设置状态;最终是否开启仍由 Worker env 和优先级规则决定。',
|
||||
create_address_subdomain_match_env_locked: '当前 Worker 环境变量 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH 为 false。后台开关仍可保存,但在 env 打开或移除前不会生效。',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -95,6 +123,44 @@ const emailRuleSettings = ref({
|
||||
blockReceiveUnknowAddressEmail: false,
|
||||
emailForwardingList: []
|
||||
})
|
||||
const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
|
||||
FOLLOW_ENV: 'follow_env',
|
||||
FORCE_ENABLE: 'force_enable',
|
||||
FORCE_DISABLE: 'force_disable'
|
||||
}
|
||||
const DEFAULT_SEND_MAIL_DAILY_LIMIT = 100
|
||||
const DEFAULT_SEND_MAIL_MONTHLY_LIMIT = 3000
|
||||
const addressCreationSubdomainMatchMode = ref(ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV)
|
||||
const sendMailDailyLimitEnabled = ref(false)
|
||||
const sendMailMonthlyLimitEnabled = ref(false)
|
||||
const sendMailDailyLimit = ref(DEFAULT_SEND_MAIL_DAILY_LIMIT)
|
||||
const sendMailMonthlyLimit = ref(DEFAULT_SEND_MAIL_MONTHLY_LIMIT)
|
||||
const addressCreationSubdomainMatchStatus = ref({
|
||||
envConfigured: false,
|
||||
envEnabled: false,
|
||||
storedEnabled: undefined,
|
||||
effectiveEnabled: false
|
||||
})
|
||||
const subdomainMatchEnvLocked = computed(() => {
|
||||
return addressCreationSubdomainMatchStatus.value.envConfigured
|
||||
&& !addressCreationSubdomainMatchStatus.value.envEnabled
|
||||
})
|
||||
const subdomainMatchModeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV,
|
||||
label: t('create_address_subdomain_match_follow_env')
|
||||
},
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE,
|
||||
label: t('create_address_subdomain_match_force_enable')
|
||||
},
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE,
|
||||
label: t('create_address_subdomain_match_force_disable')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const showEmailForwardingModal = ref(false)
|
||||
const emailForwardingList = ref([])
|
||||
@@ -246,8 +312,52 @@ const saveEmailForwardingConfig = () => {
|
||||
showEmailForwardingModal.value = false
|
||||
}
|
||||
|
||||
const getSubdomainMatchModeByStoredValue = (storedEnabled) => {
|
||||
if (storedEnabled === true) {
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE
|
||||
}
|
||||
if (storedEnabled === false) {
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE
|
||||
}
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const getSubdomainMatchPayloadValue = (mode) => {
|
||||
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE) {
|
||||
return true
|
||||
}
|
||||
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getSendMailLimitPayload = () => {
|
||||
return {
|
||||
dailyEnabled: sendMailDailyLimitEnabled.value,
|
||||
monthlyEnabled: sendMailMonthlyLimitEnabled.value,
|
||||
dailyLimit: sendMailDailyLimitEnabled.value ? sendMailDailyLimit.value : null,
|
||||
monthlyLimit: sendMailMonthlyLimitEnabled.value ? sendMailMonthlyLimit.value : null
|
||||
}
|
||||
}
|
||||
|
||||
const isValidSendMailLimit = (value) => {
|
||||
return Number.isInteger(value) && value >= -1
|
||||
}
|
||||
|
||||
const validateSendMailLimit = () => {
|
||||
if (sendMailDailyLimitEnabled.value && !isValidSendMailLimit(sendMailDailyLimit.value)) {
|
||||
message.error(t('send_mail_daily_limit_invalid'))
|
||||
return false
|
||||
}
|
||||
if (sendMailMonthlyLimitEnabled.value && !isValidSendMailLimit(sendMailMonthlyLimit.value)) {
|
||||
message.error(t('send_mail_monthly_limit_invalid'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchData = async ({ suppressErrorMessage = false } = {}) => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/account_settings`)
|
||||
addressBlockList.value = res.blockList || []
|
||||
@@ -259,33 +369,76 @@ const fetchData = async () => {
|
||||
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
|
||||
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
|
||||
}
|
||||
addressCreationSubdomainMatchStatus.value = {
|
||||
envConfigured: !!res.addressCreationSubdomainMatchStatus?.envConfigured,
|
||||
envEnabled: !!res.addressCreationSubdomainMatchStatus?.envEnabled,
|
||||
storedEnabled: typeof res.addressCreationSubdomainMatchStatus?.storedEnabled === 'boolean'
|
||||
? res.addressCreationSubdomainMatchStatus.storedEnabled
|
||||
: undefined,
|
||||
effectiveEnabled: !!res.addressCreationSubdomainMatchStatus?.effectiveEnabled
|
||||
}
|
||||
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
|
||||
addressCreationSubdomainMatchStatus.value.storedEnabled
|
||||
)
|
||||
const sendMailLimitConfig = res.sendMailLimitConfig
|
||||
sendMailDailyLimitEnabled.value = !!sendMailLimitConfig?.dailyEnabled
|
||||
sendMailMonthlyLimitEnabled.value = !!sendMailLimitConfig?.monthlyEnabled
|
||||
sendMailDailyLimit.value = sendMailDailyLimitEnabled.value
|
||||
? sendMailLimitConfig.dailyLimit
|
||||
: DEFAULT_SEND_MAIL_DAILY_LIMIT
|
||||
sendMailMonthlyLimit.value = sendMailMonthlyLimitEnabled.value
|
||||
? sendMailLimitConfig.monthlyLimit
|
||||
: DEFAULT_SEND_MAIL_MONTHLY_LIMIT
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
if (!suppressErrorMessage) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
throw 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({
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || [],
|
||||
fromBlockList: fromBlockList.value || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
||||
emailRuleSettings: emailRuleSettings.value,
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchData({ suppressErrorMessage: true })
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh account settings after save', error)
|
||||
message.warning(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
try {
|
||||
await fetchData();
|
||||
} catch {
|
||||
// 首次加载失败时,错误提示已经在 fetchData 内部统一处理,这里无需重复提示。
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -340,6 +493,35 @@ onMounted(async () => {
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('send_mail_limit')">
|
||||
<n-flex vertical style="width: 100%;">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-text>{{ t('send_mail_daily_limit') }}</n-text>
|
||||
<n-flex align="center">
|
||||
<n-switch v-model:value="sendMailDailyLimitEnabled" :round="false" />
|
||||
<n-input-number
|
||||
v-model:value="sendMailDailyLimit"
|
||||
:disabled="!sendMailDailyLimitEnabled"
|
||||
:min="-1"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-text>{{ t('send_mail_monthly_limit') }}</n-text>
|
||||
<n-flex align="center">
|
||||
<n-switch v-model:value="sendMailMonthlyLimitEnabled" :round="false" />
|
||||
<n-input-number
|
||||
v-model:value="sendMailMonthlyLimit"
|
||||
:disabled="!sendMailMonthlyLimitEnabled"
|
||||
:min="-1"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-text depth="3">
|
||||
{{ t('send_mail_limit_tip') }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('fromBlockList')">
|
||||
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')">
|
||||
<template #empty>
|
||||
@@ -352,6 +534,29 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('block_receive_unknow_address_email')">
|
||||
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('create_address_subdomain_match')">
|
||||
<n-flex vertical style="width: 100%;">
|
||||
<n-radio-group v-model:value="addressCreationSubdomainMatchMode">
|
||||
<n-space vertical size="small">
|
||||
<n-radio v-for="item in subdomainMatchModeOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_tip') }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_note') }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_follow_env_note') }}
|
||||
</n-text>
|
||||
<n-alert v-if="subdomainMatchEnvLocked" type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('create_address_subdomain_match_env_locked') }}
|
||||
</n-alert>
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('email_forwarding_config')">
|
||||
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
|
||||
</n-form-item-row>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
@@ -22,6 +22,8 @@ const { t } = useI18n({
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
addressPassword: 'Address Password',
|
||||
linkWithAddressCredential: 'Open to auto login email link',
|
||||
enableRandomSubdomain: 'Use Random Subdomain',
|
||||
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
@@ -33,11 +35,14 @@ const { t } = useI18n({
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
addressPassword: '地址密码',
|
||||
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
|
||||
enableRandomSubdomain: '启用随机子域名',
|
||||
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const enablePrefix = ref(true)
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const showReultModal = ref(false)
|
||||
@@ -45,6 +50,19 @@ const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false
|
||||
}
|
||||
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value)
|
||||
})
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const newEmail = async () => {
|
||||
if (!emailName.value || !emailDomain.value) {
|
||||
message.error(t('fillInAllFields'))
|
||||
@@ -55,6 +73,7 @@ const newEmail = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enablePrefix: enablePrefix.value,
|
||||
enableRandomSubdomain: enableRandomSubdomain.value,
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
})
|
||||
@@ -119,6 +138,14 @@ onMounted(async () => {
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading">
|
||||
{{ t('creatNewEmail') }}
|
||||
</n-button>
|
||||
|
||||
@@ -17,6 +17,12 @@ const { t } = useI18n({
|
||||
successTip: 'Save Success',
|
||||
enable_ip_blacklist: 'Enable IP Blacklist',
|
||||
enable_tip: 'Block IPs matching blacklist patterns from accessing rate-limited APIs',
|
||||
enable_ip_whitelist: 'Enable IP Whitelist (Strict)',
|
||||
enable_whitelist_tip: 'Strict mode: ONLY IPs matching the whitelist can access rate-limited APIs. All other IPs will be denied.',
|
||||
ip_whitelist: 'IP Whitelist Patterns',
|
||||
ip_whitelist_placeholder: 'Exact IP (e.g., 1.2.3.4) or anchored regex (e.g., ^192\\.168\\.1\\.\\d+$)',
|
||||
tip_whitelist: 'IP Whitelist: Strict allowlist — plain entries must be EXACT IP matches (no substring). Use anchored regex (^...$) for ranges. Whitelisted IPs skip blacklist checks.',
|
||||
whitelist_empty_warning: 'IP whitelist is enabled but the list is empty. This is ignored by the server to prevent lockout. Please add at least one entry before enabling.',
|
||||
ip_blacklist: 'IP Blacklist Patterns',
|
||||
ip_blacklist_placeholder: 'Enter pattern (e.g., 192.168.1 or ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN Organization Blacklist',
|
||||
@@ -40,6 +46,12 @@ const { t } = useI18n({
|
||||
successTip: '保存成功',
|
||||
enable_ip_blacklist: '启用 IP 黑名单',
|
||||
enable_tip: '阻止匹配黑名单的 IP 访问限流 API',
|
||||
enable_ip_whitelist: '启用 IP 白名单(严格模式)',
|
||||
enable_whitelist_tip: '严格模式:仅允许匹配白名单的 IP 访问限流 API,其他所有 IP 将被拒绝',
|
||||
ip_whitelist: 'IP 白名单匹配模式',
|
||||
ip_whitelist_placeholder: '精确 IP(如 1.2.3.4)或锚定正则(如 ^192\\.168\\.1\\.\\d+$)',
|
||||
tip_whitelist: 'IP 白名单: 严格放行名单——纯文本必须是精确 IP(不支持子串匹配), 批量放行请用锚定正则 ^...$. 命中白名单的 IP 将跳过黑名单检查.',
|
||||
whitelist_empty_warning: 'IP 白名单已启用但列表为空,服务端将忽略该开关以防止锁死。请先添加至少一条白名单条目再启用。',
|
||||
ip_blacklist: 'IP 黑名单匹配模式',
|
||||
ip_blacklist_placeholder: '输入匹配模式(例如:192.168.1 或 ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN 组织(运营商)黑名单',
|
||||
@@ -63,6 +75,8 @@ const enabled = ref(false)
|
||||
const ipBlacklist = ref([])
|
||||
const asnBlacklist = ref([])
|
||||
const fingerprintBlacklist = ref([])
|
||||
const enableWhitelist = ref(false)
|
||||
const ipWhitelist = ref([])
|
||||
const enableDailyLimit = ref(false)
|
||||
const dailyRequestLimit = ref(1000)
|
||||
|
||||
@@ -74,6 +88,8 @@ const fetchData = async () => {
|
||||
ipBlacklist.value = res.blacklist || []
|
||||
asnBlacklist.value = res.asnBlacklist || []
|
||||
fingerprintBlacklist.value = res.fingerprintBlacklist || []
|
||||
enableWhitelist.value = res.enableWhitelist || false
|
||||
ipWhitelist.value = res.whitelist || []
|
||||
enableDailyLimit.value = res.enableDailyLimit || false
|
||||
dailyRequestLimit.value = res.dailyRequestLimit || 1000
|
||||
} catch (error) {
|
||||
@@ -84,6 +100,10 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (enableWhitelist.value && (!ipWhitelist.value || ipWhitelist.value.length === 0)) {
|
||||
message.warning(t('whitelist_empty_warning'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
loading.value = true
|
||||
await api.fetch(`/admin/ip_blacklist/settings`, {
|
||||
@@ -93,6 +113,8 @@ const save = async () => {
|
||||
blacklist: ipBlacklist.value || [],
|
||||
asnBlacklist: asnBlacklist.value || [],
|
||||
fingerprintBlacklist: fingerprintBlacklist.value || [],
|
||||
enableWhitelist: enableWhitelist.value,
|
||||
whitelist: ipWhitelist.value || [],
|
||||
enableDailyLimit: enableDailyLimit.value,
|
||||
dailyRequestLimit: dailyRequestLimit.value
|
||||
})
|
||||
@@ -123,6 +145,7 @@ onMounted(async () => {
|
||||
<n-alert :show-icon="false" :bordered="false" type="info">
|
||||
<div style="line-height: 1.8;">
|
||||
<div><strong>{{ t("tip_scope") }}</strong></div>
|
||||
<div>• {{ t("tip_whitelist") }}</div>
|
||||
<div>• {{ t("tip_ip") }}</div>
|
||||
<div>• {{ t("tip_asn") }}</div>
|
||||
<div>• {{ t("tip_fingerprint") }}</div>
|
||||
@@ -130,6 +153,31 @@ onMounted(async () => {
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<n-form-item-row :label="t('enable_ip_whitelist')">
|
||||
<n-switch v-model:value="enableWhitelist" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
{{ t('enable_whitelist_tip') }}
|
||||
</n-text>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('ip_whitelist')">
|
||||
<n-select
|
||||
v-model:value="ipWhitelist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('ip_whitelist_placeholder')"
|
||||
:disabled="!enableWhitelist">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-form-item-row :label="t('enable_ip_blacklist')">
|
||||
<n-switch v-model:value="enabled" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
|
||||
@@ -13,20 +13,20 @@ const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
role: 'Role',
|
||||
maxAddressCount: 'Max Address Count',
|
||||
maxAddressCount: 'Max Address Count (0 = Unlimited)',
|
||||
save: 'Save',
|
||||
successTip: 'Success',
|
||||
noRolesAvailable: 'No roles available in system config',
|
||||
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings.',
|
||||
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings. Set 0 for unlimited.',
|
||||
notConfigured: 'Not Configured (Use Global Settings)',
|
||||
},
|
||||
zh: {
|
||||
role: '角色',
|
||||
maxAddressCount: '最大地址数量',
|
||||
maxAddressCount: '最大地址数量(0 为不限制)',
|
||||
save: '保存',
|
||||
successTip: '成功',
|
||||
noRolesAvailable: '系统配置中没有可用的角色',
|
||||
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。',
|
||||
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。设置为 0 表示不限制。',
|
||||
notConfigured: '未配置(使用全局设置)',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { api } from '../../api'
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
|
||||
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
fromName: "",
|
||||
@@ -33,6 +34,10 @@ const { t } = useI18n({
|
||||
preview: 'Preview',
|
||||
content: 'Content',
|
||||
send: 'Send',
|
||||
fromMailEmpty: 'Sender address is empty',
|
||||
subjectEmpty: 'Subject is empty',
|
||||
toMailEmpty: 'Recipient address is empty',
|
||||
contentEmpty: 'Content is empty',
|
||||
text: 'Text',
|
||||
html: 'HTML',
|
||||
'rich text': 'Rich Text',
|
||||
@@ -48,6 +53,10 @@ const { t } = useI18n({
|
||||
preview: '预览',
|
||||
content: '内容',
|
||||
send: '发送',
|
||||
fromMailEmpty: '发件人地址不能为空',
|
||||
subjectEmpty: '主题不能为空',
|
||||
toMailEmpty: '收件人地址不能为空',
|
||||
contentEmpty: '内容不能为空',
|
||||
text: '文本',
|
||||
html: 'HTML',
|
||||
'rich text': '富文本',
|
||||
@@ -62,21 +71,77 @@ const contentTypes = [
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const hasSendMailContent = (content, contentType) => {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contentType === 'text') {
|
||||
return normalizeSendMailText(content).length > 0
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = content
|
||||
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
|
||||
|
||||
const plainContent = normalizeSendMailText(container.textContent ?? '')
|
||||
if (plainContent.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromMail = `${sendMailModel.value.fromMail ?? ''}`.trim()
|
||||
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
|
||||
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
|
||||
if (!fromMail) {
|
||||
message.error(t('fromMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!subject) {
|
||||
message.error(t('subjectEmpty'))
|
||||
return
|
||||
}
|
||||
if (!toMail) {
|
||||
message.error(t('toMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
|
||||
message.error(t('contentEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_name: sendMailModel.value.fromName,
|
||||
from_mail: fromMail,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: toMail,
|
||||
subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content,
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.fetch(`/admin/send_mail`,
|
||||
{
|
||||
method: 'POST',
|
||||
body:
|
||||
JSON.stringify({
|
||||
from_name: sendMailModel.value.fromName,
|
||||
from_mail: sendMailModel.value.fromMail,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: sendMailModel.value.toMail,
|
||||
subject: sendMailModel.value.subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content: sendMailModel.value.content,
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
@@ -87,10 +152,11 @@ const send = async () => {
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
message.success(t("successSend"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +191,7 @@ const handleCreated = (editor) => {
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
|
||||
@@ -20,7 +20,7 @@ const { t } = useI18n({
|
||||
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
maxAddressCount: 'Maximum number of email addresses that can be binded',
|
||||
maxAddressCount: 'Maximum number of email addresses that can be binded (0 = Unlimited)',
|
||||
emailCheckRegex: "Email Check Regex (e.g. ^[^.]+{'@'}.+$ to disallow dots before {'@'})",
|
||||
enableEmailCheckRegex: 'Enable Email Check Regex',
|
||||
},
|
||||
@@ -34,7 +34,7 @@ const { t } = useI18n({
|
||||
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量(0 为不限制)',
|
||||
emailCheckRegex: "邮箱正则校验 (例如 ^[^.]+{'@'}.+$ 禁止{'@'}前面有.)",
|
||||
enableEmailCheckRegex: '启用邮箱正则校验',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
|
||||
@@ -19,13 +19,14 @@ const props = defineProps({
|
||||
},
|
||||
newAddressPath: {
|
||||
type: Function,
|
||||
default: async (address_name, domain, cf_token) => {
|
||||
default: async (address_name, domain, cf_token, enableRandomSubdomain) => {
|
||||
return await api.fetch("/api/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: address_name,
|
||||
domain: domain,
|
||||
cf_token: cf_token,
|
||||
enableRandomSubdomain: enableRandomSubdomain,
|
||||
}),
|
||||
});
|
||||
},
|
||||
@@ -47,6 +48,7 @@ const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const loginCfToken = ref("")
|
||||
const loginTurnstileRef = ref(null)
|
||||
const loginMethod = ref('credential') // 'credential' or 'password'
|
||||
@@ -141,6 +143,8 @@ const { locale, t } = useI18n({
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
emailPasswordRequired: 'Email and password are required',
|
||||
enableRandomSubdomain: 'Use Random Subdomain',
|
||||
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
|
||||
},
|
||||
zh: {
|
||||
login: '登录',
|
||||
@@ -163,6 +167,8 @@ const { locale, t } = useI18n({
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
emailPasswordRequired: '邮箱和密码不能为空',
|
||||
enableRandomSubdomain: '启用随机子域名',
|
||||
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -215,7 +221,8 @@ const newEmail = async () => {
|
||||
const res = await props.newAddressPath(
|
||||
nameToSend,
|
||||
emailDomain.value,
|
||||
cfToken.value
|
||||
cfToken.value,
|
||||
enableRandomSubdomain.value
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
addressPassword.value = res["password"] || '';
|
||||
@@ -241,6 +248,19 @@ const addressPrefix = computed(() => {
|
||||
return openSettings.value.prefix;
|
||||
});
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false;
|
||||
}
|
||||
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value);
|
||||
});
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const domainsOptions = computed(() => {
|
||||
// if user has role, return role domains
|
||||
if (userSettings.value.user_role) {
|
||||
@@ -350,6 +370,14 @@ onMounted(async () => {
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="domainsOptions" />
|
||||
</n-input-group>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
||||
<template #icon>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { api } from '../../api'
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
|
||||
|
||||
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
|
||||
@@ -28,8 +29,11 @@ const { t } = useI18n({
|
||||
preview: 'Preview',
|
||||
content: 'Content',
|
||||
send: 'Send',
|
||||
subjectEmpty: 'Subject is empty',
|
||||
toMailEmpty: 'Recipient address is empty',
|
||||
contentEmpty: 'Content is empty',
|
||||
requestAccess: 'Request Access',
|
||||
requestAccessTip: 'You need to request access to send mail, if have request, please contact admin.',
|
||||
requestAccessTip: 'No send balance yet. If your admin enabled a default balance it should be assigned automatically; otherwise request access or contact the admin.',
|
||||
send_balance: 'Send Mail Balance Left',
|
||||
text: 'Text',
|
||||
html: 'HTML',
|
||||
@@ -46,8 +50,11 @@ const { t } = useI18n({
|
||||
preview: '预览',
|
||||
content: '内容',
|
||||
send: '发送',
|
||||
subjectEmpty: '主题不能为空',
|
||||
toMailEmpty: '收件人地址不能为空',
|
||||
contentEmpty: '内容不能为空',
|
||||
requestAccess: '申请权限',
|
||||
requestAccessTip: '您需要申请权限才能发送邮件, 如果已经申请过, 请联系管理员提升额度。',
|
||||
requestAccessTip: '当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。',
|
||||
send_balance: '剩余发送邮件额度',
|
||||
text: '文本',
|
||||
html: 'HTML',
|
||||
@@ -63,20 +70,71 @@ const contentTypes = [
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const hasSendMailContent = (content, contentType) => {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contentType === 'text') {
|
||||
return normalizeSendMailText(content).length > 0
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = content
|
||||
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
|
||||
|
||||
const plainContent = normalizeSendMailText(container.textContent ?? '')
|
||||
if (plainContent.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
|
||||
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
|
||||
if (!subject) {
|
||||
message.error(t('subjectEmpty'))
|
||||
return
|
||||
}
|
||||
if (!toMail) {
|
||||
message.error(t('toMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
|
||||
message.error(t('contentEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: toMail,
|
||||
subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content,
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.fetch(`/api/send_mail`,
|
||||
{
|
||||
method: 'POST',
|
||||
body:
|
||||
JSON.stringify({
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: sendMailModel.value.toMail,
|
||||
subject: sendMailModel.value.subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content: sendMailModel.value.content,
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
@@ -86,11 +144,13 @@ const send = async () => {
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
indexTab.value = 'sendbox'
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
indexTab.value = 'sendbox'
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +218,7 @@ onMounted(async () => {
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
|
||||
@@ -52,13 +52,19 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const newAddressPath = async (address_name: string, domain: string, cf_token: string) => {
|
||||
const newAddressPath = async (
|
||||
address_name: string,
|
||||
domain: string,
|
||||
cf_token: string,
|
||||
enableRandomSubdomain: boolean
|
||||
) => {
|
||||
return await api.fetch("/telegram/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
address: `${address_name}@${domain}`,
|
||||
cf_token: cf_token,
|
||||
enableRandomSubdomain,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '../../api'
|
||||
import { useGlobalState } from '../../store'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
const message = useMessage()
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
@@ -78,7 +80,7 @@ onMounted(() => {
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="margin-top: 10px;"></div>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="true" :fetchMailData="fetchMailData"
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" :showFilterInput="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,6 @@ import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -17,7 +16,6 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
AutoImport({
|
||||
imports: [
|
||||
'vue',
|
||||
@@ -69,10 +67,5 @@ export default defineConfig({
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.PACKAGE_VERSION': JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
esbuild: {
|
||||
supported: {
|
||||
'top-level-await': true
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.5.0",
|
||||
"version": "1.8.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.72.0"
|
||||
"wrangler": "^4.83.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
@@ -136,40 +136,60 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'General',
|
||||
text: 'Core Configuration',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Worker Variables', link: 'worker-vars' },
|
||||
{ text: 'Common Issues', link: 'common-issues' },
|
||||
{ text: 'Configure Email Sending', link: 'config-send-mail' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Additional Features',
|
||||
text: 'Notifications & Integrations',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Configure Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: 'Configure Webhook', link: 'feature/webhook' },
|
||||
{ text: 'Configure SMTP/IMAP Mail Client', link: 'feature/config-smtp-proxy' },
|
||||
{ text: 'OAuth2 Third-party Login', link: 'feature/user-oauth2' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Advanced Features',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
|
||||
{ text: 'Configure SMTP IMAP Proxy', link: 'feature/config-smtp-proxy' },
|
||||
{ text: 'Send Email API', link: 'feature/send-mail-api' },
|
||||
{ text: 'View Email API', link: 'feature/mail-api' },
|
||||
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
|
||||
{ text: 'Configure Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
|
||||
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
|
||||
{ text: 'Configure Webhook', link: 'feature/webhook' },
|
||||
{ text: 'New Address API', link: 'feature/new-address-api' },
|
||||
{ text: 'OAuth2 Third-party Login', link: 'feature/user-oauth2' },
|
||||
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
|
||||
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Feature Overview',
|
||||
text: 'API Endpoints',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'New Address API', link: 'feature/new-address-api' },
|
||||
{ text: 'View Email API', link: 'feature/mail-api' },
|
||||
{ text: 'Send Email API', link: 'feature/send-mail-api' },
|
||||
{ text: 'Delete Address API', link: 'feature/delete-address' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Admin Console',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Admin Console', link: 'feature/admin' },
|
||||
{ text: 'Admin User Management', link: 'feature/admin-user-management' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Help',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'FAQ', link: 'common-issues' },
|
||||
]
|
||||
},
|
||||
{ text: 'Reference', base: "/en/", link: 'reference' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -136,40 +136,60 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '通用',
|
||||
text: '核心配置',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'worker变量说明', link: 'worker-vars' },
|
||||
{ text: '常见问题', link: 'common-issues' },
|
||||
{ text: 'Worker 变量说明', link: 'worker-vars' },
|
||||
{ text: '配置发送邮件', link: 'config-send-mail' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '附加功能',
|
||||
text: '通知与集成',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: '配置 webhook', link: 'feature/webhook' },
|
||||
{ text: '配置 SMTP/IMAP 邮件客户端', link: 'feature/config-smtp-proxy' },
|
||||
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '高级功能',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
|
||||
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
|
||||
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
|
||||
{ text: '查看邮件 API', link: 'feature/mail-api' },
|
||||
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
||||
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
||||
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
|
||||
{ text: '配置 webhook', link: 'feature/webhook' },
|
||||
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
|
||||
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
|
||||
{ text: '配置其他worker增强', link: 'feature/another-worker-enhanced' },
|
||||
{ text: '配置其他 worker 增强', link: 'feature/another-worker-enhanced' },
|
||||
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '功能简介',
|
||||
text: 'API 接口',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
|
||||
{ text: '查看邮件 API', link: 'feature/mail-api' },
|
||||
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
|
||||
{ text: '删除邮箱地址 API', link: 'feature/delete-address' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '管理后台',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Admin 控制台', link: 'feature/admin' },
|
||||
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '帮助',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '常见问题 (FAQ)', link: 'common-issues' },
|
||||
]
|
||||
},
|
||||
{ text: '参考', base: "/zh/", link: 'reference' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,15 +33,17 @@ git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
|
||||
|
||||
```bash
|
||||
# create a database, and copy the output to wrangler.toml in the next step
|
||||
wrangler d1 create dev
|
||||
wrangler d1 execute dev --file=db/schema.sql --remote
|
||||
wrangler d1 create temp-email-db
|
||||
wrangler d1 execute temp-email-db --file=db/schema.sql --remote
|
||||
# schema update, if you have initialized the database before this date, you can execute this command to update
|
||||
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql --remote
|
||||
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql --remote
|
||||
# wrangler d1 execute temp-email-db --file=db/2024-01-13-patch.sql --remote
|
||||
# wrangler d1 execute temp-email-db --file=db/2024-04-03-patch.sql --remote
|
||||
# create a namespace, and copy the output to wrangler.toml in the next step
|
||||
wrangler kv:namespace create DEV
|
||||
```
|
||||
|
||||
Use a D1 database name such as `temp-email-db` or `cloudflare-temp-email-prod`.
|
||||
|
||||

|
||||
|
||||
### Backend - Cloudflare workers
|
||||
|
||||
@@ -14,7 +14,8 @@ The `worker.dev` domain is inaccessible in China, please use a custom domain
|
||||
- Fork this repository on GitHub
|
||||
- Open the `Actions` page of the repository
|
||||
- Find `Deploy Backend` and click `enable workflow` to enable the `workflow`
|
||||
- If you need separate frontend and backend deployment, find `Deploy Frontend` and click `enable workflow` to enable the `workflow`
|
||||
- If you need separate frontend and backend deployment that talks to Worker directly, find `Deploy Frontend` and click `enable workflow` to enable the `workflow`
|
||||
- If you need Pages deployment with Page Functions forwarding backend requests, find `Deploy Frontend with page function` and click `enable workflow` to enable the `workflow`
|
||||
|
||||
### Configure Secrets
|
||||
|
||||
@@ -43,17 +44,18 @@ Then go to the repository page `Settings` -> `Secrets and variables` -> `Actions
|
||||
|
||||
| Name | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FRONTEND_ENV` | Frontend configuration file, please copy the content from `frontend/.env.example`, [and modify according to this guide](/en/guide/cli/pages.html) |
|
||||
| `FRONTEND_ENV` | Frontend configuration file used by the `Deploy Frontend` workflow. Copy the content from `frontend/.env.example`, [and modify it according to this guide](/en/guide/cli/pages.html). For separate frontend/backend deployment that talks to Worker directly, `VITE_API_BASE` should be the backend Worker API root URL, must start with `https://`, and must not include a trailing `/`. When this address is configured incorrectly, common symptoms are the `map` error or `405` API responses |
|
||||
| `FRONTEND_NAME` | The project name you created in Cloudflare Pages, can be created via [UI](https://temp-mail-docs.awsl.uk/en/guide/ui/pages.html) or [Command Line](https://temp-mail-docs.awsl.uk/en/guide/cli/pages.html) |
|
||||
| `FRONTEND_BRANCH` | (Optional) Branch for pages deployment, can be left unconfigured, defaults to `production` |
|
||||
| `PAGE_TOML` | (Optional) Required when using page functions to forward backend requests. Please copy the content from `pages/wrangler.toml` and modify the `service` field to your worker backend name according to actual situation |
|
||||
| `PAGE_TOML` | (Optional) Used only by the `Deploy Frontend with page function` workflow. Required when using page functions to forward backend requests. Please copy the content from `pages/wrangler.toml` and modify the `service` field to your worker backend name according to actual situation. This workflow builds the frontend in Pages mode and uses same-origin requests, so it does not read `FRONTEND_ENV` |
|
||||
| `TG_FRONTEND_NAME` | (Optional) The project name you created in Cloudflare Pages, same as `FRONTEND_NAME`. Fill this in if you need Telegram Mini App functionality |
|
||||
|
||||
### Deploy
|
||||
|
||||
- Open the `Actions` page of the repository
|
||||
- Find `Deploy Backend` and click `Run workflow` to select a branch and deploy manually
|
||||
- If you need separate frontend and backend deployment, find `Deploy Frontend` and click `Run workflow` to select a branch and deploy manually
|
||||
- If you need separate frontend and backend deployment that talks to Worker directly, find `Deploy Frontend` and click `Run workflow` to select a branch and deploy manually
|
||||
- If you need Pages deployment with Page Functions forwarding backend requests, find `Deploy Frontend with page function` and click `Run workflow` to deploy manually
|
||||
|
||||
## How to Configure Auto-Update
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ When executing the wrangler login command for the first time, you will be prompt
|
||||
cd worker
|
||||
cp wrangler.toml.template wrangler.toml
|
||||
# Create D1 and execute schema.sql
|
||||
wrangler d1 create dev
|
||||
wrangler d1 execute dev --file=../db/schema.sql --remote
|
||||
wrangler d1 create temp-email-db
|
||||
wrangler d1 execute temp-email-db --file=../db/schema.sql --remote
|
||||
```
|
||||
|
||||
> [!tip]
|
||||
> Use a D1 database name such as `temp-email-db` or `cloudflare-temp-email-prod`.
|
||||
|
||||
After creation, you can see the D1 database in the Cloudflare console.
|
||||
|
||||

|
||||
@@ -25,6 +28,6 @@ Find the `patch` file you need to execute and run it, for example:
|
||||
|
||||
```bash
|
||||
cd worker
|
||||
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql --remote
|
||||
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql --remote
|
||||
wrangler d1 execute temp-email-db --file=../db/2024-01-13-patch.sql --remote
|
||||
wrangler d1 execute temp-email-db --file=../db/2024-04-03-patch.sql --remote
|
||||
```
|
||||
|
||||
@@ -9,6 +9,12 @@ Refer to [Deploy Worker](/en/guide/cli/worker#deploy-worker-with-frontend-option
|
||||
|
||||
## Separate Frontend and Backend Deployment
|
||||
|
||||
> [!warning] Important: SPA Mode
|
||||
> This project is a Single-Page Application (SPA). If you deploy manually via the Cloudflare dashboard, **you must set "Not Found handling" to `Single-page application (SPA)` in the advanced options**, otherwise refreshing the page or directly accessing sub-paths like `/admin` will return a 404 error.
|
||||
> When deploying via CLI (`wrangler pages deploy`), this is handled automatically and no extra configuration is needed.
|
||||
>
|
||||
> 
|
||||
|
||||
The first deployment will prompt you to create a project. For the `production` branch, enter `production`.
|
||||
|
||||
```bash
|
||||
|
||||
@@ -35,6 +35,8 @@ compatibility_date = "2024-09-23"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
# If you want to use a custom domain, you need to add routes configuration
|
||||
# Replace pattern with your own domain, which must already be added to your Cloudflare account
|
||||
# Once configured, the Worker will serve via this custom domain instead of the default *.workers.dev domain
|
||||
# routes = [
|
||||
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
|
||||
# ]
|
||||
@@ -59,7 +61,8 @@ compatibility_flags = [ "nodejs_compat" ]
|
||||
PREFIX = "tmp"
|
||||
# All domains used for temporary email, supports multiple domains
|
||||
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
|
||||
# Secret key for generating JWT, JWT is used for user login and authentication
|
||||
# Secret key for signing JWTs used in login and authentication
|
||||
# Use a random string, e.g. generated via: openssl rand -hex 32
|
||||
JWT_SECRET = "xxx"
|
||||
|
||||
# Admin console password, if not configured, console access is not allowed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Common Issues
|
||||
# FAQ
|
||||
|
||||
> [!NOTE] Note
|
||||
> If you don't find a solution here, please search or ask in `Github Issues`, or ask in the Telegram group.
|
||||
@@ -9,6 +9,7 @@
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| Sending emails to authenticated forwarding addresses using Cloudflare Workers | Use CF's API for sending, only supports recipient addresses bound to CF, i.e., CF EMAIL forwarding destination addresses |
|
||||
| Binding multiple domains | Each domain needs to configure email forwarding to worker |
|
||||
| Subdomain cannot receive email | Subdomains must have Email Routing **enabled separately** on Cloudflare with their own DNS records and Catch-all rule. Enabling it only on the apex domain does NOT cover subdomains. See [Email Routing](/en/guide/email-routing) |
|
||||
|
||||
## Worker Related
|
||||
|
||||
@@ -19,13 +20,29 @@
|
||||
| `Subdomain cannot send emails` | [Reference](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515) |
|
||||
| `Failed to send verify code: No balance` | Set unlimited emails in admin console or increase quota on the sending permission page |
|
||||
| `Github OAuth unable to get email 400 Failed to get user email` | GitHub user needs to set email to public |
|
||||
| `Cannot read properties of undefined (reading 'map')` | Worker variables not set successfully |
|
||||
| `Cannot read properties of undefined (reading 'map')` during page initialization | First check whether `/open_api/settings` is returning valid data. In a direct Worker deployment, this usually means Worker variables were not configured correctly, so verify JSON-format variables such as `DOMAINS` and `ADMIN_PASSWORDS`. If this happens in a Pages deployment because requests are going to the wrong backend address, continue with the Pages troubleshooting section below |
|
||||
|
||||
## Pages Related
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------- | --------------------------------------------------------- |
|
||||
| `network error` | Use incognito mode or clear browser cache and DNS cache |
|
||||
| Pages deployment shows the `map` error, or API requests such as `/admin/users` / `/admin/new_address` return `405 Method Not Allowed` | This is usually caused by an incorrect frontend backend address. Check `VITE_API_BASE`, the URL entered when generating the zip in the UI guide, or `FRONTEND_ENV`: for separate frontend/backend deployment talking directly to Worker, it should be the backend Worker API root URL, start with `https://`, and have no trailing `/`; if you use `PAGE_TOML` to proxy backend requests through Page Functions, `VITE_API_BASE` can be left empty to use same-origin requests. See [Pages Frontend Deployment](/en/guide/ui/pages) |
|
||||
| Refreshing page or directly visiting `/admin`, `/user` returns 404 | This project is a Single-Page Application (SPA). When deploying Pages via UI, set "Not Found handling" to `Single-page application (SPA)` in the advanced options. See [Pages Frontend Deployment](/en/guide/ui/pages) |
|
||||
|
||||
## Email Sending Related
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------- | --------------------------------------------------------- |
|
||||
| Set `DEFAULT_SEND_BALANCE` but still getting `No balance` | Refresh the settings page or try sending again first. When `DEFAULT_SEND_BALANCE > 0`, the system only auto-initializes the default quota for addresses that have **no `address_sender` row yet**; existing rows — including legacy `balance = 0 && enabled = 0` rows, admin-disabled rows, and admin-edited rows — are never modified by the runtime and must be manually restored by an admin (enable + set balance). Alternatively, add the address to the "No Limit Send Address List" in the admin console, or configure `NO_LIMIT_SEND_ROLE` |
|
||||
| Error: `Please enable resend or smtp for this domain` | You need to configure `RESEND_TOKEN` or `SMTP_CONFIG` first. See [Configure Email Sending](/en/guide/config-send-mail) |
|
||||
| `SMTP_CONFIG` configured but sending fails | Make sure the JSON key is **your own sending domain** (e.g. `your-domain.com`), not the example `awsl.uk`. See [Configure Email Sending](/en/guide/config-send-mail#send-emails-using-smtp) |
|
||||
|
||||
## Mail Client Related
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------- | --------------------------------------------------------- |
|
||||
| Set `ENABLE_ADDRESS_PASSWORD` but Foxmail/Outlook cannot login | `ENABLE_ADDRESS_PASSWORD` only enables the "address password login" web API. It does **NOT** provide standard IMAP/SMTP service. To use mail clients, you need to deploy the [SMTP/IMAP Proxy Service](/en/guide/feature/config-smtp-proxy) |
|
||||
|
||||
## Telegram Bot
|
||||
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
|
||||
# Configure Email Sending
|
||||
|
||||
::: warning Note
|
||||
All three methods can be configured simultaneously. When sending emails, it will prioritize using `resend`, if `resend` is not configured, it will use `smtp`.
|
||||
::: tip Recommended
|
||||
Use Cloudflare `send_email` binding as the default send channel. Bind `SEND_MAIL` and finish Email Routing onboarding, then the Worker can send to any external address directly.
|
||||
|
||||
If a Cloudflare authenticated forwarding email address is configured, CF's internal API will be prioritized for sending emails
|
||||
Workers Paid includes 3,000 messages/month, then $0.35 per 1,000 messages.
|
||||
:::
|
||||
|
||||
## Send Channel Priority
|
||||
|
||||
Each `/api/send_mail` request matches channels in order; **the first hit sends**:
|
||||
|
||||
| Order | Condition | Channel | Deducts balance |
|
||||
|-------|-----------|---------|----------------|
|
||||
| 1 | `SEND_MAIL` bound **AND** recipient in `verifiedAddressList` | Cloudflare binding (compat mode) | No |
|
||||
| 2 | `RESEND_TOKEN` or `RESEND_TOKEN_<DOMAIN>` set | Resend API | Yes |
|
||||
| 3 | `SMTP_CONFIG` has entry for current domain | worker-mailer SMTP | Yes |
|
||||
| 4 | `SEND_MAIL` bound (none of the above) | **Cloudflare binding (recommended primary)** | Yes |
|
||||
| — | None of the above | Throws | — |
|
||||
|
||||
> [!NOTE]
|
||||
> Binding send failures return an error directly.
|
||||
|
||||
## Using the Cloudflare `send_email` Binding (Recommended)
|
||||
|
||||
Only available when deploying via CLI. Add to `wrangler.toml`:
|
||||
|
||||
```toml
|
||||
# Send emails via the Cloudflare send_email binding
|
||||
send_email = [
|
||||
{ name = "SEND_MAIL" },
|
||||
]
|
||||
```
|
||||
|
||||
> [!warning] Important
|
||||
> The binding name must be `SEND_MAIL` — different from Cloudflare's official `SEND_EMAIL` example.
|
||||
|
||||
After the following steps, you can send to any external address directly:
|
||||
|
||||
1. Enable Email Routing on the domain in the Cloudflare Dashboard and complete onboarding
|
||||
2. Add the `send_email` binding shown above to `wrangler.toml`
|
||||
3. Deploy the Worker
|
||||
|
||||
No additional env var is required.
|
||||
|
||||
## Send Emails Using Resend
|
||||
|
||||
Register at `https://resend.com/domains` and add DNS records according to the instructions.
|
||||
@@ -35,12 +72,18 @@ wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
|
||||
|
||||
## Send Emails Using SMTP
|
||||
|
||||
The format of `SMTP_CONFIG` is as follows, where key is the domain name and value is the SMTP configuration. For SMTP configuration format details, refer to [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
The format of `SMTP_CONFIG` is as follows. **The key must be your own sending domain**, and the value is the SMTP configuration.
|
||||
|
||||
For SMTP configuration format details, refer to [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
|
||||
> [!warning] Important
|
||||
> The JSON key (e.g. `your-domain.com` in the example below) must be replaced with **your own domain** — the domain configured in your `DOMAINS` variable.
|
||||
> This is one of the most common configuration mistakes. Do not copy the example domain directly.
|
||||
|
||||
```json
|
||||
{
|
||||
"awsl.uk": {
|
||||
"host": "smtp.xxx.com",
|
||||
"your-domain.com": {
|
||||
"host": "smtp.example.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authType": [
|
||||
@@ -48,13 +91,46 @@ The format of `SMTP_CONFIG` is as follows, where key is the domain name and valu
|
||||
"login"
|
||||
],
|
||||
"credentials": {
|
||||
"username": "username",
|
||||
"password": "password"
|
||||
"username": "your-smtp-username",
|
||||
"password": "your-smtp-password"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Field Reference:**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| key (e.g. `your-domain.com`) | Your sending domain, must match a domain configured in `DOMAINS` |
|
||||
| `host` | SMTP server address, e.g. `smtp.mailgun.org`, `smtp.gmail.com`, or your self-hosted SMTP server |
|
||||
| `port` | SMTP port, typically `465` (SSL) or `587` (STARTTLS) |
|
||||
| `secure` | Whether to use SSL/TLS. Set to `true` for port 465, `false` for port 587 |
|
||||
| `authType` | Authentication method, typically `["plain", "login"]` |
|
||||
| `credentials.username` | SMTP server login username |
|
||||
| `credentials.password` | SMTP server login password |
|
||||
|
||||
If you have **multiple domains** using different SMTP services, add multiple keys in the same JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain-a.com": {
|
||||
"host": "smtp.mailgun.org",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authType": ["plain", "login"],
|
||||
"credentials": { "username": "user@domain-a.com", "password": "xxx" }
|
||||
},
|
||||
"domain-b.com": {
|
||||
"host": "smtp.gmail.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authType": ["plain", "login"],
|
||||
"credentials": { "username": "user@gmail.com", "password": "app-password" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then execute the following command to add `SMTP_CONFIG` to secrets:
|
||||
|
||||
> [!NOTE]
|
||||
@@ -68,17 +144,29 @@ cd worker
|
||||
wrangler secret put SMTP_CONFIG
|
||||
```
|
||||
|
||||
## Send Balance Mechanism
|
||||
|
||||
Users need a send balance to send emails. The balance mechanism works as follows:
|
||||
|
||||
1. **Auto-initialize Default Quota**: When `DEFAULT_SEND_BALANCE > 0`, the system automatically initializes the default quota when the user opens the send page or calls the send-mail API for the first time
|
||||
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can still click "Request Send Permission" in the frontend to create a pending send-access record for admins to review
|
||||
3. **Unlimited Sending**: The following methods can bypass balance checks:
|
||||
- Add the address to the "No Limit Send Address List" in the admin console
|
||||
- Configure the `NO_LIMIT_SEND_ROLE` environment variable to specify roles that can send without limits
|
||||
|
||||
> [!NOTE]
|
||||
> `DEFAULT_SEND_BALANCE` only inserts an initial quota for addresses that do not yet have an `address_sender` row (`ON CONFLICT DO NOTHING`); existing rows — including admin-disabled or admin-edited ones — are never modified by the runtime path. Restoring a previously disabled or pre-existing address must go through the admin console (enable + set balance).
|
||||
>
|
||||
> Layer 1 (`verifiedAddressList` hit) does not deduct balance, but it still counts toward send limits; layers 2/3/4 all deduct balance.
|
||||
>
|
||||
> Send limits apply to **all** send channels, including admin send endpoints.
|
||||
>
|
||||
> Daily and monthly windows are calculated in **UTC**.
|
||||
>
|
||||
> The current limit implementation is a **soft guard**. It is suitable for routine quota control, but it should not be treated as a strict hard-stop cost gate under database errors or high concurrency.
|
||||
|
||||
## Send Emails to Authenticated Forwarding Addresses on Cloudflare
|
||||
|
||||
Only supported for CLI deployment, add `send_email` configuration in `wrangler.toml`.
|
||||
Typical use case: non-onboarded domains or Workers free-tier users.
|
||||
|
||||
The destination email address must be an authenticated email address on Cloudflare, which has significant limitations. If you need to send emails to other addresses, you can use `resend` or `smtp` to send emails.
|
||||
|
||||
```toml
|
||||
# Send emails through Cloudflare
|
||||
send_email = [
|
||||
{ name = "SEND_MAIL" },
|
||||
]
|
||||
```
|
||||
|
||||
Admin console account configuration `Verified address list (can send emails through CF internal API)`
|
||||
In this compatibility mode, mail is sent via `SEND_MAIL` binding only when the recipient is in the admin `Verified Address List`.
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
3. Configure the `Catch-all address` in the routing rules of each domain's `Email Routing` to send to `worker`.
|
||||
|
||||

|
||||
|
||||
> [!WARNING] Subdomains must be configured separately
|
||||
> If you want to receive mail on a **subdomain** (e.g. `mail.example.com`), you must enable `Email Routing` on **that subdomain** in the CF dashboard and configure its email DNS records and Catch-all rule separately. Enabling Email Routing only on the apex domain **does not cover subdomains**.
|
||||
|
||||
@@ -10,6 +10,48 @@ You need to configure `ADMIN_PASSWORDS` in the backend or ensure the current use
|
||||
|
||||

|
||||
|
||||
## Account List Sorting
|
||||
|
||||
The Accounts tab in the admin console supports column sorting. Click the column header to toggle ascending/descending order for:
|
||||
|
||||
- ID
|
||||
- Name
|
||||
- Created At
|
||||
- Updated At
|
||||
- Mail Count
|
||||
- Send Count
|
||||
|
||||
When searching for email addresses, pagination automatically resets to page 1.
|
||||
|
||||
## If your website is for private access only, you can disable this check
|
||||
|
||||
`DISABLE_ADMIN_PASSWORD_CHECK = true`
|
||||
|
||||
## IP Blacklist / Whitelist
|
||||
|
||||
Configure access control in Admin Console → **IP Blacklist Settings**. Applies to: create address, send mail, external send mail API, user registration, and verify code endpoints.
|
||||
|
||||
### IP Whitelist (Strict Mode)
|
||||
|
||||
When enabled, **only** whitelisted IPs can access protected endpoints; all others receive 403.
|
||||
|
||||
- Plain entries: exact match (no substring), e.g. `1.2.3.4`
|
||||
- Regex entries: use anchored patterns, e.g. `^192\.168\.1\.\d+$`
|
||||
- Whitelisted IPs skip blacklist checks
|
||||
- If whitelist is enabled but the list is empty, the server ignores the switch (fail-open to prevent lockout)
|
||||
|
||||
### IP Blacklist
|
||||
|
||||
When enabled, matching IPs receive 403. Supports substring text matching or regex.
|
||||
|
||||
### ASN Organization Blacklist
|
||||
|
||||
Block by ISP/provider name, case-insensitive. Supports text or regex matching.
|
||||
|
||||
### Browser Fingerprint Blacklist
|
||||
|
||||
Block by `x-fingerprint` request header. Supports exact or regex matching.
|
||||
|
||||
### Daily Request Limit
|
||||
|
||||
Limit the maximum number of requests per IP per day (1–1,000,000). Exceeding the limit returns 429. Counter resets every 24 hours (UTC date boundary).
|
||||
|
||||
42
vitepress-docs/docs/en/guide/feature/delete-address.md
Normal file
42
vitepress-docs/docs/en/guide/feature/delete-address.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Delete Address API
|
||||
|
||||
## Admin Delete Address API
|
||||
|
||||
Delete an address by address ID. This endpoint requires admin auth and deletes related data (mails, sender settings, bindings, etc.).
|
||||
|
||||
```bash
|
||||
DELETE /admin/delete_address/:id
|
||||
```
|
||||
|
||||
Header:
|
||||
|
||||
- `x-admin-auth: <admin_password>`
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
## User Delete Address API
|
||||
|
||||
Delete mailbox by address JWT. The request needs address token permission and deletes related data (received mails, sent items, auto reply data, sender bindings, user bindings, telegram bind records).
|
||||
|
||||
```bash
|
||||
DELETE /api/delete_address
|
||||
```
|
||||
|
||||
Headers:
|
||||
|
||||
- `Authorization: Bearer <address_jwt>`
|
||||
|
||||
Notes:
|
||||
|
||||
- `ENABLE_USER_DELETE_EMAIL` must be enabled.
|
||||
- Address credential can be obtained from `/api/new_address` or `/admin/new_address`.
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
@@ -131,6 +131,14 @@ print(response.json())
|
||||
|
||||
## User Mail API
|
||||
|
||||
::: warning Note: User JWT vs Address JWT
|
||||
This endpoint uses **User JWT** (obtained via `/user_api/login` or `/user_api/register`), with `x-user-token` header.
|
||||
|
||||
**Do not confuse with Address JWT**:
|
||||
- Address JWT uses `Authorization: Bearer <jwt>` to access `/api/*` endpoints
|
||||
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
|
||||
:::
|
||||
|
||||
Supports `address` filter
|
||||
|
||||
```python
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Create New Email Address API
|
||||
|
||||
::: warning Note: Address JWT vs User JWT
|
||||
This page describes **Address JWT**, which is different from **User JWT**:
|
||||
|
||||
- **Address JWT**: Returned when creating a mailbox via `/api/new_address` or `/admin/new_address`
|
||||
- Use `Authorization: Bearer <jwt>` header
|
||||
- Access `/api/*` endpoints (view mails, delete mails, etc.)
|
||||
|
||||
- **User JWT**: Obtained via `/user_api/login` or `/user_api/register`
|
||||
- Use `x-user-token: <jwt>` header
|
||||
- Access `/user_api/*` endpoints (user account management)
|
||||
|
||||
**Do not confuse these two JWT types!**
|
||||
:::
|
||||
|
||||
## Create Email Address via Admin API
|
||||
|
||||
This is a `python` example using the `requests` library to send emails.
|
||||
@@ -21,10 +35,36 @@ res = requests.post(
|
||||
}
|
||||
)
|
||||
|
||||
# Returns {"jwt": "<Jwt>"}
|
||||
# Returns {"jwt": "<Jwt>", "address": "<email_address>", "address_id": 123}
|
||||
print(res.json())
|
||||
```
|
||||
|
||||
### Create a Subdomain Mailbox Address
|
||||
|
||||
If your base domain is already configured in `DOMAINS` / `DEFAULT_DOMAINS` / `USER_ROLES`, and
|
||||
`ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is enabled (it can also be toggled in the admin panel),
|
||||
the create-address APIs can accept subdomains directly:
|
||||
|
||||
```python
|
||||
res = requests.post(
|
||||
"https://xxxx.xxxx/admin/new_address",
|
||||
json={
|
||||
"enablePrefix": True,
|
||||
"name": "project001",
|
||||
"domain": "team.example.com",
|
||||
},
|
||||
headers={
|
||||
'x-admin-auth': "<your_website_admin_password>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- If `example.com` is an allowed base domain, `team.example.com` and `dev.team.example.com` can match successfully
|
||||
- Lookalike domains such as `badexample.com` will **not** be treated as `example.com`
|
||||
- This is different from `RANDOM_SUBDOMAIN_DOMAINS`: here the caller **explicitly specifies** the subdomain, instead of the system generating a random one
|
||||
- In the admin panel, this can be set to **Follow Environment Variable / Force Enable / Force Disable**. Choosing **Follow Environment Variable** clears the admin override and returns to env fallback behavior.
|
||||
|
||||
## Batch Create Random Username Email Addresses API Example
|
||||
|
||||
### Batch Create Email Addresses via Admin API
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
## Send Email via HTTP API
|
||||
|
||||
This is a `python` example using the `requests` library to send emails.
|
||||
There are two HTTP API endpoints for sending emails:
|
||||
|
||||
| Endpoint | Authentication | Use Case |
|
||||
|----------|---------------|----------|
|
||||
| `/api/send_mail` | `Authorization: Bearer <address_JWT>` header | Internal calls, requires cookie / header auth |
|
||||
| `/external/api/send_mail` | `token` field in request body | External system integration, no header auth needed |
|
||||
|
||||
::: tip What is "Address JWT"?
|
||||
The Address JWT is the `jwt` field returned when creating an email address via `/api/new_address` or `/admin/new_address`.
|
||||
You can view it in the "Password" menu in the frontend UI. It is **NOT** the `JWT_SECRET` environment variable, nor the admin password.
|
||||
:::
|
||||
|
||||
### Method 1: Header Authentication (`/api/send_mail`)
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
@@ -15,17 +27,22 @@ send_body = {
|
||||
}
|
||||
|
||||
res = requests.post(
|
||||
"http://localhost:8787/api/send_mail",
|
||||
"https://your_worker_domain/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Authorization": f"Bearer {your_JWT_password}",
|
||||
"Authorization": f"Bearer {address_JWT}",
|
||||
# "x-custom-auth": "<your_website_password>", # If private site password is enabled
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
# Using body authentication
|
||||
### Method 2: Body Token Authentication (`/external/api/send_mail`)
|
||||
|
||||
Suitable for external system calls, place the Address JWT in the `token` field of the request body:
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
"token": "<your_JWT_password>",
|
||||
"token": "<address_JWT>",
|
||||
"from_name": "Sender Name",
|
||||
"to_name": "Recipient Name",
|
||||
"to_mail": "Recipient Address",
|
||||
@@ -34,7 +51,7 @@ send_body = {
|
||||
"content": "<Email content: html or text>",
|
||||
}
|
||||
res = requests.post(
|
||||
"http://localhost:8787/external/api/send_mail",
|
||||
"https://your_worker_domain/external/api/send_mail",
|
||||
json=send_body, headers={
|
||||
# "x-custom-auth": "<your_website_password>", # If private site password is enabled
|
||||
"Content-Type": "application/json"
|
||||
|
||||
@@ -9,3 +9,50 @@ Mail channel is no longer supported. The reference below is limited to the recei
|
||||
Reference
|
||||
|
||||
- [Configure Subdomain Email](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)
|
||||
|
||||
## Create Random Second-level Subdomain Addresses
|
||||
|
||||
If your base domain mail routing is already configured, you can also let users create mailbox
|
||||
addresses with an automatically generated random second-level subdomain, for example:
|
||||
|
||||
- Base domain: `abc.com`
|
||||
- Created address: `name@x7k2p9q1.abc.com`
|
||||
|
||||
This is useful for mailbox isolation and reducing repeated hits on the same address.
|
||||
|
||||
Add these worker variables:
|
||||
|
||||
```toml
|
||||
RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
||||
RANDOM_SUBDOMAIN_LENGTH = 8
|
||||
```
|
||||
|
||||
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow optional random second-level subdomains
|
||||
- `RANDOM_SUBDOMAIN_LENGTH`: random string length, range `1-63`, default `8`
|
||||
|
||||
> [!NOTE]
|
||||
> This feature only appends a random second-level subdomain when the mailbox is created.
|
||||
>
|
||||
> It does not automatically create Cloudflare-side subdomain mail routes or DNS records for you,
|
||||
> so make sure the base-domain/subdomain routing is already available first.
|
||||
|
||||
## Let APIs Specify Subdomains Directly
|
||||
|
||||
If you do not want the system to generate a random subdomain, and instead want the caller to
|
||||
explicitly create addresses like `team.abc.com`, enable:
|
||||
|
||||
```toml
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||
```
|
||||
|
||||
When this is enabled, as long as `abc.com` is in the allowed base-domain list, the following
|
||||
addresses can be created through `/api/new_address` or `/admin/new_address`:
|
||||
|
||||
- `name@team.abc.com`
|
||||
- `name@dev.team.abc.com`
|
||||
|
||||
> [!NOTE]
|
||||
> This only relaxes the domain validation used by the create-address APIs. It does not change the
|
||||
> default domain dropdown, and it does not create Cloudflare-side subdomain mail routes for you.
|
||||
>
|
||||
> If the admin panel has already saved an override once, you can switch it back to **Follow Environment Variable** to clear the override and return to env fallback behavior.
|
||||
|
||||
@@ -7,14 +7,67 @@ import JSZip from 'jszip';
|
||||
const domain = ref("")
|
||||
const downloadUrl = ref("")
|
||||
const tip = ref("Download")
|
||||
const errorMessage = ref("")
|
||||
|
||||
const resetDownloadUrl = () => {
|
||||
if (!downloadUrl.value) {
|
||||
return
|
||||
}
|
||||
window.URL.revokeObjectURL(downloadUrl.value)
|
||||
downloadUrl.value = ""
|
||||
}
|
||||
|
||||
const validateDomain = (value) => {
|
||||
const normalizedValue = value.trim()
|
||||
if (!normalizedValue) {
|
||||
return "Please enter a backend API URL starting with https://"
|
||||
}
|
||||
if (/\s/.test(normalizedValue)) {
|
||||
return "The backend API URL must not contain whitespace characters"
|
||||
}
|
||||
if (!normalizedValue.startsWith("https://")) {
|
||||
return "The backend API URL must start with https://"
|
||||
}
|
||||
if (normalizedValue.endsWith("/")) {
|
||||
return "Do not add a trailing / to the backend API URL"
|
||||
}
|
||||
try {
|
||||
const url = new URL(normalizedValue)
|
||||
if (url.protocol !== "https:") {
|
||||
return "The backend API URL must start with https://"
|
||||
}
|
||||
if (url.pathname !== "/" || url.search || url.hash) {
|
||||
return "Please enter the backend API root URL only, without a path, query, or hash"
|
||||
}
|
||||
} catch {
|
||||
return "The backend API URL format is invalid"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const generate = async () => {
|
||||
const normalizedDomain = domain.value.trim()
|
||||
const validationError = validateDomain(normalizedDomain)
|
||||
errorMessage.value = validationError
|
||||
resetDownloadUrl()
|
||||
if (validationError) {
|
||||
return
|
||||
}
|
||||
domain.value = normalizedDomain
|
||||
let timeoutId = 0
|
||||
try {
|
||||
const response = await fetch("/ui_install/frontend.zip");
|
||||
const controller = new AbortController()
|
||||
timeoutId = window.setTimeout(() => controller.abort(), 10000)
|
||||
const response = await fetch("/ui_install/frontend.zip", {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
errorMessage.value = "Failed to download the frontend zip file. Please try again later"
|
||||
return
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
var zip = new JSZip();
|
||||
await zip.loadAsync(arrayBuffer);
|
||||
let target_content = ""
|
||||
let target_path = ""
|
||||
const directory = zip.folder("assets");
|
||||
if (directory) {
|
||||
@@ -22,7 +75,7 @@ const generate = async () => {
|
||||
console.log(relativePath);
|
||||
if (relativePath.startsWith("assets/index-") && relativePath.endsWith(".js")){
|
||||
let content = await zipEntry.async("string");
|
||||
content = content.replace("https://temp-email-api.xxx.xxx", domain.value);
|
||||
content = content.replaceAll("https://temp-email-api.xxx.xxx", normalizedDomain);
|
||||
target_path = relativePath;
|
||||
zip.file(relativePath, content);
|
||||
break;
|
||||
@@ -30,14 +83,22 @@ const generate = async () => {
|
||||
}
|
||||
}
|
||||
if (!target_path) {
|
||||
tip.value = "Generation failed";
|
||||
downloadUrl.value = '';
|
||||
errorMessage.value = "Could not find the frontend entry file. Generation failed"
|
||||
return
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
errorMessage.value = ""
|
||||
downloadUrl.value = url;
|
||||
} catch (error) {
|
||||
console.error("Error: ", error);
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
errorMessage.value = "Download timed out. Please refresh the page and try again"
|
||||
return
|
||||
}
|
||||
errorMessage.value = "Generation failed. Please refresh the page and try again"
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -50,27 +111,38 @@ const generate = async () => {
|
||||
|
||||

|
||||
|
||||
3. Enter the address of the deployed worker. The address should not include a trailing `/`. Click generate, and if successful, a download button will appear. You will get a zip package.
|
||||
3. Enter the deployed worker address. It must be the backend API root URL, start with `https://`, and must not include a trailing `/`. Click generate, and if successful, a download button will appear. You will get a zip package.
|
||||
- The worker domain here is the backend API domain. For example, if I deployed at `https://temp-email-api.awsl.uk`, then fill in `https://temp-email-api.awsl.uk`
|
||||
- If your domain is `https://temp-email-api.xxx.workers.dev`, then fill in `https://temp-email-api.xxx.workers.dev`
|
||||
- Do not enter your frontend `Pages` domain, and do not include paths like `/admin` or `/api`. Otherwise frontend requests will hit the wrong address and you may see `Cannot read properties of undefined (reading 'map')` or `405 Method Not Allowed`
|
||||
|
||||
> [!warning] Note
|
||||
> The `worker.dev` domain is not accessible in China, please use a custom domain.
|
||||
|
||||
<div :class="$style.container">
|
||||
<input :class="$style.input" type="text" v-model="domain" placeholder="Please enter address"></input>
|
||||
<input :class="$style.input" type="text" v-model="domain" placeholder="Enter a backend API URL starting with https://"></input>
|
||||
<button :class="$style.button" @click="generate">Generate</button>
|
||||
<a v-if="downloadUrl" :href="downloadUrl" download="frontend.zip">{{ tip }}</a>
|
||||
</div>
|
||||
<p :class="$style.hint">Example: `https://temp-email-api.example.com`. Do not enter the frontend Pages domain and do not add a trailing `/`.</p>
|
||||
<p v-if="errorMessage" :class="$style.error">{{ errorMessage }}</p>
|
||||
|
||||
> [!NOTE]
|
||||
> You can also deploy manually. Download the zip from here: [frontend.zip](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip)
|
||||
>
|
||||
> Modify the index-xxx.js file in the archive, where xx is a random string
|
||||
>
|
||||
> Search for `https://temp-email-api.xxx.xxx` and replace it with your worker's domain, then deploy the new zip file
|
||||
> Search for `https://temp-email-api.xxx.xxx` and replace it with your worker's backend API root URL, then deploy the new zip file. If you replace it with the frontend Pages domain, common symptoms are the `map` error or `405` responses from API requests
|
||||
|
||||
4. Select `Pages`, click `Create Pages`, modify the name, upload the downloaded zip package, and then click `Deploy`
|
||||
4. Select `Pages`, click `Create Pages`, modify the name, upload the downloaded zip package
|
||||
|
||||
> [!warning] Important: SPA Mode
|
||||
> This project is a Single-Page Application (SPA). **You must expand the advanced options during deployment and set "Not Found handling" to `Single-page application (SPA)`**.
|
||||
> Otherwise, refreshing the page or directly accessing sub-paths like `/admin` will return a 404 error.
|
||||
>
|
||||
> 
|
||||
|
||||
Then click `Deploy`
|
||||
|
||||

|
||||
|
||||
@@ -102,4 +174,14 @@ const generate = async () => {
|
||||
.button:hover {
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 8px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 8px;
|
||||
color: #d03050;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| Variable Name | Type | Description | Example |
|
||||
| -------------------------- | ----------- | ---------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `DOMAINS` | JSON | All domains for temporary email, supports multiple domains | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `JWT_SECRET` | Text/Secret | Secret key for generating JWT, used for login and authentication | `xxx` |
|
||||
| `JWT_SECRET` | Text/Secret | Secret key for signing JWTs used in login and authentication. Use a random string, e.g. generated via `openssl rand -hex 32` | `a1b2c3d4...` |
|
||||
| `ADMIN_PASSWORDS` | JSON | Admin console passwords, console access disabled if not configured | `["123", "456"]` |
|
||||
| `ENABLE_USER_CREATE_EMAIL` | Text/JSON | Whether to allow users to create mailboxes, disabled if not configured | `true` |
|
||||
| `ENABLE_USER_DELETE_EMAIL` | Text/JSON | Whether to allow users to delete emails, disabled if not configured | `true` |
|
||||
@@ -32,10 +32,36 @@
|
||||
| `ADDRESS_REGEX` | Text | Regular expression to replace illegal symbols in `email address` name, symbols not in the regex will be replaced. Default is `[^a-z0-9]` if not set. Use with caution as some symbols may prevent email reception | `[^a-z0-9]` |
|
||||
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
|
||||
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | Text/JSON | Whether to allow create-address APIs to use base-domain suffix matching. When enabled, if `example.com` is allowed, `/api/new_address` and `/admin/new_address` can also accept `foo.example.com` or `a.b.example.com` | `true` |
|
||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow optional random subdomain creation, so `name@abc.com` can become `name@<random>.abc.com` | `["abc.com"]` |
|
||||
| `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` |
|
||||
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
|
||||
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance, will be 0 if not set | `1` |
|
||||
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance. When greater than `0`, it is auto-initialized when users open the settings page or send mail for the first time. Defaults to `0` if unset | `1` |
|
||||
| `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` |
|
||||
| `SEND_MAIL_DOMAINS` | JSON | Restrict which sender domains can use the `SEND_MAIL` binding; when unset or empty, all domains are allowed | `["example.com", "mail.example.com"]` |
|
||||
|
||||
> [!NOTE]
|
||||
> `RANDOM_SUBDOMAIN_DOMAINS` only controls automatic random subdomain generation during mailbox
|
||||
> creation. It does not create Cloudflare-side subdomain routing for you.
|
||||
>
|
||||
> Subdomain addresses are usually best used for receiving only; for sending, prefer the main
|
||||
> domain.
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is different from random subdomain generation: it lets
|
||||
> API callers **directly specify** a subdomain such as `foo.example.com`, while random subdomain
|
||||
> generation appends one automatically during creation.
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` precedence: if the env is explicitly set to `false`, the
|
||||
> feature is globally forced off; otherwise the persisted admin setting takes precedence, and the env
|
||||
> value is only used as a fallback when no admin setting has been saved.
|
||||
>
|
||||
> The admin panel exposes three explicit states: **Follow Environment Variable**, **Force Enable**,
|
||||
> and **Force Disable**. Saving **Follow Environment Variable** clears the admin override and returns
|
||||
> the feature to the "unset" fallback behavior.
|
||||
>
|
||||
> `SEND_MAIL_DOMAINS` only affects the `SEND_MAIL` binding fallback path and
|
||||
> `/admin/send_mail_by_binding`. It does not affect Resend, SMTP, or `verifiedAddressList`.
|
||||
|
||||
## Email Reception Related Variables
|
||||
|
||||
@@ -48,8 +74,11 @@
|
||||
| `FORWARD_ADDRESS_LIST` | JSON | Global forward address list, disabled if not configured, all emails will be forwarded to listed addresses when enabled | `["xxx@xxx.com"]` |
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
|
||||
|
||||
> [!NOTE]
|
||||
> `ENABLE_MAIL_GZIP` adds CPU cost for gzip compression on write and decompression on read. Free-tier Workers are more likely to hit CPU limits, so a paid plan is recommended before enabling it
|
||||
>
|
||||
> `Junk mail checking` and `attachment removal` require email parsing, free tier CPU is limited, may cause large email parsing timeout
|
||||
>
|
||||
> If you want stronger email parsing capabilities
|
||||
|
||||
BIN
vitepress-docs/docs/public/ui_install/pages-spa-setting.jpg
Normal file
BIN
vitepress-docs/docs/public/ui_install/pages-spa-setting.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -14,7 +14,8 @@
|
||||
- 在 GitHub fork 本仓库
|
||||
- 打开仓库的 `Actions` 页面
|
||||
- 找到 `Deploy Backend` 点击 `enable workflow` 启用 `workflow`
|
||||
- 如果需要前后端分离部署, 找到`Deploy Frontend` 点击 `enable workflow` 启用 `workflow`
|
||||
- 如果需要前后端分离并直连 Worker, 找到 `Deploy Frontend` 点击 `enable workflow` 启用 `workflow`
|
||||
- 如果需要通过 Page Functions 转发后端请求的 Pages 部署, 找到 `Deploy Frontend with page function` 点击 `enable workflow` 启用 `workflow`
|
||||
|
||||
### 配置 Secrets
|
||||
|
||||
@@ -43,17 +44,18 @@
|
||||
|
||||
| 名称 | 说明 |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FRONTEND_ENV` | 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html) |
|
||||
| `FRONTEND_ENV` | `Deploy Frontend` workflow 使用的前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html)。如果是前后端分离直连 Worker,`VITE_API_BASE` 应填写后端 Worker API 根地址,并且以 `https://` 开头、末尾不要带 `/`。地址配置错误时,常见现象是前端报 `map` 错误或接口返回 `405` |
|
||||
| `FRONTEND_NAME` | 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建 |
|
||||
| `FRONTEND_BRANCH` | (可选) pages 部署的分支,可不配置,默认 `production` |
|
||||
| `PAGE_TOML` | (可选) 使用 page functions 转发后端请求时需要配置,请复制 `pages/wrangler.toml` 的内容,并根据实际情况修改 `service` 字段为你的 worker 后端名称 |
|
||||
| `PAGE_TOML` | (可选) 仅供 `Deploy Frontend with page function` workflow 使用。通过 page functions 转发后端请求时需要配置,请复制 `pages/wrangler.toml` 的内容,并根据实际情况修改 `service` 字段为你的 worker 后端名称。这个 workflow 会以 Pages 模式构建前端并走同域请求,因此不会读取 `FRONTEND_ENV` |
|
||||
| `TG_FRONTEND_NAME` | (可选) 你在 Cloudflare Pages 创建的项目名称,同 `FRONTEND_NAME`,如果需要 Telegram Mini App 功能,请填写 |
|
||||
|
||||
### 部署
|
||||
|
||||
- 打开仓库的 `Actions` 页面
|
||||
- 找到 `Deploy Backend` 点击 `Run workflow` 选择分支手动部署
|
||||
- 如果需要前后端分离部署, 找到 `Deploy Frontend`, 点击 `Run workflow` 选择分支手动部署
|
||||
- 如果需要前后端分离并直连 Worker, 找到 `Deploy Frontend`,点击 `Run workflow` 选择分支手动部署
|
||||
- 如果需要通过 Page Functions 转发后端请求的 Pages 部署, 找到 `Deploy Frontend with page function`,点击 `Run workflow` 手动部署
|
||||
|
||||
## 如何配置自动更新
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
cd worker
|
||||
cp wrangler.toml.template wrangler.toml
|
||||
# 创建 D1 并执行 schema.sql
|
||||
wrangler d1 create dev
|
||||
wrangler d1 execute dev --file=../db/schema.sql --remote
|
||||
wrangler d1 create temp-email-db
|
||||
wrangler d1 execute temp-email-db --file=../db/schema.sql --remote
|
||||
```
|
||||
|
||||
> [!tip] 命名建议
|
||||
> 数据库名称请使用例如 `temp-email-db`、`cloudflare-temp-email-prod` 这样的名称。
|
||||
|
||||
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
|
||||
|
||||

|
||||
@@ -25,6 +28,6 @@ wrangler d1 execute dev --file=../db/schema.sql --remote
|
||||
|
||||
```bash
|
||||
cd worker
|
||||
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql --remote
|
||||
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql --remote
|
||||
wrangler d1 execute temp-email-db --file=../db/2024-01-13-patch.sql --remote
|
||||
wrangler d1 execute temp-email-db --file=../db/2024-04-03-patch.sql --remote
|
||||
```
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
|
||||
## 前后端分离部署
|
||||
|
||||
> [!warning] 重要:SPA 模式
|
||||
> 本项目是单页应用(SPA)。如果你通过 Cloudflare 控制台手动上传部署,**必须在高级选项中将「未找到处理」设置为 `Single-page application (SPA)`**,否则刷新页面或直接访问 `/admin` 等子路径时会返回 404。
|
||||
> 通过 CLI(`wrangler pages deploy`)部署时会自动处理,无需额外配置。
|
||||
>
|
||||
> 
|
||||
|
||||
第一次部署会提示创建项目, `production` 分支请填写 `production`
|
||||
|
||||
```bash
|
||||
|
||||
@@ -35,6 +35,8 @@ compatibility_date = "2024-09-23"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
# 如果你想使用自定义域名,你需要添加 routes 配置
|
||||
# 将 pattern 替换为你自己的域名,该域名需要已添加到你的 Cloudflare 账户中
|
||||
# 配置后 Worker 将通过该自定义域名提供服务,而非默认的 *.workers.dev 域名
|
||||
# routes = [
|
||||
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
|
||||
# ]
|
||||
@@ -59,7 +61,8 @@ compatibility_flags = [ "nodejs_compat" ]
|
||||
PREFIX = "tmp"
|
||||
# 用于临时邮箱的所有域名, 支持多个域名
|
||||
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
|
||||
# 用于生成 jwt 的密钥, jwt 用于给用户登录以及鉴权
|
||||
# 用于签名 JWT 的密钥,JWT 用于登录鉴权
|
||||
# 请使用随机字符串,例如通过 openssl rand -hex 32 生成
|
||||
JWT_SECRET = "xxx"
|
||||
|
||||
# admin 控制台密码, 不配置则不允许访问控制台
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 常见问题
|
||||
# 常见问题 (FAQ)
|
||||
|
||||
> [!NOTE] 注意
|
||||
> 如果你的问题没有在这里找到解决方案,请到 `Github Issues` 中搜索或者提问, 或者到 Telegram 群组中提问。
|
||||
@@ -9,8 +9,9 @@
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| 使用 Cloudflare Workers 给已认证的转发邮箱发送邮件 | 使用 cf 的 API 进行发送,只支持绑定到 CF 上的收件地址,即 CF EMAIL 转发目的地址 |
|
||||
| 绑定多个域名 | 每个域名都需要设置 email 转发到 worker |
|
||||
| 子域名收不到邮件 | 子域名需要在 CF 上**单独启用** Email Routing 并配置 DNS 与 Catch-all 规则,仅在一级域开启不会自动覆盖子域,详见 [Email Routing](/zh/guide/email-routing) |
|
||||
|
||||
## worker 相关
|
||||
## Worker 相关
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
@@ -19,15 +20,31 @@
|
||||
| `二级域名无法发送邮件` | [参考](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515) |
|
||||
| `Failed to send verify code: No balance` | admin 后台设置无限制邮件或者发件权限页面增加额度 |
|
||||
| `Github OAuth无法获取到邮箱 400 Failed to get user email` | 需要 github 用户设置公开邮箱 |
|
||||
| `Cannot read properties of undefined (reading 'map')` | worker 变量没有设置成功 |
|
||||
| 页面初始化时报 `Cannot read properties of undefined (reading 'map')` | 先看 `/open_api/settings` 返回是否正常。如果是 Worker 直连部署,通常是 worker 变量没有设置成功,请检查 `DOMAINS`、`ADMIN_PASSWORDS` 等 JSON 格式变量是否正确配置;如果是 Pages 前端部署并且请求打到了错误地址,则继续看下方 Pages 相关排障 |
|
||||
|
||||
## pages 相关
|
||||
## Pages 相关
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| `network error` | 使用无痕模式或者清空浏览器缓存,DNS 缓存 |
|
||||
| Pages 部署后页面报 `map` 错误,或 `/admin/users`、`/admin/new_address` 等接口返回 `405 Method Not Allowed` | 通常是前端后端地址配置错误。请检查 `VITE_API_BASE`、UI 页面生成 zip 时填写的地址或 `FRONTEND_ENV`:前后端分离直连 Worker 时,应填写后端 Worker API 根地址,并且以 `https://` 开头、末尾不要带 `/`;如果使用 `PAGE_TOML` 通过 Page Functions 反代后端,则可保持 `VITE_API_BASE` 为空走同域请求。详见 [Pages 前端部署](/zh/guide/ui/pages) |
|
||||
| 刷新页面或直接访问 `/admin`、`/user` 返回 404 | 本项目是单页应用(SPA),通过 UI 部署 Pages 时需要在高级选项中将「未找到处理」设置为 `Single-page application (SPA)`。详见 [Pages 前端部署](/zh/guide/ui/pages) |
|
||||
|
||||
## telegram bot
|
||||
## 发送邮件相关
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| 设置了 `DEFAULT_SEND_BALANCE` 但仍提示 `No balance` | 先刷新前端设置页或重试发送。当 `DEFAULT_SEND_BALANCE > 0` 时,系统只会为**尚无 `address_sender` 记录**的地址自动初始化默认额度;已有记录(包括历史 `balance = 0 且 enabled = 0` 的行、管理员禁用或手动设置的行)不会被 runtime 修改,需要管理员在后台手动启用并设置余额。也可以将地址加入「无限制发送地址列表」或配置 `NO_LIMIT_SEND_ROLE` |
|
||||
| 提示 `请先为此域名启用 resend 或 smtp` | 需要先配置 `RESEND_TOKEN` 或 `SMTP_CONFIG`,详见 [配置发送邮件](/zh/guide/config-send-mail) |
|
||||
| `SMTP_CONFIG` 配置了但发送失败 | 请确认 JSON 中的 key 是**你自己的发信域名**(如 `your-domain.com`),不要直接复制示例 key。详见 [配置发送邮件](/zh/guide/config-send-mail#使用-smtp-发送邮件) |
|
||||
|
||||
## 邮件客户端相关
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| 设置了 `ENABLE_ADDRESS_PASSWORD` 但 Foxmail/Outlook 等客户端无法登录 | `ENABLE_ADDRESS_PASSWORD` 只是开启「地址密码登录」Web 接口,**不等于**提供标准 IMAP/SMTP 服务。要使用邮件客户端收发邮件,需要额外部署 [SMTP/IMAP 代理服务](/zh/guide/feature/config-smtp-proxy) |
|
||||
|
||||
## Telegram Bot
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| -------------------------------------------------------------- | -------------------------------------------------- |
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
|
||||
# 配置发送邮件
|
||||
|
||||
::: warning 注意
|
||||
三种方式可以同时配置,发送邮件时会优先使用 `resend`,如果没有配置 `resend`,则会使用 `smtp`.
|
||||
::: tip 推荐方案
|
||||
推荐使用 Cloudflare `send_email` binding 作为默认发信通道。绑定 `SEND_MAIL` 并完成 Email Routing onboarding 后,即可直接向任意外部地址发信。
|
||||
|
||||
如果配置了 Cloudflare 已认证的转发邮箱地址,会优先使用 cf 内部 API 发送邮件
|
||||
Workers Paid 每月含 3,000 封,超出部分 $0.35 / 1000 封。
|
||||
:::
|
||||
|
||||
## 使用 resend 发送邮件
|
||||
## 发信通道优先级
|
||||
|
||||
每次 `/api/send_mail` 请求按如下顺序匹配通道,**命中即发送**:
|
||||
|
||||
| 顺序 | 条件 | 通道 | 扣 balance |
|
||||
|------|------|------|-----------|
|
||||
| 1 | `SEND_MAIL` 已绑定 **且** 收件人在 `verifiedAddressList` | Cloudflare binding(兼容模式) | 否 |
|
||||
| 2 | `RESEND_TOKEN` 或 `RESEND_TOKEN_<DOMAIN>` 已配置 | Resend API | 是 |
|
||||
| 3 | `SMTP_CONFIG` 含当前域名配置 | worker-mailer SMTP | 是 |
|
||||
| 4 | `SEND_MAIL` 已绑定(以上均未命中) | **Cloudflare binding(推荐主通道)** | 是 |
|
||||
| — | 以上均未命中 | 抛错 | — |
|
||||
|
||||
> [!NOTE]
|
||||
> binding 发信失败会直接报错。
|
||||
|
||||
## 使用 Cloudflare `send_email` binding(推荐)
|
||||
|
||||
仅 CLI 部署时使用,在 `wrangler.toml` 中添加:
|
||||
|
||||
```toml
|
||||
# 通过 Cloudflare send_email binding 发送邮件
|
||||
send_email = [
|
||||
{ name = "SEND_MAIL" },
|
||||
]
|
||||
```
|
||||
|
||||
> [!warning] 重要
|
||||
> 绑定名必须为 `SEND_MAIL`,与 Cloudflare 官方文档示例中的 `SEND_EMAIL` 不同。
|
||||
|
||||
完成下列步骤后即可直接向任意外部地址发信:
|
||||
|
||||
1. 在 Cloudflare Dashboard 给对应域名开启 Email Routing 并完成 onboarding
|
||||
2. `wrangler.toml` 添加上述 `send_email` 绑定
|
||||
3. 部署 Worker
|
||||
|
||||
无需配置任何额外的 env var。
|
||||
|
||||
## 使用 Resend 发送邮件
|
||||
|
||||
注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
|
||||
|
||||
@@ -35,12 +72,18 @@ wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
|
||||
|
||||
## 使用 SMTP 发送邮件
|
||||
|
||||
`SMTP_CONFIG` 的格式如下,key 为域名,value 为 SMTP 配置,SMTP 配置格式详情可以参考 [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
`SMTP_CONFIG` 的格式如下,**key 必须是你自己的发信域名**,value 为 SMTP 配置。
|
||||
|
||||
SMTP 配置格式详情可以参考 [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
|
||||
> [!warning] 重要
|
||||
> JSON 中的 key(如下面示例中的 `your-domain.com`)必须替换为**你自己的域名**,即 `DOMAINS` 变量中配置的域名。
|
||||
> 这是最常见的配置错误之一,请勿直接复制示例中的域名。
|
||||
|
||||
```json
|
||||
{
|
||||
"awsl.uk": {
|
||||
"host": "smtp.xxx.com",
|
||||
"your-domain.com": {
|
||||
"host": "smtp.example.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authType": [
|
||||
@@ -48,13 +91,46 @@ wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
|
||||
"login"
|
||||
],
|
||||
"credentials": {
|
||||
"username": "username",
|
||||
"password": "password"
|
||||
"username": "your-smtp-username",
|
||||
"password": "your-smtp-password"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| key(如 `your-domain.com`) | 你的发信域名,必须与 `DOMAINS` 中配置的域名一致 |
|
||||
| `host` | SMTP 服务器地址,如 `smtp.mailgun.org`、`smtp.gmail.com` 或你自建的 SMTP 服务器地址 |
|
||||
| `port` | SMTP 端口,通常 `465`(SSL)或 `587`(STARTTLS) |
|
||||
| `secure` | 是否使用 SSL/TLS,端口 465 时设为 `true`,端口 587 时设为 `false` |
|
||||
| `authType` | 认证方式,一般使用 `["plain", "login"]` |
|
||||
| `credentials.username` | SMTP 服务器的登录用户名 |
|
||||
| `credentials.password` | SMTP 服务器的登录密码 |
|
||||
|
||||
如果你有**多个域名**使用不同的 SMTP 服务,在同一个 JSON 中添加多个 key 即可:
|
||||
|
||||
```json
|
||||
{
|
||||
"domain-a.com": {
|
||||
"host": "smtp.mailgun.org",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authType": ["plain", "login"],
|
||||
"credentials": { "username": "user@domain-a.com", "password": "xxx" }
|
||||
},
|
||||
"domain-b.com": {
|
||||
"host": "smtp.gmail.com",
|
||||
"port": 465,
|
||||
"secure": true,
|
||||
"authType": ["plain", "login"],
|
||||
"credentials": { "username": "user@gmail.com", "password": "app-password" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后执行下面的命令,将 `SMTP_CONFIG` 添加到 secrets 中
|
||||
|
||||
> [!NOTE]
|
||||
@@ -68,17 +144,29 @@ cd worker
|
||||
wrangler secret put SMTP_CONFIG
|
||||
```
|
||||
|
||||
## 发信余额机制
|
||||
|
||||
用户发送邮件需要有发信余额。余额机制如下:
|
||||
|
||||
1. **自动初始化默认额度**:当 `DEFAULT_SEND_BALANCE > 0` 时,用户打开前端发信页或第一次调用发信接口时,系统会自动为该地址初始化默认额度
|
||||
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`,用户仍可以在前端界面点击「申请发信权限」按钮,创建待管理员处理的发信权限记录
|
||||
3. **无限制发送**:以下方式可以跳过余额检查:
|
||||
- 在 admin 后台将地址加入「无限制发送地址列表」
|
||||
- 配置 `NO_LIMIT_SEND_ROLE` 环境变量,指定可以无限发送的用户角色
|
||||
|
||||
> [!NOTE]
|
||||
> `DEFAULT_SEND_BALANCE` 仅在地址尚无 `address_sender` 记录时自动插入初始额度(`ON CONFLICT DO NOTHING`),已有记录(包括管理员禁用或手动设置的行)一律保持原样,runtime 不会修改;历史异常或被禁用的地址需由管理员在后台手动启用并设置余额。
|
||||
>
|
||||
> 第 1 层 `verifiedAddressList` 命中时不扣余额,但同样计入发信额度;第 2/3/4 层统一扣 balance。
|
||||
>
|
||||
> 发信额度对**全部**发信渠道生效,admin 发信接口也会一起计入。
|
||||
>
|
||||
> 每日和每月额度按 **UTC** 时间窗口计算。
|
||||
>
|
||||
> 当前额度实现属于 **soft guard**,适合日常额度控制;在数据库异常或高并发场景下,它不适合作为绝对严格的成本硬闸。
|
||||
|
||||
## 给 Cloudflare 上已认证的转发邮箱发送邮件
|
||||
|
||||
仅支持 CLI 部署时使用,在 `wrangler.toml` 中添加 `send_email` 配置
|
||||
适合未完成 Email Routing onboarding 的域名,或 Workers 免费版。
|
||||
|
||||
发送的目的邮箱地址必须是 Cloudflare 上已认证的邮箱地址,局限性较大,如果需要发送邮件给其他邮箱,可以使用 `resend` 或者 `smtp` 发送邮件
|
||||
|
||||
```toml
|
||||
# 通过 Cloudflare 发送邮件
|
||||
send_email = [
|
||||
{ name = "SEND_MAIL" },
|
||||
]
|
||||
```
|
||||
|
||||
admin 后台 账号配置 `已验证地址列表(可通过 cf 内部 api 发送邮件)`
|
||||
只有收件人在 admin 后台的 `已验证地址列表` 中时,才会通过 `SEND_MAIL` binding 发信。
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
3. 配置每个域名的 `Email Routing` 的路由规则中的 `Catch-all 地址` 发送到 `worker`
|
||||
|
||||

|
||||
|
||||
> [!WARNING] 子域需要单独配置
|
||||
> 如果你要用**子域名**(如 `mail.example.com`)收信,必须在 CF 控制台里对 **该子域** 单独启用 `Email Routing`,并配置邮件 DNS 记录与 Catch-all 规则。仅在一级域名上开启 Email Routing **不会自动覆盖子域名**。
|
||||
|
||||
@@ -10,6 +10,48 @@
|
||||
|
||||

|
||||
|
||||
## 账号列表排序
|
||||
|
||||
管理后台的账号标签页支持按列排序,可点击表头对以下列进行升序/降序排列:
|
||||
|
||||
- ID
|
||||
- 名称
|
||||
- 创建时间
|
||||
- 更新时间
|
||||
- 邮件数量
|
||||
- 发送数量
|
||||
|
||||
搜索邮箱地址时,分页会自动重置到第 1 页。
|
||||
|
||||
## 如果你的网站只可私人访问,可通过此禁用检查
|
||||
|
||||
`DISABLE_ADMIN_PASSWORD_CHECK = true`
|
||||
|
||||
## IP 黑名单 / 白名单
|
||||
|
||||
在 Admin 控制台 → **IP 黑名单设置** 页面可配置访问控制,作用于以下接口:创建邮箱地址、发送邮件、外部发送邮件 API、用户注册、验证码校验。
|
||||
|
||||
### IP 白名单(严格模式)
|
||||
|
||||
启用后,**仅**匹配白名单的 IP 才能访问受保护接口,其他所有 IP 一律返回 403。
|
||||
|
||||
- 纯文本条目:精确匹配(不支持子串),例如 `1.2.3.4`
|
||||
- 正则条目:使用锚定正则,例如 `^192\.168\.1\.\d+$`
|
||||
- 白名单命中的 IP 会跳过黑名单检查
|
||||
- 白名单启用但列表为空时,服务端忽略该开关(防止锁死)
|
||||
|
||||
### IP 黑名单
|
||||
|
||||
启用后,匹配黑名单的 IP 返回 403。支持文本子串匹配或正则表达式。
|
||||
|
||||
### ASN 组织黑名单
|
||||
|
||||
按运营商/ISP 拉黑,不区分大小写,支持文本匹配或正则。
|
||||
|
||||
### 浏览器指纹黑名单
|
||||
|
||||
按 `x-fingerprint` 请求头拉黑,支持精确匹配或正则。
|
||||
|
||||
### 每日请求限流
|
||||
|
||||
限制单个 IP 每天最多请求次数(1–1,000,000),超出返回 429。计数以 UTC 日期为周期,24 小时后自动重置。
|
||||
|
||||
42
vitepress-docs/docs/zh/guide/feature/delete-address.md
Normal file
42
vitepress-docs/docs/zh/guide/feature/delete-address.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 删除邮箱地址 API
|
||||
|
||||
## 管理员删除地址 API
|
||||
|
||||
使用地址 ID 删除邮箱地址。该接口需要管理员鉴权,并会同时清理关联数据(收件、发件来源授权、用户绑定等)。
|
||||
|
||||
```bash
|
||||
DELETE /admin/delete_address/:id
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
- `x-admin-auth: <admin_password>`
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
## 普通地址删除 API
|
||||
|
||||
使用地址 JWT 删除当前邮箱。该接口会清理关联数据(收件、发件、自动回复、sender 绑定、用户绑定、Telegram 绑定等)。
|
||||
|
||||
```bash
|
||||
DELETE /api/delete_address
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
- `Authorization: Bearer <address_jwt>`
|
||||
|
||||
说明:
|
||||
|
||||
- 需开启 `ENABLE_USER_DELETE_EMAIL = true`
|
||||
- 地址凭证来自 `/api/new_address` 或 `/admin/new_address`
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
@@ -131,6 +131,14 @@ print(response.json())
|
||||
|
||||
## user 邮件 API
|
||||
|
||||
::: warning 注意:用户 JWT vs 地址 JWT
|
||||
此接口使用**用户 JWT**(通过 `/user_api/login` 或 `/user_api/register` 获得),使用 `x-user-token` header。
|
||||
|
||||
**请勿与地址 JWT 混淆**:
|
||||
- 地址 JWT 使用 `Authorization: Bearer <jwt>` 访问 `/api/*` 接口
|
||||
- 用户 JWT 使用 `x-user-token: <jwt>` 访问 `/user_api/*` 接口
|
||||
:::
|
||||
|
||||
支持 `address` 过滤
|
||||
|
||||
```python
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 新建邮箱地址 API
|
||||
|
||||
::: warning 注意:地址 JWT vs 用户 JWT
|
||||
本页面介绍的是**地址 JWT**,与**用户 JWT** 是两种不同的认证方式:
|
||||
|
||||
- **地址 JWT**:通过 `/api/new_address` 或 `/admin/new_address` 创建邮箱时返回
|
||||
- 使用 `Authorization: Bearer <jwt>` header
|
||||
- 用于访问 `/api/*` 接口(查看邮件、删除邮件等)
|
||||
|
||||
- **用户 JWT**:通过 `/user_api/login` 或 `/user_api/register` 获得
|
||||
- 使用 `x-user-token: <jwt>` header
|
||||
- 用于访问 `/user_api/*` 接口(用户账户管理)
|
||||
|
||||
**请勿混淆两种 JWT 的使用方式!**
|
||||
:::
|
||||
|
||||
## 通过 admin API 新建邮箱地址
|
||||
|
||||
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
|
||||
@@ -21,10 +35,35 @@ res = requests.post(
|
||||
}
|
||||
)
|
||||
|
||||
# 返回值 {"jwt": "<Jwt>"}
|
||||
# 返回值 {"jwt": "<Jwt>", "address": "<邮箱地址>", "address_id": 123}
|
||||
print(res.json())
|
||||
```
|
||||
|
||||
### 创建子域名邮箱地址
|
||||
|
||||
如果你已经把基础域名配置进 `DOMAINS` / `DEFAULT_DOMAINS` / `USER_ROLES`,并且开启了
|
||||
`ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH`(管理后台也可单独开关),那么创建地址 API 可以直接接收子域名:
|
||||
|
||||
```python
|
||||
res = requests.post(
|
||||
"https://xxxx.xxxx/admin/new_address",
|
||||
json={
|
||||
"enablePrefix": True,
|
||||
"name": "project001",
|
||||
"domain": "team.example.com",
|
||||
},
|
||||
headers={
|
||||
'x-admin-auth': "<你的网站admin密码>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- 如果允许域名里有 `example.com`,则 `team.example.com`、`dev.team.example.com` 都可以匹配成功
|
||||
- `badexample.com` 这种**不是点分后缀**的域名不会被误判为 `example.com`
|
||||
- 这与 `RANDOM_SUBDOMAIN_DOMAINS` 不同:这里是**由调用方显式指定子域名**,不是系统自动生成随机子域名
|
||||
- 管理后台可以把该能力设置为“跟随环境变量 / 强制开启 / 强制关闭”;其中“跟随环境变量”会清空后台覆盖,恢复到未设置后按 env 回退
|
||||
|
||||
## 批量创建随机用户名邮箱地址 API 示例
|
||||
|
||||
### 通过 admin API 批量新建邮箱地址
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
## 通过 HTTP API 发送邮件
|
||||
|
||||
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
|
||||
有两种 HTTP API 端点可以发送邮件,区别如下:
|
||||
|
||||
| 端点 | 认证方式 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `/api/send_mail` | `Authorization: Bearer <地址JWT>` header | 内部调用,需要先通过 cookie / header 鉴权 |
|
||||
| `/external/api/send_mail` | 请求体中的 `token` 字段 | 外部系统集成,无需 header 鉴权 |
|
||||
|
||||
::: tip 什么是"地址 JWT"?
|
||||
地址 JWT 是通过 `/api/new_address` 或 `/admin/new_address` 创建邮箱地址时返回的 `jwt` 字段。
|
||||
你可以在前端 UI 的「密码」菜单中查看它。它**不是** `JWT_SECRET` 环境变量,也**不是** admin 密码。
|
||||
:::
|
||||
|
||||
### 方式一:通过 Header 认证(`/api/send_mail`)
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
@@ -15,17 +27,22 @@ send_body = {
|
||||
}
|
||||
|
||||
res = requests.post(
|
||||
"http://localhost:8787/api/send_mail",
|
||||
"https://你的worker域名/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Authorization": f"Bearer {你的JWT密码}",
|
||||
"Authorization": f"Bearer {地址JWT}",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
# 使用 body 验证
|
||||
### 方式二:通过 Body Token 认证(`/external/api/send_mail`)
|
||||
|
||||
适合外部系统调用,将地址 JWT 放在请求体的 `token` 字段中:
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
"token": "<你的JWT密码>",
|
||||
"token": "<地址JWT>",
|
||||
"from_name": "发件人名字",
|
||||
"to_name": "收件人名字",
|
||||
"to_mail": "收件人地址",
|
||||
@@ -34,7 +51,7 @@ send_body = {
|
||||
"content": "<邮件内容:html 或者 文本>",
|
||||
}
|
||||
res = requests.post(
|
||||
"http://localhost:8787/external/api/send_mail",
|
||||
"https://你的worker域名/external/api/send_mail",
|
||||
json=send_body, headers={
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
"Content-Type": "application/json"
|
||||
|
||||
@@ -9,3 +9,49 @@ mail channel 已不被支持,下面参考中仅限收件部分。
|
||||
参考
|
||||
|
||||
- [配置子域名邮箱](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)
|
||||
|
||||
## 创建随机二级域名地址
|
||||
|
||||
如果你已经配置好了基础域名的收件路由,还可以让用户在创建邮箱时,自动生成随机二级域名地址,例如:
|
||||
|
||||
- 基础域名:`abc.com`
|
||||
- 创建结果:`name@x7k2p9q1.abc.com`
|
||||
|
||||
这适合做收件隔离、降低地址被重复命中的概率。
|
||||
|
||||
在 `worker` 变量中增加:
|
||||
|
||||
```toml
|
||||
RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
||||
RANDOM_SUBDOMAIN_LENGTH = 8
|
||||
```
|
||||
|
||||
- `RANDOM_SUBDOMAIN_DOMAINS`:允许启用随机二级域名的基础域名列表
|
||||
- `RANDOM_SUBDOMAIN_LENGTH`:随机串长度,范围 `1-63`,默认 `8`
|
||||
|
||||
> [!NOTE]
|
||||
> 这个功能只是在“创建地址”时自动补一个随机二级域名。
|
||||
>
|
||||
> 它不会自动帮你创建 Cloudflare 侧的子域名收件路由或 DNS 配置,请先确保基础域名/子域名路由本身已经可用。
|
||||
|
||||
## 允许 API 直接指定子域名
|
||||
|
||||
如果你不想让系统随机生成子域名,而是希望调用方在创建地址时直接指定 `team.abc.com` 这种子域名,
|
||||
可以开启:
|
||||
|
||||
```toml
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||
```
|
||||
|
||||
开启后,只要允许域名里包含基础域名 `abc.com`,那么:
|
||||
|
||||
- `name@team.abc.com`
|
||||
- `name@dev.team.abc.com`
|
||||
|
||||
都可以通过 `/api/new_address` 或 `/admin/new_address` 创建。
|
||||
|
||||
> [!NOTE]
|
||||
> 这个能力只放宽“创建地址 API 的域名校验”,不会改动默认域名下拉,也不会自动创建 Cloudflare 侧的
|
||||
> 子域名邮箱路由。
|
||||
>
|
||||
> 如果你在管理后台里保存过这个开关,后续也可以通过“跟随环境变量”把它恢复到未设置状态,再重新回退到 env 默认值。
|
||||
|
||||
@@ -7,14 +7,67 @@ import JSZip from 'jszip';
|
||||
const domain = ref("")
|
||||
const downloadUrl = ref("")
|
||||
const tip = ref("下载")
|
||||
const errorMessage = ref("")
|
||||
|
||||
const resetDownloadUrl = () => {
|
||||
if (!downloadUrl.value) {
|
||||
return
|
||||
}
|
||||
window.URL.revokeObjectURL(downloadUrl.value)
|
||||
downloadUrl.value = ""
|
||||
}
|
||||
|
||||
const validateDomain = (value) => {
|
||||
const normalizedValue = value.trim()
|
||||
if (!normalizedValue) {
|
||||
return "请输入以 https:// 开头的后端 API 地址"
|
||||
}
|
||||
if (/\s/.test(normalizedValue)) {
|
||||
return "后端 API 地址不能包含空白字符"
|
||||
}
|
||||
if (!normalizedValue.startsWith("https://")) {
|
||||
return "后端 API 地址必须以 https:// 开头"
|
||||
}
|
||||
if (normalizedValue.endsWith("/")) {
|
||||
return "后端 API 地址末尾不要带 /"
|
||||
}
|
||||
try {
|
||||
const url = new URL(normalizedValue)
|
||||
if (url.protocol !== "https:") {
|
||||
return "后端 API 地址必须以 https:// 开头"
|
||||
}
|
||||
if (url.pathname !== "/" || url.search || url.hash) {
|
||||
return "请填写后端 API 根地址,不要带路径、参数或锚点"
|
||||
}
|
||||
} catch {
|
||||
return "后端 API 地址格式不正确"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const generate = async () => {
|
||||
const normalizedDomain = domain.value.trim()
|
||||
const validationError = validateDomain(normalizedDomain)
|
||||
errorMessage.value = validationError
|
||||
resetDownloadUrl()
|
||||
if (validationError) {
|
||||
return
|
||||
}
|
||||
domain.value = normalizedDomain
|
||||
let timeoutId = 0
|
||||
try {
|
||||
const response = await fetch("/ui_install/frontend.zip");
|
||||
const controller = new AbortController()
|
||||
timeoutId = window.setTimeout(() => controller.abort(), 10000)
|
||||
const response = await fetch("/ui_install/frontend.zip", {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
errorMessage.value = "下载前端压缩包失败,请稍后重试"
|
||||
return
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
var zip = new JSZip();
|
||||
await zip.loadAsync(arrayBuffer);
|
||||
let target_content = ""
|
||||
let target_path = ""
|
||||
const directory = zip.folder("assets");
|
||||
if (directory) {
|
||||
@@ -22,7 +75,7 @@ const generate = async () => {
|
||||
console.log(relativePath);
|
||||
if (relativePath.startsWith("assets/index-") && relativePath.endsWith(".js")){
|
||||
let content = await zipEntry.async("string");
|
||||
content = content.replace("https://temp-email-api.xxx.xxx", domain.value);
|
||||
content = content.replaceAll("https://temp-email-api.xxx.xxx", normalizedDomain);
|
||||
target_path = relativePath;
|
||||
zip.file(relativePath, content);
|
||||
break;
|
||||
@@ -30,14 +83,22 @@ const generate = async () => {
|
||||
}
|
||||
}
|
||||
if (!target_path) {
|
||||
tip.value = "生成失败";
|
||||
downloadUrl.value = '';
|
||||
errorMessage.value = "没有找到前端入口文件,生成失败"
|
||||
return
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
errorMessage.value = ""
|
||||
downloadUrl.value = url;
|
||||
} catch (error) {
|
||||
console.error("Error: ", error);
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
errorMessage.value = "下载超时,请刷新页面后重试"
|
||||
return
|
||||
}
|
||||
errorMessage.value = "生成失败,请刷新页面后重试"
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -50,27 +111,38 @@ const generate = async () => {
|
||||
|
||||

|
||||
|
||||
3. 输入部署的 worker 的地址, 地址不要带 `/`,点击生成,成功会出现下载按钮,你会得到一个 zip 包
|
||||
3. 输入部署的 worker 地址,必须填写后端 API 根地址,并且以 `https://` 开头,地址不要带 `/`,点击生成,成功会出现下载按钮,你会得到一个 zip 包
|
||||
- 此处 worker 域名为后端 api 的域名,比如我部署在 `https://temp-email-api.awsl.uk`,则填写 `https://temp-email-api.awsl.uk`
|
||||
- 如果你的域名是 `https://temp-email-api.xxx.workers.dev`,则填写 `https://temp-email-api.xxx.workers.dev`
|
||||
- 不要填写前端 `Pages` 自己的域名,也不要带 `/admin`、`/api` 等路径,否则前端请求会打到错误地址,可能出现 `Cannot read properties of undefined (reading 'map')` 或 `405 Method Not Allowed`
|
||||
|
||||
> [!warning] 注意
|
||||
> `worker.dev` 域名在中国无法访问,请自定义域名
|
||||
|
||||
<div :class="$style.container">
|
||||
<input :class="$style.input" type="text" v-model="domain" placeholder="请输入地址"></input>
|
||||
<input :class="$style.input" type="text" v-model="domain" placeholder="请输入以 https:// 开头的后端 API 地址"></input>
|
||||
<button :class="$style.button" @click="generate">生成</button>
|
||||
<a v-if="downloadUrl" :href="downloadUrl" download="frontend.zip">{{ tip }}</a>
|
||||
</div>
|
||||
<p :class="$style.hint">示例:`https://temp-email-api.example.com`,不要填写前端 Pages 域名,也不要带结尾 `/`。</p>
|
||||
<p v-if="errorMessage" :class="$style.error">{{ errorMessage }}</p>
|
||||
|
||||
> [!NOTE]
|
||||
> 你也可以手动部署,从这里下载 zip, [frontend.zip](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip)
|
||||
>
|
||||
> 修改压缩包里面的 index-xxx.js 文件 ,xx 是随机的字符串
|
||||
>
|
||||
> 搜索 `https://temp-email-api.xxx.xxx` ,替换成你worker 的域名,然后部署新的zip文件
|
||||
> 搜索 `https://temp-email-api.xxx.xxx` ,替换成你 worker 的后端 API 根地址,然后部署新的 zip 文件。如果填成前端 Pages 域名,常见现象就是页面报 `map` 错误或接口返回 `405`
|
||||
|
||||
4. 选择 `Pages`,点击 `Create Pages`, 修改名称,上传下载的 zip 包,然后点击 `Deploy`
|
||||
4. 选择 `Pages`,点击 `Create Pages`, 修改名称,上传下载的 zip 包
|
||||
|
||||
> [!warning] 重要:SPA 模式
|
||||
> 本项目是单页应用(SPA),**必须在部署时展开高级选项,将「未找到处理」设置为 `Single-page application (SPA)`**。
|
||||
> 否则刷新页面或直接访问 `/admin` 等子路径时会返回 404。
|
||||
>
|
||||
> 
|
||||
|
||||
然后点击 `Deploy`
|
||||
|
||||

|
||||
|
||||
@@ -102,4 +174,14 @@ const generate = async () => {
|
||||
.button:hover {
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 8px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 8px;
|
||||
color: #d03050;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| 变量名 | 类型 | 说明 | 示例 |
|
||||
| -------------------------- | ----------- | ------------------------------------------ | ------------------------------------ |
|
||||
| `DOMAINS` | JSON | 用于临时邮箱的所有域名, 支持多个域名 | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `JWT_SECRET` | 文本/Secret | 用于生成 jwt 的密钥, jwt 用于登录以及鉴权 | `xxx` |
|
||||
| `JWT_SECRET` | 文本/Secret | 用于签名 JWT 的密钥,JWT 用于登录鉴权。请使用随机字符串,例如通过 `openssl rand -hex 32` 生成 | `a1b2c3d4...` |
|
||||
| `ADMIN_PASSWORDS` | JSON | admin 控制台密码, 不配置则不允许访问控制台 | `["123", "456"]` |
|
||||
| `ENABLE_USER_CREATE_EMAIL` | 文本/JSON | 是否允许用户创建邮箱, 不配置则不允许 | `true` |
|
||||
| `ENABLE_USER_DELETE_EMAIL` | 文本/JSON | 是否允许用户删除邮件, 不配置则不允许 | `true` |
|
||||
@@ -32,10 +32,32 @@
|
||||
| `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` |
|
||||
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true,当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
|
||||
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | 文本/JSON | 是否允许创建邮箱 API 使用“基础域名后缀匹配”。开启后,如果允许域名里有 `example.com`,则 `/api/new_address` 与 `/admin/new_address` 可以接受 `foo.example.com`、`a.b.example.com` 这类子域名 | `true` |
|
||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | 允许启用随机子域名的基础域名列表,启用后可把 `name@abc.com` 创建成 `name@随机串.abc.com` | `["abc.com"]` |
|
||||
| `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` |
|
||||
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/`) | `true` |
|
||||
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` |
|
||||
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额;当值大于 `0` 时,用户打开前端设置页或首次发送邮件时会自动初始化该额度。如果不设置,将为 `0` | `1` |
|
||||
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
|
||||
| `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
|
||||
|
||||
> [!NOTE]
|
||||
> `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare
|
||||
> 侧的子域名路由。
|
||||
>
|
||||
> 子域名地址通常更适合收件;如果要发件,仍建议优先使用主域名。
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与随机子域名功能不同:它允许 API 调用方**直接指定**
|
||||
> `foo.example.com` 这类子域名;而随机子域名功能是系统在创建时自动补一个随机前缀。
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 的优先级为:当 env 明确设置为 `false` 时,全局硬禁用;
|
||||
> 其他情况下优先使用后台持久化设置,后台未设置时再回退到 env 值。
|
||||
>
|
||||
> 管理后台提供三种显式状态:**跟随环境变量**、**强制开启**、**强制关闭**。当你选择
|
||||
> “跟随环境变量”并保存时,会清空后台覆盖,恢复到“未设置”的回退行为。
|
||||
>
|
||||
> `SEND_MAIL_DOMAINS` 只影响 `SEND_MAIL` binding 的兜底发信路径和 `/admin/send_mail_by_binding`。
|
||||
> 它不影响 Resend、SMTP、`verifiedAddressList` 等其他发信通道。
|
||||
|
||||
## 接受邮件相关变量
|
||||
|
||||
@@ -48,8 +70,11 @@
|
||||
| `FORWARD_ADDRESS_LIST` | JSON | 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址 | `["xxx@xxx.com"]` |
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB,则删除附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | 文本/JSON | 移除所有附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | 文本/JSON | 启用后新邮件将 Gzip 压缩存储到 `raw_blob` 字段,可节省 D1 数据库空间。已有明文 `raw` 数据自动兼容读取。**启用前请先执行数据库迁移(`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema` 或 `POST /admin/db_migration`),确保 `raw_blob` 列已创建。该功能会增加压缩/解压 CPU 开销,建议使用 Cloudflare Worker 付费 Plan 再开启。** | `true` |
|
||||
|
||||
> [!NOTE]
|
||||
> `ENABLE_MAIL_GZIP` 会增加邮件写入压缩与读取解压的 CPU 消耗,免费版 Worker 更容易触发 CPU 限制,建议付费 Plan 再开启
|
||||
>
|
||||
> `垃圾邮件检查` 和 `移除附件功能` 需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时
|
||||
>
|
||||
> 如果你想解析邮件能力更强
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp-mail-docs",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.8.0",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"vitepress": "^1.6.4",
|
||||
"wrangler": "^4.72.0"
|
||||
"wrangler": "^4.83.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vitepress dev docs",
|
||||
|
||||
748
vitepress-docs/pnpm-lock.yaml
generated
748
vitepress-docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.5.0",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,23 +11,23 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260310.1",
|
||||
"@cloudflare/workers-types": "^4.20260420.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@types/node": "^25.4.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"eslint": "9.39.1",
|
||||
"globals": "^16.5.0",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"wrangler": "^4.72.0"
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"wrangler": "^4.83.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.888.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.888.0",
|
||||
"@simplewebauthn/server": "13.2.3",
|
||||
"hono": "^4.12.7",
|
||||
"hono": "^4.12.14",
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"mimetext": "^3.0.28",
|
||||
"postal-mime": "^2.7.3",
|
||||
"resend": "^6.9.3",
|
||||
"postal-mime": "^2.7.4",
|
||||
"resend": "^6.12.0",
|
||||
"telegraf": "4.16.3",
|
||||
"worker-mailer": "^1.2.1"
|
||||
},
|
||||
|
||||
1182
worker/pnpm-lock.yaml
generated
1182
worker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
133
worker/src/admin_api/account_settings_api.ts
Normal file
133
worker/src/admin_api/account_settings_api.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { getJsonSetting, saveSetting } from '../utils'
|
||||
import { getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import {
|
||||
getSendMailLimitConfig,
|
||||
getSendMailLimitConfigToSave,
|
||||
validateSendMailLimitConfig
|
||||
} from '../mails_api/send_mail_limit_utils'
|
||||
import { EmailRuleSettings } from '../models'
|
||||
|
||||
const normalizeAddressCreationSettingsUpdate = (
|
||||
value: unknown
|
||||
): {
|
||||
shouldUpdate: boolean,
|
||||
shouldClear: boolean,
|
||||
nextEnableSubdomainMatch?: boolean,
|
||||
} | null => {
|
||||
if (typeof value === 'undefined') {
|
||||
return { shouldUpdate: false, shouldClear: false };
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
|
||||
if (typeof nextEnableSubdomainMatch === 'undefined') {
|
||||
return { shouldUpdate: false, shouldClear: false };
|
||||
}
|
||||
// null 代表"清空后台覆盖,恢复为未设置并回退到 env",这是给前端三态显式使用的正式路径。
|
||||
if (nextEnableSubdomainMatch === null) {
|
||||
return { shouldUpdate: true, shouldClear: true };
|
||||
}
|
||||
if (typeof nextEnableSubdomainMatch !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
shouldClear: false,
|
||||
nextEnableSubdomainMatch,
|
||||
};
|
||||
};
|
||||
|
||||
const get = async (c: Context<HonoCustomType>) => {
|
||||
try {
|
||||
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
|
||||
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
|
||||
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
|
||||
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
|
||||
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
|
||||
const addressCreationSettings = await getAddressCreationSettings(c);
|
||||
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
|
||||
const sendMailLimitConfig = await getSendMailLimitConfig(c);
|
||||
return c.json({
|
||||
blockList: blockList || [],
|
||||
sendBlockList: sendBlockList || [],
|
||||
verifiedAddressList: verifiedAddressList || [],
|
||||
fromBlockList: fromBlockList || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList || [],
|
||||
emailRuleSettings: emailRuleSettings || {},
|
||||
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
|
||||
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
|
||||
: {},
|
||||
addressCreationSubdomainMatchStatus,
|
||||
sendMailLimitConfig,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return c.json({})
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const {
|
||||
blockList, sendBlockList, noLimitSendAddressList,
|
||||
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings,
|
||||
sendMailLimitConfig
|
||||
} = await c.req.json();
|
||||
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
|
||||
if (!addressCreationSettingsUpdate) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
|
||||
return c.text(msgs.EnableSendMailMsg, 400)
|
||||
}
|
||||
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
|
||||
if (fromBlockList?.length > 0 && !c.env.KV) {
|
||||
return c.text(msgs.EnableKVMsg, 400)
|
||||
}
|
||||
if (sendMailLimitConfig && !validateSendMailLimitConfig(sendMailLimitConfig)) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
const sendMailLimitConfigToSave = sendMailLimitConfig
|
||||
? getSendMailLimitConfigToSave(sendMailLimitConfig)
|
||||
: null;
|
||||
await saveSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY, JSON.stringify(blockList));
|
||||
await saveSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY, JSON.stringify(sendBlockList));
|
||||
await saveSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY, JSON.stringify(verifiedAddressList));
|
||||
if (fromBlockList?.length > 0 && c.env.KV) {
|
||||
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
|
||||
}
|
||||
await saveSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY, JSON.stringify(noLimitSendAddressList || []));
|
||||
await saveSetting(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY, JSON.stringify(emailRuleSettings || {}));
|
||||
if (addressCreationSettingsUpdate.shouldUpdate) {
|
||||
if (addressCreationSettingsUpdate.shouldClear) {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM settings WHERE key = ?`
|
||||
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
|
||||
} else {
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sendMailLimitConfigToSave) {
|
||||
await saveSetting(
|
||||
c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY,
|
||||
JSON.stringify(sendMailLimitConfigToSave)
|
||||
)
|
||||
}
|
||||
return c.json({ success: true });
|
||||
};
|
||||
|
||||
export default { get, save };
|
||||
159
worker/src/admin_api/address_api.ts
Normal file
159
worker/src/admin_api/address_api.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Context } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { getBooleanValue, hashPassword } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
|
||||
const listAddresses = async (c: Context<HonoCustomType>) => {
|
||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||
const allowedSortColumns: Record<string, string> = {
|
||||
'id': 'a.id',
|
||||
'name': 'a.name',
|
||||
'created_at': 'a.created_at',
|
||||
'updated_at': 'a.updated_at',
|
||||
'source_meta': 'a.source_meta',
|
||||
'mail_count': 'mail_count',
|
||||
'send_count': 'send_count',
|
||||
};
|
||||
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
|
||||
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
|
||||
const orderBy = `${sortColumn} ${sortDirection}`;
|
||||
if (query) {
|
||||
// D1 caps LIKE pattern length at 50 bytes; fall back to instr() for
|
||||
// longer queries to avoid "LIKE or GLOB pattern too complex" (#956).
|
||||
const useInstr = new TextEncoder().encode(query).length + 2 > 50;
|
||||
const whereClause = useInstr ? `instr(name, ?) > 0` : `name like ?`;
|
||||
const param = useInstr ? query : `%${query}%`;
|
||||
return await handleListQuery(c,
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`
|
||||
+ ` where ${whereClause}`,
|
||||
`SELECT count(*) as count FROM address where ${whereClause}`,
|
||||
[param], limit, offset, orderBy
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`,
|
||||
`SELECT count(*) as count FROM address`,
|
||||
[], limit, offset, orderBy
|
||||
);
|
||||
};
|
||||
|
||||
const createNewAddress = async (c: Context<HonoCustomType>) => {
|
||||
const { name, domain, enablePrefix, enableRandomSubdomain } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!name) {
|
||||
return c.text(msgs.RequiredFieldMsg, 400)
|
||||
}
|
||||
try {
|
||||
const res = await newAddress(c, {
|
||||
name, domain, enablePrefix,
|
||||
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
|
||||
checkLengthByConfig: false,
|
||||
addressPrefix: null,
|
||||
checkAllowDomains: false,
|
||||
enableCheckNameRegex: false,
|
||||
sourceMeta: 'admin'
|
||||
});
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(id).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
const { success: usersAddressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ?`
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success && mailSuccess && sendAccess && usersAddressSuccess
|
||||
})
|
||||
};
|
||||
|
||||
const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
return c.json({ success: mailSuccess });
|
||||
};
|
||||
|
||||
const clearSentItems = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: sendboxSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!sendboxSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
return c.json({ success: sendboxSuccess });
|
||||
};
|
||||
|
||||
const showPassword = async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const name = await c.env.DB.prepare(
|
||||
`SELECT name FROM address WHERE id = ? `
|
||||
).bind(id).first("name");
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return c.json({ jwt });
|
||||
};
|
||||
|
||||
const resetPassword = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { password } = await c.req.json();
|
||||
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||
return c.text(msgs.PasswordChangeDisabledMsg, 403);
|
||||
}
|
||||
if (!password) {
|
||||
return c.text(msgs.NewPasswordRequiredMsg, 400);
|
||||
}
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).bind(hashedPassword, id).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedUpdatePasswordMsg, 500);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
};
|
||||
|
||||
export default {
|
||||
listAddresses, createNewAddress, deleteAddress, clearInbox, clearSentItems,
|
||||
showPassword, resetPassword
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user