Compare commits

...

33 Commits

Author SHA1 Message Date
dreamhunter2333
2e32cf472b feat: add cf-temp-mail-usage skill and parsed mail API for AI agents
- feat: new /api/parsed_mails and /api/parsed_mail/:id endpoints returning
  server-parsed subject/text/html/attachments metadata (reuses commonParseMail)
- feat: add .claude/skills/cf-temp-mail-usage read-only skill so AI agents
  (OpenClaw / Codex / Cursor) can consume a mailbox with a user-supplied JWT,
  bypassing the Turnstile challenge required for mailbox creation
- refactor: split mails_api/index.ts and admin_api/index.ts into thin route
  shells; move business logic into dedicated *_api.ts files
- docs: update README / README_EN / CHANGELOG with agent-email feature and
  npx degit install instructions for the skill

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 13:36:15 +08:00
Dream Hunter
296ddb8619 chore: bump v1.8.0, add release-notify skill, optimize docs deploy (#992)
- Upgrade version to 1.8.0 in all package.json files
- Add cf-temp-mail-release-notify skill with MarkdownV2 Telegram posting
- Optimize docs_deploy.yml to auto-trigger on Tag Build CI completion
- Add v1.8.0 placeholder in CHANGELOG.md and CHANGELOG_EN.md

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 16:29:17 +08:00
Dream Hunter
a5b64e1dc9 chore: rename project skills with cf-temp-mail prefix (#991)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 15:10:17 +08:00
Dream Hunter
fa19dbbe02 chore: upgrade dependencies (#990)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:53:56 +08:00
jiaxin
ebeb94ed23 fix: auto initialize default send balance (#985)
* fix: auto initialize default send balance

* fix: tighten send access auto init flow

* refactor: centralize send balance state

* fix: separate legacy repair from admin control in send balance

Add an `address_sender.source` column to distinguish legacy / auto /
user / admin rows. `ensureDefaultSendBalance` now only repairs rows
with `source IS NULL`, so admin-disabled and user-requested rows are
never overwritten. Admin POST writes tag `source = 'admin'`; new
auto-init inserts tag `'auto'`; `requestSendMailAccess` inserts tag
`'user'`.

Bumps DB_VERSION to v0.0.8 with the usual `PRAGMA table_info` guarded
ALTER, plus a standalone SQL patch under db/.

Adds E2E regressions: legacy repair path, admin-disabled rows stay
disabled across settings and send, send after admin deletion
auto-initializes a fresh row.

* fix: drop runtime legacy repair; backfill source='legacy' on migrate

Pre-v0.0.8 schema cannot distinguish legacy request-send-access
remnants from admin-disabled rows — both share `balance = 0,
enabled = 0`. Letting ensureDefaultSendBalance repair that shape on
upgrade could silently re-enable an admin-disabled row.

Remove the runtime repair path entirely:

- `ensureDefaultSendBalance` now uses `ON CONFLICT(address) DO NOTHING`;
  existing rows are never touched.
- The v0.0.8 migration (and the matching SQL patch) backfills every
  pre-existing row with `source = 'legacy'`, making pre-migration
  state explicitly off-limits to runtime auto-init.
- E2E: flip the legacy test to the negative direction — a
  `source='legacy'` zero-balance row stays untouched by settings
  reads and send attempts. Harden `resetSenderToLegacy` to return
  404 when `meta.changes < 1`.
- Update changelog and docs: legacy/admin-disabled rows must be
  restored manually via the admin UI.

* refactor: collapse send balance auto-init to missing-row insert

Per review feedback: the runtime guarantee we actually need is
"create an address_sender row when one is missing, leave existing
rows alone". Once `ensureDefaultSendBalance` switched to
`ON CONFLICT DO NOTHING`, the `source` column, the v0.0.8 migration,
and the `resetSenderToLegacy` test endpoint became dead weight —
the DO NOTHING path already protects admin-disabled and admin-edited
rows without any provenance metadata.

- Drop `address_sender.source` and the v0.0.8 migration; revert
  DB_VERSION to v0.0.7. No schema change ships with this PR.
- Strip the `source` field from `ensureDefaultSendBalance`,
  `requestSendMailAccess`, and the admin-update path.
- Remove the `/admin/test/reset_sender_to_legacy` test endpoint and
  its E2E helper; the negative legacy-repair test it served is no
  longer needed because the runtime no longer touches existing rows.
- E2E coverage stays focused on the three guardrails: missing-row
  auto-init, admin-disabled rows stay disabled, admin deletion
  triggers a fresh re-insert.
- Tighten changelog and docs to "auto-initialize missing rows".

* docs: align common-issues with missing-row-only auto-init

The FAQ entries for "DEFAULT_SEND_BALANCE set but still No balance"
still described the old behaviour of repairing legacy
`balance = 0 && enabled = 0` rows. Rewrite both zh and en rows to
match the current runtime: only addresses with no existing
`address_sender` row get auto-initialised; legacy, admin-disabled,
and admin-edited rows must be restored manually through the admin
console.
2026-04-20 12:40:14 +08:00
Dream Hunter
d1fb1f773b Fix send mail form validation (#989)
* fix: harden send mail form validation

* fix: tighten send mail content checks

* fix: refine send mail empty-content checks

* fix: reset send mail preview state
2026-04-18 15:29:48 +08:00
Dream Hunter
5c40eeec80 docs: add SEND_MAIL_DOMAINS docs (#988) 2026-04-18 12:28:15 +08:00
Dream Hunter
000cd0ddfa fix: limit SEND_MAIL domain checks to binding paths (#987)
* fix: scope SEND_MAIL domain gating to binding

* test: cover SEND_MAIL domain gating in e2e
2026-04-17 18:08:19 +08:00
Dream Hunter
e772db8c3e feat: add SEND_MAIL delivery and quota controls (#986)
* feat: add SEND_MAIL delivery and quota controls

* test: cover -1 unlimited runtime for send mail quota

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: split send limit validation and save

* refactor: move send limit counters to settings

* fix: polish send mail limit review follow-ups

* docs: note SEND_MAIL breaking change

* test: align send mail limit e2e with new messages

* fix: address review follow-ups

* fix: harden admin send mail handlers

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 11:37:14 +08:00
Dream Hunter
a5aa475380 chore: upgrade dependencies and bump version to v1.7.0 (#982)
- Upgrade deps across frontend/worker/pages/vitepress-docs (wrangler 4.82.2, dompurify 3.4.0, resend 6.11.0, etc.)
- Bump version to v1.7.0 in all package.json and worker constants
- Add v1.7.0 CHANGELOG placeholder; move #978/#930 Bug Fixes from v1.6.0 to v1.7.0 (merged after v1.6.0 tag)
- Add upgrade-dependencies skill; translate version-upgrade skill to English

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:13:40 +08:00
jiaxin
3221f5ae30 fix: lowercase configured address prefixes (#980)
* fix: normalize address casing for password login

Store new mailbox addresses in lowercase and migrate historical address data so mixed-case password logins can read inbox, sendbox, and settings consistently.

* fix: only lowercase configured address prefixes

Limit the #930 change to prefix normalization and document that existing mixed-case data must be migrated manually by users.
2026-04-14 15:59:25 +08:00
jiaxin
15e339282d fix: respect user mail deletion toggle in user center (#979)
* fix: respect user mail deletion toggle in user center

Hide user mailbox delete actions and block /user_api/mails deletion when ENABLE_USER_DELETE_EMAIL is disabled. Add an e2e regression test and changelog entries for issue #978.

* test: hash user password in mail deletion e2e

Use the same SHA-256 pre-hashed password format as the frontend for the user register/login flow in the mail deletion regression test.
2026-04-14 15:25:39 +08:00
dreamhunter2333
e15b1b83d0 ci: upgrade to upload-artifact@v7 and download-artifact@v8 for Node.js 24 2026-04-12 21:26:22 +08:00
dreamhunter2333
c297a49b2a ci: upgrade upload/download-artifact to v4 for Node.js 24 compatibility 2026-04-12 21:24:14 +08:00
dreamhunter2333
de3f9e92ed fix: add checkout step to release job in tag_build workflow 2026-04-12 21:22:05 +08:00
Dream Hunter
832e996dd8 docs: add missing PR #968 to CHANGELOG (#976) 2026-04-12 21:14:09 +08:00
Dream Hunter
e81c9d0d9c docs: add SPA settings screenshot (#975) 2026-04-12 21:03:05 +08:00
Dream Hunter
163df5c908 chore: update dependencies (#974)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:11:25 +08:00
Dream Hunter
c3058817ff feat(admin): add IP whitelist (strict allowlist mode) (#920) (#971)
* feat(admin): add IP whitelist (strict allowlist mode) (#920)

- Add enableWhitelist/whitelist fields to IpBlacklistSettings
- Implement three-layer access control: whitelist → blacklist → daily limit
- Whitelist uses exact match for IPv4/IPv6, regex for patterns
- Whitelisted IPs skip blacklist checks (trusted)
- Fail-closed when cf-connecting-ip missing under whitelist mode
- Frontend: independent whitelist toggle + empty list protection
- Backend: backward compatible (old frontends get defaults)
- E2E tests: config validation + runtime behavior
- Docs: CHANGELOG zh/en updated

Closes #920

* fix(admin): address PR review feedback on IP whitelist

- Add IPv4-mapped IPv6 (::ffff:x.x.x.x) exact match in isWhitelisted
- Include error.message in whitelist regex parse failure log
- Include actual/max size in whitelist size limit error message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): validate whitelist regex on save and preserve existing whitelist on partial update

- Reject invalid regex patterns in whitelist at save time to prevent runtime lockout
- Preserve existing enableWhitelist/whitelist from DB when older clients omit these fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): revert P2 - keep simple ?? defaults for backward compat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): validate whitelist elements are strings before trimming

Prevents 500 error when whitelist contains non-string elements (e.g. numbers, null)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(admin): add IP blacklist/whitelist documentation (zh + en)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): fix fingerprint blacklist bypass when cf-connecting-ip absent, improve e2e tests

- Split checkBlacklist into checkFingerprintBlacklist (IP-independent) and checkIpAsnBlacklist
- Fingerprint check now runs before the !reqIp early-return to prevent bypass
- Add afterEach reset to config test group, extract RESET_SETTINGS constant
- Strengthen whitelist-blocks test to deterministic 403 assertion
- Add e2e tests: invalid regex rejection, non-string element rejection, fingerprint-blocks-without-IP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): suppress no-useless-escape lint warning in whitelist regex check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:06:13 +08:00
dependabot[bot]
16c4e43871 chore(deps): bump nodemailer and imapflow in /e2e (#963)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) and [imapflow](https://github.com/postalsys/imapflow). These dependencies needed to be updated together.

Updates `nodemailer` from 8.0.4 to 8.0.5
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.4...v8.0.5)

Updates `imapflow` from 1.2.18 to 1.3.1
- [Release notes](https://github.com/postalsys/imapflow/releases)
- [Changelog](https://github.com/postalsys/imapflow/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postalsys/imapflow/compare/v1.2.18...v1.3.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.5
  dependency-type: direct:production
- dependency-name: imapflow
  dependency-version: 1.3.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 20:16:58 +08:00
dreamhunter2333
68cbfb9c32 Revert "feat(admin): add IP whitelist (strict allowlist mode) (#920)"
This reverts commit e18285d3ef.
2026-04-11 20:11:52 +08:00
dreamhunter2333
e18285d3ef feat(admin): add IP whitelist (strict allowlist mode) (#920)
- Add enableWhitelist/whitelist fields to IpBlacklistSettings
- Implement three-layer access control: whitelist → blacklist → daily limit
- Whitelist uses exact match for IPv4/IPv6, regex for patterns
- Whitelisted IPs skip blacklist checks (trusted)
- Fail-closed when cf-connecting-ip missing under whitelist mode
- Frontend: independent whitelist toggle + empty list protection
- Backend: backward compatible (old frontends get defaults)
- E2E tests: config validation + runtime behavior
- Docs: CHANGELOG zh/en updated

Closes #920
2026-04-11 18:49:09 +08:00
Dream Hunter
1584851a36 docs: note that subdomains need Email Routing enabled separately (#970)
Subdomains do not inherit Email Routing from the apex domain;
each subdomain must enable Email Routing and configure its own
DNS records and Catch-all rule.

Refs #969

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 16:32:20 +08:00
YewFence
1cafbbf220 feat(address): 支持最大地址数量设置为 0 表示无限制 (#968)
* feat(address): 支持最大地址数量设置为 0 表示无限制

- 移除角色配置中 =0 时回退到全局设置的逻辑
- 添加负数校验防止无效输入
- 更新前端文案说明 0 表示无限制

* fix(admin): 修复 maxAddressCount 验证逻辑,禁止负数和非对象输入

在 saveRoleAddressConfig 接口增加 configs 参数类型校验,
确保其为有效对象而非数组或 null。同时在 UserSettings 模型中
验证 maxAddressCount 必须大于等于 0,防止无效数据进入系统。

* style: 修正错误的缩进
2026-04-09 17:04:58 +08:00
Dream Hunter
873a10ddb1 docs: simplify D1 naming guidance (#961) 2026-04-08 01:26:28 +08:00
Dream Hunter
9689a1cbca docs: clarify Pages backend URL config (#960)
* docs: clarify pages backend url setup

* docs: refine pages and d1 examples

* docs: harden pages zip generator
2026-04-07 23:59:31 +08:00
Dream Hunter
ef475bab21 chore: upgrade frontend and worker dependencies (#959)
chore: upgrade project dependencies
2026-04-07 19:37:34 +08:00
Dream Hunter
e6ef110ec9 fix: avoid D1 LIKE pattern length limit on admin search (#956) (#957)
D1 caps LIKE/GLOB pattern length at 50 bytes. /admin/address and
/admin/users wrapped the query as `%${query}%` and fed it to LIKE,
so searching by a full email address crashed with "LIKE or GLOB
pattern too complex". Fall back to instr() above the 50-byte
threshold.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 19:23:26 +08:00
Dream Hunter
42281cdc49 ci: upgrade GitHub Actions to support Node.js 24 (#951)
* ci: upgrade GitHub Actions to support Node.js 24

- pnpm/action-setup: v4 → v5
- actions/upload-artifact: v4 → v6
- actions/download-artifact: v4 → v6
- sync.yaml: replace inactive aormsby/Fork-Sync-With-Upstream-action with gh repo sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: replace softprops/action-gh-release with gh release CLI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): use gh release upload instead of create for existing releases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:44:07 +08:00
Dream Hunter
5248c03b6c docs: restructure sidebar, expand FAQ, enhance send mail docs (#949)
* docs: restructure sidebar, expand FAQ, enhance send mail docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: remove specific example domain reference in FAQ per review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 11:47:25 +08:00
Dream Hunter
b86d1faac4 docs: update missing documentation from closed issues (#948)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 11:11:44 +08:00
Dream Hunter
a0db913952 fix: remove vite-plugin-top-level-await, incompatible with new esbuild (#940) 2026-04-04 20:08:25 +08:00
Dream Hunter
4746983780 feat: upgrade version to v1.6.0 (#939)
* feat: upgrade version to v1.6.0

- Update version number to 1.6.0 in all package.json files
- Add v1.6.0 placeholder in CHANGELOG.md and CHANGELOG_EN.md

* docs: update release skill to use bilingual format (zh + en collapsed)

* chore: upgrade dependencies

* fix: correct CHANGELOG placeholder position and update version-upgrade skill

* docs: update version-upgrade skill with correct CHANGELOG placeholder position
2026-04-04 19:58:47 +08:00
104 changed files with 6241 additions and 2823 deletions

View File

@@ -0,0 +1,3 @@
config.json
__pycache__/
*.py[cod]

View 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.

View File

@@ -0,0 +1,5 @@
{
"token": "<telegram bot token>",
"chat_id": "@cloudflare_temp_email",
"message_thread_id": 82
}

View 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()

View File

@@ -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.

View 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.

View 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` 1100, `offset` 0-based. On `429`, back off.
**Prefer the `parsed_*` endpoints.** They run the same `commonParseMail` (postal-mime) the frontend uses and return structured fields directly, so the agent does not need to ship a MIME parser.
`parsedMail` shape:
```json
{
"id": 42,
"message_id": "<...>",
"source": "noreply@foo.com",
"to": "abc@yourdomain.com",
"created_at": "2026-04-21 10:00:00",
"sender": "Foo <noreply@foo.com>",
"subject": "Your code is 123456",
"text": "Your code is 123456\n",
"html": "<p>Your code is <b>123456</b></p>",
"attachments": [
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
]
}
```
Attachment **binary content is not included** in `parsed_*` responses — only metadata. If you need the bytes, fetch the raw mail via `/api/mail/:id` and parse it client-side (see below).
## Recipes
### 1. Smoke-test the JWT
```bash
curl -s "$BASE/api/settings" -H "Authorization: Bearer $JWT"
# → { "address": "abc123@example.com", "send_balance": 0 }
```
If this returns `401`, JWT is wrong / expired / mismatched with `BASE` — ask the user for a fresh one.
### 2. List the inbox (parsed)
```bash
curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
-H "Authorization: Bearer $JWT"
```
### 3. Get one mail (parsed)
```bash
curl -s "$BASE/api/parsed_mail/<id>" -H "Authorization: Bearer $JWT"
```
### 4. Extract a verification code (end-to-end, parsed API)
```python
import re, time, requests
BASE, JWT = "<BASE>", "<JWT>"
H = {"Authorization": f"Bearer {JWT}"}
def wait_for_code(pattern=r"\b\d{4,8}\b", timeout=120, poll=3):
deadline = time.time() + timeout
seen = set()
while time.time() < deadline:
lst = requests.get(f"{BASE}/api/parsed_mails?limit=5&offset=0", headers=H).json()
for m in lst.get("results", []):
if m["id"] in seen: continue
seen.add(m["id"])
body = (m.get("subject") or "") + "\n" + (m.get("text") or "") + "\n" + (m.get("html") or "")
hit = re.search(pattern, body)
if hit:
return hit.group(0)
time.sleep(poll)
raise TimeoutError("no matching mail within window")
print(wait_for_code())
```
## Raw endpoints (fallback — only if you need attachment bytes or the original MIME)
`/api/mails` and `/api/mail/:id` return the gzip-resolved RFC822 source in `raw`. Parse it client-side.
### Node.js (postal-mime, pure JS)
```bash
npm i postal-mime
```
```js
import PostalMime from 'postal-mime';
const mail = await (await fetch(`${BASE}/api/mail/${id}`, {
headers: { Authorization: `Bearer ${JWT}` },
})).json();
const parsed = await PostalMime.parse(mail.raw);
// parsed.subject / parsed.from / parsed.text / parsed.html
// parsed.attachments[i].content is a Uint8Array
```
### Python (stdlib, no deps)
```python
import email, requests
from email import policy
r = requests.get(f"{BASE}/api/mail/{mid}", headers={"Authorization": f"Bearer {JWT}"}).json()
msg = email.message_from_string(r["raw"], policy=policy.default)
subject = msg["subject"]
text = (msg.get_body(preferencelist=("plain",)) or None) and msg.get_body(preferencelist=("plain",)).get_content()
html = (msg.get_body(preferencelist=("html",)) or None) and msg.get_body(preferencelist=("html",)).get_content()
for part in msg.iter_attachments():
name, mime, data = part.get_filename(), part.get_content_type(), part.get_content()
```
The frontend's reference implementation is `frontend/src/utils/email-parser.js` — tries `mail-parser-wasm` first, falls back to `postal-mime`. The server uses `postal-mime` only.
## Polling discipline
- Start at `poll=3s`, exponential backoff capped at 10s.
- Dedupe by mail `id`.
- Never poll faster than once per second.
- Respect `429` — sleep and retry.
## Common errors
- `401 InvalidAddressCredentialMsg` — JWT wrong/expired/sent via wrong header. Ask the user for a fresh JWT.
- `401 CustomAuthPasswordMsg` — site requires `x-custom-auth`; attach `SITE_PASSWORD`.
- `400 InvalidLimitMsg` / `InvalidOffsetMsg``limit` must be 1..100, `offset ≥ 0`.
- `429` — rate limited; back off.

View 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
```

View File

@@ -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
```

View File

@@ -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:

View File

@@ -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

View File

@@ -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: |

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -6,6 +6,61 @@
<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

View File

@@ -6,6 +6,61 @@
<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

View File

@@ -150,9 +150,26 @@
- [x] Webhook 支持,消息推送集成
- [x] 支持 `CF Turnstile` 人机验证
- [x] 限流配置,防止滥用
- [x] **Agent 友好**:提供服务端解析的 `/api/parsed_mails` / `/api/parsed_mail/:id`,配合仓库内的 `cf-temp-mail-usage` skillOpenClaw / Codex / Cursor 等 AI agent 可直接使用用户提供的 JWT 读取验证码 / 链接,无需在客户端引入 MIME 解析器
</details>
## 给 AI Agent 使用:`cf-temp-mail-usage` skill
仓库内置一个只读 skill`.claude/skills/cf-temp-mail-usage/`,让 AI agent 用用户提供的 `Address JWT + API 地址`直接消费邮箱(列出邮件 / 取单封 / 轮询验证码),规避前端创建邮箱时的 Turnstile 人机验证。
安装到当前项目的 Claude Code
```bash
# 方式 1degit 拷贝子目录
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage .claude/skills/cf-temp-mail-usage
# 方式 2安装到全局
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage ~/.claude/skills/cf-temp-mail-usage
```
细节见 [.claude/skills/cf-temp-mail-usage/SKILL.md](.claude/skills/cf-temp-mail-usage/SKILL.md)。
## 技术架构
<details>

View File

@@ -150,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>

View File

@@ -72,6 +72,24 @@ services:
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: ..
@@ -128,6 +146,7 @@ services:
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
@@ -146,6 +165,8 @@ services:
condition: service_healthy
worker-gzip:
condition: service_healthy
worker-send-mail-domain:
condition: service_healthy
frontend:
condition: service_started
smtp-proxy:

View File

@@ -5,6 +5,7 @@ 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';
@@ -182,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,
@@ -197,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.
*/

View File

@@ -12,7 +12,7 @@ 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 = true
ENABLE_USER_DELETE_EMAIL = false
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true

View 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
View File

@@ -6,8 +6,8 @@
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.2.18",
"nodemailer": "^8.0.4"
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
},
"devDependencies": {
"@playwright/test": "1.58.2",
@@ -129,22 +129,34 @@
}
},
"node_modules/imapflow": {
"version": "1.2.18",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.18.tgz",
"integrity": "sha512-zxYvcG9ckj/UcTRs+ZDT+wJzW8DqkjgWZwc1z4Q28R/4C/1YvJieVETOuR/9ztCXcycURC50PJShMimITvz5wQ==",
"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.4",
"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.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"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"

View File

@@ -13,7 +13,7 @@
"ws": "^8.18.0"
},
"dependencies": {
"imapflow": "^1.2.18",
"nodemailer": "^8.0.4"
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
}
}

View File

@@ -1,18 +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, 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}` },
});

View 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);
});
});

View 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,
},
});
});
});

View File

@@ -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');

View File

@@ -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);
}

View 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);
});
});

View File

@@ -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);
});

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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',
@@ -65,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: '邮件转发配置',
@@ -116,7 +128,13 @@ const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
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,
@@ -314,6 +332,31 @@ const getSubdomainMatchPayloadValue = (mode) => {
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`)
@@ -337,6 +380,15 @@ const fetchData = async ({ suppressErrorMessage = false } = {}) => {
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
addressCreationSubdomainMatchStatus.value.storedEnabled
)
const sendMailLimitConfig = res.sendMailLimitConfig
sendMailDailyLimitEnabled.value = !!sendMailLimitConfig?.dailyEnabled
sendMailMonthlyLimitEnabled.value = !!sendMailLimitConfig?.monthlyEnabled
sendMailDailyLimit.value = sendMailDailyLimitEnabled.value
? sendMailLimitConfig.dailyLimit
: DEFAULT_SEND_MAIL_DAILY_LIMIT
sendMailMonthlyLimit.value = sendMailMonthlyLimitEnabled.value
? sendMailLimitConfig.monthlyLimit
: DEFAULT_SEND_MAIL_MONTHLY_LIMIT
} catch (error) {
if (!suppressErrorMessage) {
message.error(error.message || "error");
@@ -346,6 +398,9 @@ const fetchData = async ({ suppressErrorMessage = false } = {}) => {
}
const save = async () => {
if (!validateSendMailLimit()) {
return
}
try {
const payload = {
blockList: addressBlockList.value || [],
@@ -356,7 +411,8 @@ const save = async () => {
emailRuleSettings: emailRuleSettings.value,
addressCreationSettings: {
enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value)
}
},
sendMailLimitConfig: getSendMailLimitPayload()
}
await api.fetch(`/admin/account_settings`, {
method: 'POST',
@@ -437,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>

View File

@@ -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;">

View File

@@ -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: '未配置(使用全局设置)',
}
}

View File

@@ -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">

View File

@@ -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: '启用邮箱正则校验',
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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
},
}
})

View File

@@ -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"
}

View File

@@ -136,25 +136,31 @@ 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: '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: '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' },
]
@@ -170,13 +176,20 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
]
},
{
text: 'Feature Overview',
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' }
]
}

View File

@@ -136,26 +136,32 @@ 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: '配置子域名邮箱', 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: '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' },
]
},
@@ -170,13 +176,20 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
]
},
{
text: '功能简介',
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' }
]
}

View File

@@ -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`.
![d1](/readme_assets/d1.png)
### Backend - Cloudflare workers

View File

@@ -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

View File

@@ -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.
![D1](/readme_assets/d1.png)
@@ -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
```

View File

@@ -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.
>
> ![pages spa setting](/ui_install/pages-spa-setting.jpg)
The first deployment will prompt you to create a project. For the `production` branch, enter `production`.
```bash

View File

@@ -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

View File

@@ -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

View File

@@ -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`.

View File

@@ -7,3 +7,6 @@
3. Configure the `Catch-all address` in the routing rules of each domain's `Email Routing` to send to `worker`.
![email](/readme_assets/email.png)
> [!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**.

View File

@@ -10,6 +10,48 @@ You need to configure `ADMIN_PASSWORDS` in the backend or ensure the current use
![admin](/feature/admin.png)
## 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 (11,000,000). Exceeding the limit returns 429. Counter resets every 24 hours (UTC date boundary).

View File

@@ -35,7 +35,7 @@ res = requests.post(
}
)
# Returns {"jwt": "<Jwt>"}
# Returns {"jwt": "<Jwt>", "address": "<email_address>", "address_id": 123}
print(res.json())
```

View File

@@ -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"

View File

@@ -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 () => {
![pages](/ui_install/pages.png)
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.
>
> ![pages spa setting](/ui_install/pages-spa-setting.jpg)
Then click `Deploy`
![pages1](/ui_install/pages-1.png)
@@ -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>

View File

@@ -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` |
@@ -37,8 +37,9 @@
| `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
@@ -58,6 +59,9 @@
> 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -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` 手动部署
## 如何配置自动更新

View File

@@ -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 数据库
![D1](/readme_assets/d1.png)
@@ -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
```

View File

@@ -9,6 +9,12 @@
## 前后端分离部署
> [!warning] 重要SPA 模式
> 本项目是单页应用SPA。如果你通过 Cloudflare 控制台手动上传部署,**必须在高级选项中将「未找到处理」设置为 `Single-page application (SPA)`**,否则刷新页面或直接访问 `/admin` 等子路径时会返回 404。
> 通过 CLI`wrangler pages deploy`)部署时会自动处理,无需额外配置。
>
> ![pages spa setting](/ui_install/pages-spa-setting.jpg)
第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash

View File

@@ -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 控制台密码, 不配置则不允许访问控制台

View File

@@ -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
| 问题 | 解决方案 |
| -------------------------------------------------------------- | -------------------------------------------------- |

View File

@@ -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 发信。

View File

@@ -7,3 +7,6 @@
3. 配置每个域名的 `Email Routing` 的路由规则中的 `Catch-all 地址` 发送到 `worker`
![email](/readme_assets/email.png)
> [!WARNING] 子域需要单独配置
> 如果你要用**子域名**(如 `mail.example.com`)收信,必须在 CF 控制台里对 **该子域** 单独启用 `Email Routing`,并配置邮件 DNS 记录与 Catch-all 规则。仅在一级域名上开启 Email Routing **不会自动覆盖子域名**。

View File

@@ -10,6 +10,48 @@
![admin](/feature/admin.png)
## 账号列表排序
管理后台的账号标签页支持按列排序,可点击表头对以下列进行升序/降序排列:
- 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 每天最多请求次数11,000,000超出返回 429。计数以 UTC 日期为周期24 小时后自动重置。

View File

@@ -35,7 +35,7 @@ res = requests.post(
}
)
# 返回值 {"jwt": "<Jwt>"}
# 返回值 {"jwt": "<Jwt>", "address": "<邮箱地址>", "address_id": 123}
print(res.json())
```

View File

@@ -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"

View File

@@ -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 () => {
![pages](/ui_install/pages.png)
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。
>
> ![pages spa setting](/ui_install/pages-spa-setting.jpg)
然后点击 `Deploy`
![pages1](/ui_install/pages-1.png)
@@ -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>

View File

@@ -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` |
@@ -37,8 +37,9 @@
| `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
@@ -54,6 +55,9 @@
>
> 管理后台提供三种显式状态:**跟随环境变量**、**强制开启**、**强制关闭**。当你选择
> “跟随环境变量”并保存时,会清空后台覆盖,恢复到“未设置”的回退行为。
>
> `SEND_MAIL_DOMAINS` 只影响 `SEND_MAIL` binding 的兜底发信路径和 `/admin/send_mail_by_binding`。
> 它不影响 Resend、SMTP、`verifiedAddressList` 等其他发信通道。
## 接受邮件相关变量

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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 };

View 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
};

View File

@@ -0,0 +1,53 @@
import { Context } from 'hono'
import i18n from '../i18n'
import { sendAdminInternalMail } from '../utils'
import { handleListQuery } from '../common'
const list = async (c: Context<HonoCustomType>) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM address_sender where address = ? `,
`SELECT count(*) as count FROM address_sender where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM address_sender `,
`SELECT count(*) as count FROM address_sender `,
[], limit, offset
);
};
const update = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
/* eslint-disable prefer-const */
let { address, address_id, balance, enabled } = await c.req.json();
/* eslint-enable prefer-const */
if (!address_id) {
return c.text(msgs.InvalidAddressIdMsg, 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text(msgs.OperationFailedMsg, 500)
}
await sendAdminInternalMail(
c, address, "Account Send Access Updated",
`Your send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
);
return c.json({ success });
};
const remove = async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE id = ? `
).bind(id).run();
return c.json({ success });
};
export default { list, update, remove };

View File

@@ -39,15 +39,21 @@ export default {
getUsers: async (c: Context<HonoCustomType>) => {
const { limit, offset, query } = c.req.query();
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 param = useInstr ? query : `%${query}%`;
const userEmailWhere = useInstr ? `instr(u.user_email, ?) > 0` : `u.user_email like ?`;
const userEmailWhereCount = useInstr ? `instr(user_email, ?) > 0` : `user_email like ?`;
return await handleListQuery(c,
`SELECT u.id as id, u.user_email, u.created_at, u.updated_at,`
+ ` ur.role_text as role_text,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u`
+ ` LEFT JOIN user_roles ur ON u.id = ur.user_id`
+ ` where u.user_email like ?`,
`SELECT count(*) as count FROM users where user_email like ?`,
[`%${query}%`], limit, offset
+ ` where ${userEmailWhere}`,
`SELECT count(*) as count FROM users where ${userEmailWhereCount}`,
[param], limit, offset
);
}
return await handleListQuery(c,
@@ -175,7 +181,16 @@ export default {
return c.json({ configs });
},
saveRoleAddressConfig: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { configs } = await c.req.json<{ configs: RoleAddressConfig }>();
if (typeof configs !== "object" || configs === null || Array.isArray(configs)) {
return c.text(msgs.InvalidMaxAddressCountMsg, 400);
}
for (const config of Object.values(configs)) {
if (typeof config?.maxAddressCount === "number" && config.maxAddressCount < 0) {
return c.text(msgs.InvalidMaxAddressCountMsg, 400);
}
}
await saveSetting(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY, JSON.stringify(configs));
return c.json({ success: true });
},

View File

@@ -1,10 +1,11 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { Context, Hono } from 'hono'
import i18n from '../i18n'
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
import { newAddress, handleListQuery, getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
import { CONSTANTS } from '../constants'
import { getUserRoles } from '../utils'
import address_api from './address_api'
import address_sender_api from './address_sender_api'
import sendbox_api from './sendbox_api'
import statistics_api from './statistics_api'
import account_settings_api from './account_settings_api'
import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api'
import webhook_settings from './webhook_settings'
@@ -12,410 +13,43 @@ import mail_webhook_settings from './mail_webhook_settings'
import oauth2_settings from './oauth2_settings'
import worker_config from './worker_config'
import admin_mail_api from './admin_mail_api'
import { sendMailbyAdmin } from './send_mail'
import { sendMailbyAdmin, sendMailByBindingAdmin } from './send_mail'
import db_api from './db_api'
import ip_blacklist_settings from './ip_blacklist_settings'
import ai_extract_settings from './ai_extract_settings'
import { EmailRuleSettings } from '../models'
import e2e_test_api from './e2e_test_api'
export const api = new Hono<HonoCustomType>()
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,
};
}
api.get('/admin/address', async (c) => {
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) {
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 name like ?`,
`SELECT count(*) as count FROM address where name like ?`,
[`%${query}%`], 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
);
})
api.post('/admin/new_address', async (c) => {
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)
}
})
api.delete('/admin/delete_address/:id', async (c) => {
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
})
})
api.delete('/admin/clear_inbox/:id', async (c) => {
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
})
})
api.delete('/admin/clear_sent_items/:id', async (c) => {
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
})
})
api.get('/admin/show_password/:id', async (c) => {
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: jwt
})
})
api.post('/admin/address/:id/reset_password', async (c) => {
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 });
})
// address
api.get('/admin/address', address_api.listAddresses)
api.post('/admin/new_address', address_api.createNewAddress)
api.delete('/admin/delete_address/:id', address_api.deleteAddress)
api.delete('/admin/clear_inbox/:id', address_api.clearInbox)
api.delete('/admin/clear_sent_items/:id', address_api.clearSentItems)
api.get('/admin/show_password/:id', address_api.showPassword)
api.post('/admin/address/:id/reset_password', address_api.resetPassword)
// mail api
api.get('/admin/mails', admin_mail_api.getMails);
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails);
api.get('/admin/mails', admin_mail_api.getMails)
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails)
api.delete('/admin/mails/:id', admin_mail_api.deleteMail)
api.get('/admin/address_sender', async (c) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM address_sender where address = ? `,
`SELECT count(*) as count FROM address_sender where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM address_sender `,
`SELECT count(*) as count FROM address_sender `,
[], limit, offset
);
})
// address sender
api.get('/admin/address_sender', address_sender_api.list)
api.post('/admin/address_sender', address_sender_api.update)
api.delete('/admin/address_sender/:id', address_sender_api.remove)
api.post('/admin/address_sender', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
/* eslint-disable prefer-const */
let { address, address_id, balance, enabled } = await c.req.json();
/* eslint-enable prefer-const */
if (!address_id) {
return c.text(msgs.InvalidAddressIdMsg, 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text(msgs.OperationFailedMsg, 500)
}
await sendAdminInternalMail(
c, address, "Account Send Access Updated",
`Your send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
);
return c.json({
success: success
})
})
// sendbox
api.get('/admin/sendbox', sendbox_api.list)
api.delete('/admin/sendbox/:id', sendbox_api.remove)
api.delete('/admin/address_sender/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
// statistics
api.get('/admin/statistics', statistics_api.get)
api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM sendbox where address = ? `,
`SELECT count(*) as count FROM sendbox where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM sendbox `,
`SELECT count(*) as count FROM sendbox `,
[], limit, offset
);
})
api.delete('/admin/sendbox/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
api.get('/admin/statistics', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM raw_mails`
).first<{ count: number }>() || {};
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first<{ count: number }>() || {};
const { count: activeAddressCount7days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first<{ count: number }>() || {};
const { count: activeAddressCount30days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-30 day')`
).first<{ count: number }>() || {};
const { count: sendMailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first<{ count: number }>() || {};
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users`
).first<{ count: number }>() || {};
return c.json({
mailCount: mailCount,
addressCount: addressCount,
activeAddressCount7days: activeAddressCount7days,
activeAddressCount30days: activeAddressCount30days,
userCount: userCount,
sendMailCount: sendMailCount
})
});
api.get('/admin/account_settings', async (c) => {
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);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || [],
noLimitSendAddressList: noLimitSendAddressList || [],
emailRuleSettings: emailRuleSettings || {},
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
: {},
addressCreationSubdomainMatchStatus,
})
} catch (error) {
console.error(error);
return c.json({})
}
})
api.post('/admin/account_settings', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings
} = 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)
}
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
})
)
}
}
return c.json({
success: true
})
})
// account settings
api.get('/admin/account_settings', account_settings_api.get)
api.post('/admin/account_settings', account_settings_api.save)
// cleanup
api.post('/admin/cleanup', cleanup_api.cleanup)
@@ -429,7 +63,7 @@ api.get('/admin/users', admin_user_api.getUsers)
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
api.get('/admin/user_roles', async (c: Context<HonoCustomType>) => c.json(getUserRoles(c)))
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
api.get('/admin/role_address_config', admin_user_api.getRoleAddressConfig)
api.post('/admin/role_address_config', admin_user_api.saveRoleAddressConfig)
@@ -441,33 +75,34 @@ api.get('/admin/user_oauth2_settings', oauth2_settings.getUserOauth2Settings)
api.post('/admin/user_oauth2_settings', oauth2_settings.saveUserOauth2Settings)
// webhook settings
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);
api.get('/admin/webhook/settings', webhook_settings.getWebhookSettings)
api.post('/admin/webhook/settings', webhook_settings.saveWebhookSettings)
// mail webhook settings
api.get("/admin/mail_webhook/settings", mail_webhook_settings.getWebhookSettings);
api.post("/admin/mail_webhook/settings", mail_webhook_settings.saveWebhookSettings);
api.post("/admin/mail_webhook/test", mail_webhook_settings.testWebhookSettings);
api.get('/admin/mail_webhook/settings', mail_webhook_settings.getWebhookSettings)
api.post('/admin/mail_webhook/settings', mail_webhook_settings.saveWebhookSettings)
api.post('/admin/mail_webhook/test', mail_webhook_settings.testWebhookSettings)
// worker config
api.get("/admin/worker/configs", worker_config.getConfig);
api.get('/admin/worker/configs', worker_config.getConfig)
// send mail by admin
api.post("/admin/send_mail", sendMailbyAdmin);
api.post('/admin/send_mail', sendMailbyAdmin)
api.post('/admin/send_mail_by_binding', sendMailByBindingAdmin)
// db api
api.get('admin/db_version', db_api.getVersion);
api.post('admin/db_initialize', db_api.initialize);
api.post('admin/db_migration', db_api.migrate);
api.get('admin/db_version', db_api.getVersion)
api.post('admin/db_initialize', db_api.initialize)
api.post('admin/db_migration', db_api.migrate)
// IP blacklist settings
api.get("/admin/ip_blacklist/settings", ip_blacklist_settings.getIpBlacklistSettings);
api.post("/admin/ip_blacklist/settings", ip_blacklist_settings.saveIpBlacklistSettings);
api.get('/admin/ip_blacklist/settings', ip_blacklist_settings.getIpBlacklistSettings)
api.post('/admin/ip_blacklist/settings', ip_blacklist_settings.saveIpBlacklistSettings)
// AI extract settings
api.get("/admin/ai_extract/settings", ai_extract_settings.getAiExtractSettings);
api.post("/admin/ai_extract/settings", ai_extract_settings.saveAiExtractSettings);
api.get('/admin/ai_extract/settings', ai_extract_settings.getAiExtractSettings)
api.post('/admin/ai_extract/settings', ai_extract_settings.saveAiExtractSettings)
// E2E test endpoints
api.post('/admin/test/seed_mail', e2e_test_api.seedMail);
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail);
api.post('/admin/test/seed_mail', e2e_test_api.seedMail)
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail)

View File

@@ -18,6 +18,8 @@ async function getIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Respo
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: false,
whitelist: [],
enableDailyLimit: false,
dailyRequestLimit: 1000
});
@@ -30,6 +32,10 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
const msgs = i18n.getMessagesbyContext(c);
const settings = await c.req.json<IpBlacklistSettings>();
// Backward compatibility: default new fields if absent (older frontends)
settings.enableWhitelist = settings.enableWhitelist ?? false;
settings.whitelist = settings.whitelist ?? [];
// Validate settings
if (typeof settings.enabled !== 'boolean') {
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: enabled`, 400);
@@ -47,6 +53,14 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: fingerprintBlacklist`, 400);
}
if (typeof settings.enableWhitelist !== 'boolean') {
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: enableWhitelist`, 400);
}
if (!Array.isArray(settings.whitelist)) {
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: whitelist`, 400);
}
if (typeof settings.enableDailyLimit !== 'boolean') {
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: enableDailyLimit`, 400);
}
@@ -70,6 +84,10 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
return c.text(`${msgs.BlacklistExceedsMaxSizeMsg}: fingerprintBlacklist (${MAX_BLACKLIST_SIZE})`, 400);
}
if (settings.whitelist.length > MAX_BLACKLIST_SIZE) {
return c.text(`${msgs.BlacklistExceedsMaxSizeMsg}: whitelist (${settings.whitelist.length}/${MAX_BLACKLIST_SIZE})`, 400);
}
// Sanitize patterns (trim and remove empty strings)
// Both regex and plain strings are allowed
const sanitizedBlacklist = settings.blacklist
@@ -84,11 +102,30 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
.map(pattern => pattern.trim())
.filter(pattern => pattern.length > 0);
const sanitizedWhitelist: string[] = [];
for (const pattern of settings.whitelist) {
if (typeof pattern !== 'string') {
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: whitelist element must be a string`, 400);
}
const p = pattern.trim();
if (!p) continue;
// Validate regex patterns before saving to prevent runtime lockout
// eslint-disable-next-line no-useless-escape
if (/[\^$.*+?\[\]{}()|\\]/.test(p)) {
try { new RegExp(p); } catch {
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: whitelist invalid regex: ${p}`, 400);
}
}
sanitizedWhitelist.push(p);
}
const sanitizedSettings: IpBlacklistSettings = {
enabled: settings.enabled,
blacklist: sanitizedBlacklist,
asnBlacklist: sanitizedAsnBlacklist,
fingerprintBlacklist: sanitizedFingerprintBlacklist,
enableWhitelist: settings.enableWhitelist,
whitelist: sanitizedWhitelist,
enableDailyLimit: settings.enableDailyLimit,
dailyRequestLimit: settings.dailyRequestLimit
};

View File

@@ -1,21 +1,100 @@
import { Context } from "hono";
import { isSendMailBindingEnabled } from "../common";
import i18n from "../i18n";
import { sendMail } from "../mails_api/send_mail_api";
import { ensureSendMailLimit, increaseSendMailLimitCount } from "../mails_api/send_mail_limit_utils";
const getAdminSendMailErrorMessage = (
msgs: ReturnType<typeof i18n.getMessagesbyContext>,
error: unknown
): string => {
const message = error instanceof Error ? error.message : "";
return Object.values(msgs).includes(message)
? message
: msgs.OperationFailedMsg;
}
export const sendMailbyAdmin = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
let reqJson;
try {
reqJson = await c.req.json();
} catch (e) {
console.error("Admin send_mail invalid json", e);
return c.text(msgs.InvalidInputMsg, 400)
}
const {
from_name, from_mail,
to_mail, to_name,
subject, content, is_html
} = await c.req.json();
await sendMail(c, from_mail, {
from_name: from_name,
to_name: to_name,
to_mail: to_mail,
subject: subject,
content: content,
is_html: is_html,
}, {
isAdmin: true
})
} = reqJson;
try {
await sendMail(c, from_mail, {
from_name: from_name,
to_name: to_name,
to_mail: to_mail,
subject: subject,
content: content,
is_html: is_html,
}, {
isAdmin: true
})
} catch (e) {
console.error("Admin send_mail failed", e);
return c.text(getAdminSendMailErrorMessage(msgs, e), 400)
}
return c.json({ status: "ok" });
}
export const sendMailByBindingAdmin = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!c.env.SEND_MAIL) {
return c.text(msgs.EnableSendMailMsg, 400)
}
let reqJson;
try {
reqJson = await c.req.json();
} catch (e) {
console.error("Admin raw send_mail invalid json", e);
return c.text(msgs.InvalidInputMsg, 400)
}
const {
from, to, subject,
html, text,
cc, bcc, replyTo,
attachments, headers,
} = reqJson;
if (!from || !to || !subject || (!html && !text)) {
return c.text(msgs.InvalidInputMsg, 400)
}
const fromMail = typeof from === "string" ? from : from?.email;
const mailDomain = typeof fromMail === "string" && fromMail.includes("@")
? fromMail.split("@")[1]?.trim().toLowerCase()
: null;
if (!mailDomain) {
return c.text(msgs.InvalidInputMsg, 400)
}
if (!isSendMailBindingEnabled(c, mailDomain)) {
return c.text(msgs.EnableSendMailForDomainMsg, 400)
}
try {
await ensureSendMailLimit(c);
await c.env.SEND_MAIL.send({
from,
to,
subject,
...(html ? { html } : {}),
...(text ? { text } : {}),
...(cc ? { cc } : {}),
...(bcc ? { bcc } : {}),
...(replyTo ? { replyTo } : {}),
...(attachments && attachments.length ? { attachments } : {}),
...(headers ? { headers } : {}),
});
await increaseSendMailLimitCount(c);
} catch (e) {
console.error("Admin raw send_mail failed", e);
return c.text(getAdminSendMailErrorMessage(msgs, e), 400)
}
return c.json({ status: "ok" });
}

View File

@@ -0,0 +1,29 @@
import { Context } from 'hono'
import { handleListQuery } from '../common'
const list = async (c: Context<HonoCustomType>) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM sendbox where address = ? `,
`SELECT count(*) as count FROM sendbox where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM sendbox `,
`SELECT count(*) as count FROM sendbox `,
[], limit, offset
);
};
const remove = async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE id = ? `
).bind(id).run();
return c.json({ success });
};
export default { list, remove };

View File

@@ -0,0 +1,32 @@
import { Context } from 'hono'
const get = async (c: Context<HonoCustomType>) => {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM raw_mails`
).first<{ count: number }>() || {};
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first<{ count: number }>() || {};
const { count: activeAddressCount7days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first<{ count: number }>() || {};
const { count: activeAddressCount30days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-30 day')`
).first<{ count: number }>() || {};
const { count: sendMailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first<{ count: number }>() || {};
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users`
).first<{ count: number }>() || {};
return c.json({
mailCount,
addressCount,
activeAddressCount7days,
activeAddressCount30days,
userCount,
sendMailCount,
});
};
export default { get };

View File

@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { WorkerMailerOptions } from 'worker-mailer';
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains } from './utils';
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains } from './utils';
import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
@@ -44,11 +44,26 @@ export const isSendMailEnabled = (
if (smtpConfigMap && smtpConfigMap[mailDomain]) return true;
// Check SEND_MAIL binding
if (c.env.SEND_MAIL) return true;
if (isSendMailBindingEnabled(c, mailDomain)) return true;
return false;
}
export const isSendMailBindingEnabled = (
c: Context<HonoCustomType>,
mailDomain: string
): boolean => {
if (!c.env.SEND_MAIL) {
return false;
}
const sendMailDomains = getStringArray(c.env.SEND_MAIL_DOMAINS)
.map((domain) => normalizeDomainValue(domain));
if (sendMailDomains.length === 0) {
return true;
}
return sendMailDomains.includes(normalizeDomainValue(mailDomain));
}
/**
* Check if send mail is enabled for any configured domain
*/
@@ -728,13 +743,13 @@ export const commonGetUserRole = async (
export const getAddressPrefix = async (c: Context<HonoCustomType>): Promise<string | undefined> => {
const user = c.get("userPayload");
if (!user) {
return getStringValue(c.env.PREFIX);
return getStringValue(c.env.PREFIX).trim().toLowerCase();
}
const user_role = await commonGetUserRole(c, user.user_id);
if (typeof user_role?.prefix === "string") {
return user_role.prefix;
return user_role.prefix.trim().toLowerCase();
}
return getStringValue(c.env.PREFIX);
return getStringValue(c.env.PREFIX).trim().toLowerCase();
}
export const getAllowDomains = async (c: Context<HonoCustomType>): Promise<string[]> => {

View File

@@ -1,5 +1,5 @@
export const CONSTANTS = {
VERSION: 'v' + '1.5.0',
VERSION: 'v' + '1.8.0',
// DB Version
DB_VERSION_KEY: 'db_version',
@@ -26,4 +26,6 @@ export const CONSTANTS = {
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list",
WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY: "temp-mail-webhook-admin-mail-settings",
SEND_MAIL_LIMIT_COUNT_KEY_PREFIX: "send_mail_limit_count:",
SEND_MAIL_LIMIT_CONFIG_KEY: "send_mail_limit_config",
}

View File

@@ -71,13 +71,16 @@ const messages: LocaleMessages = {
ContentEmptyMsg: "Content is empty",
AlreadyRequestedMsg: "Already requested",
EnableResendOrSmtpMsg: "Please enable resend or smtp for this domain",
EnableResendOrSmtpWithVerifiedMsg: "Please enable resend or smtp for this domain, or add recipient to verified address list",
EnableResendOrSmtpOrSendMailMsg: "Please enable resend, smtp or SEND_MAIL for this domain",
ServerSendMailDailyLimitMsg: "Server daily send quota has been reached",
ServerSendMailMonthlyLimitMsg: "Server monthly send quota has been reached",
InvalidToMailMsg: "Invalid recipient address",
// Admin related
InvalidAddressIdMsg: "Invalid address_id",
EnableKVMsg: "Please enable KV first",
EnableSendMailMsg: "Please enable SEND_MAIL first",
EnableSendMailForDomainMsg: "Please enable SEND_MAIL for this domain first",
InvalidCleanupConfigMsg: "Invalid cleanType or cleanDays",
InvalidCleanTypeMsg: "Invalid cleanType",
EnableKVForMailVerifyMsg: "Please enable KV first if you want to enable mail verify",

View File

@@ -69,13 +69,16 @@ export type LocaleMessages = {
ContentEmptyMsg: string
AlreadyRequestedMsg: string
EnableResendOrSmtpMsg: string
EnableResendOrSmtpWithVerifiedMsg: string
EnableResendOrSmtpOrSendMailMsg: string
ServerSendMailDailyLimitMsg: string
ServerSendMailMonthlyLimitMsg: string
InvalidToMailMsg: string
// Admin related
InvalidAddressIdMsg: string
EnableKVMsg: string
EnableSendMailMsg: string
EnableSendMailForDomainMsg: string
InvalidCleanupConfigMsg: string
InvalidCleanTypeMsg: string
EnableKVForMailVerifyMsg: string

View File

@@ -71,13 +71,16 @@ const messages: LocaleMessages = {
ContentEmptyMsg: "内容不能为空",
AlreadyRequestedMsg: "已经申请过了",
EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp",
EnableResendOrSmtpWithVerifiedMsg: "请先为此域名启用 resendsmtp,或将收件人添加到已验证地址列表",
EnableResendOrSmtpOrSendMailMsg: "请先为此域名启用 resendsmtp 或 SEND_MAIL",
ServerSendMailDailyLimitMsg: "服务器今日发信次数已达上限",
ServerSendMailMonthlyLimitMsg: "服务器本月发信次数已达上限",
InvalidToMailMsg: "收件人地址无效",
// Admin related
InvalidAddressIdMsg: "无效的 address_id",
EnableKVMsg: "请先启用 KV",
EnableSendMailMsg: "请先启用 SEND_MAIL",
EnableSendMailForDomainMsg: "请先为此域名启用 SEND_MAIL",
InvalidCleanupConfigMsg: "无效的 cleanType 或 cleanDays",
InvalidCleanTypeMsg: "无效的 cleanType",
EnableKVForMailVerifyMsg: "如果要启用邮件验证,请先启用 KV",

View File

@@ -10,6 +10,8 @@ export type IpBlacklistSettings = {
blacklist?: string[]; // Array of regex patterns or plain strings
asnBlacklist?: string[]; // Array of ASN organization patterns (e.g., "Google LLC", "Amazon")
fingerprintBlacklist?: string[]; // Array of browser fingerprint patterns
enableWhitelist?: boolean; // Enable IP whitelist (strict allowlist mode)
whitelist?: string[]; // Array of exact IPs or anchored regex; only matching IPs are allowed
enableDailyLimit?: boolean; // Enable daily request limit per IP
dailyRequestLimit?: number; // Maximum requests per IP per day
}
@@ -78,6 +80,61 @@ function isBlacklisted(value: string | null | undefined, blacklist: string[], ca
});
}
/**
* Whitelist-style match: strict allowlist, independent from blacklist semantics.
* Plain IPv4/IPv6 entries are matched EXACTLY (not as regex) to avoid unintended matches.
* Only explicit regex patterns (containing metacharacters beyond dots/colons) are treated as regex.
*
* Examples:
* "1.2.3.4" → exact match only (NOT treated as regex /1.2.3.4/)
* "2001:db8::1" → exact match only
* "^192\\.168\\.1\\.\\d+$" → regex (contains anchors/escapes)
*/
function isWhitelisted(value: string | null | undefined, whitelist: string[] | undefined): boolean {
if (!value || !whitelist || whitelist.length === 0) {
return false;
}
const normalizedValue = value.trim();
return whitelist.some(pattern => {
const normalizedPattern = pattern.trim();
if (!normalizedPattern) {
return false;
}
// IPv4 detection: digits and dots only → exact match (bypass regex heuristic)
if (/^\d+\.\d+\.\d+\.\d+$/.test(normalizedPattern)) {
return normalizedValue === normalizedPattern;
}
// IPv4-mapped IPv6: ::ffff:1.2.3.4 → exact match
if (/^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(normalizedPattern)) {
return normalizedValue === normalizedPattern;
}
// IPv6 detection: hex digits and colons → exact match
if (/^[0-9a-fA-F:]+$/.test(normalizedPattern) && normalizedPattern.includes(':')) {
return normalizedValue === normalizedPattern;
}
// Regex detection: contains metacharacters beyond dots/colons
if (looksLikeRegex(normalizedPattern)) {
try {
const regex = new RegExp(normalizedPattern);
return regex.test(normalizedValue);
} catch (error) {
// Invalid regex in a whitelist = never match (fail closed)
console.warn(`Whitelist regex "${normalizedPattern}" failed to parse: ${(error as Error).message}, treating as no-match`);
return false;
}
}
// Fallback: other plain strings → exact match
return normalizedValue === normalizedPattern;
});
}
/**
* Get IP blacklist settings from database
*
@@ -93,75 +150,147 @@ export async function getIpBlacklistSettings(
}
/**
* Middleware to check access control (blacklist and rate limiting) for rate-limited endpoints
* Returns 403/429 response if blocked, null if allowed or any error occurs
* Layer 1 — IP whitelist check (strict allowlist mode).
* Independent from blacklist. Fails closed when client IP is missing.
*
* @param c - Hono context
* @returns Response if blocked, null otherwise (including errors)
* Returns:
* - { response } — request is blocked (403)
* - { hit: true } — whitelist active and the IP matched (trusted, skip blacklist)
* - { hit: false } — whitelist not active or list empty (proceed normally)
*/
function checkIpWhitelist(
c: Context<HonoCustomType>,
settings: IpBlacklistSettings,
reqIp: string | null
): { response?: Response; hit: boolean } {
const active = !!(settings.enableWhitelist && settings.whitelist && settings.whitelist.length > 0);
if (!active) return { hit: false };
if (!reqIp) {
console.warn(`Blocked request without cf-connecting-ip under whitelist mode for path: ${c.req.path}`);
return { response: c.text(`Access denied: client IP unavailable`, 403), hit: false };
}
if (isWhitelisted(reqIp, settings.whitelist)) {
return { hit: true };
}
console.warn(`Blocked non-whitelisted IP: ${reqIp} for path: ${c.req.path}`);
return { response: c.text(`Access denied: IP ${reqIp} is not whitelisted`, 403), hit: false };
}
/**
* Layer 2a — Fingerprint blacklist check. Does NOT require a client IP.
* Must run before the IP-based early-return so fingerprint bans cannot be bypassed.
*/
function checkFingerprintBlacklist(
c: Context<HonoCustomType>,
settings: IpBlacklistSettings,
): Response | null {
if (!settings.enabled) return null;
if (!settings.fingerprintBlacklist || settings.fingerprintBlacklist.length === 0) return null;
const fingerprint = c.req.raw.headers.get("x-fingerprint");
if (fingerprint && isBlacklisted(fingerprint, settings.fingerprintBlacklist, true)) {
console.warn(`Blocked blacklisted fingerprint: ${fingerprint} for path: ${c.req.path}`);
return c.text(`Access denied: Browser fingerprint is blacklisted`, 403);
}
return null;
}
/**
* Layer 2b — IP + ASN blacklist check. Requires a client IP.
*/
function checkIpAsnBlacklist(
c: Context<HonoCustomType>,
settings: IpBlacklistSettings,
reqIp: string
): Response | null {
if (!settings.enabled) return null;
if (settings.blacklist && settings.blacklist.length > 0) {
if (isBlacklisted(reqIp, settings.blacklist, true)) {
console.warn(`Blocked blacklisted IP: ${reqIp} for path: ${c.req.path}`);
return c.text(`Access denied: IP ${reqIp} is blacklisted`, 403);
}
}
if (settings.asnBlacklist && settings.asnBlacklist.length > 0) {
const asOrganization = c.req.raw.cf?.asOrganization;
if (asOrganization && isBlacklisted(asOrganization as string, settings.asnBlacklist, false)) {
console.warn(`Blocked blacklisted ASN: ${asOrganization} (IP: ${reqIp}) for path: ${c.req.path}`);
return c.text(`Access denied: ASN organization is blacklisted`, 403);
}
}
return null;
}
/**
* Layer 3 — Daily request limit per IP. Always runs (protects backend resources).
*/
async function checkDailyLimit(
c: Context<HonoCustomType>,
settings: IpBlacklistSettings,
reqIp: string
): Promise<Response | null> {
if (!settings.enableDailyLimit || !settings.dailyRequestLimit || !c.env.KV) {
return null;
}
const daily_count_key = `limit|${reqIp}|${new Date().toISOString().slice(0, 10)}`;
const dailyLimit = settings.dailyRequestLimit;
const current_count = parseInt(await c.env.KV.get(daily_count_key) || "0", 10);
if (current_count && current_count >= dailyLimit) {
console.warn(`Blocked IP ${reqIp} exceeded daily limit of ${dailyLimit} requests for path: ${c.req.path}`);
return c.text(`IP=${reqIp} Exceeded daily limit of ${dailyLimit} requests`, 429);
}
// Increment counter with 24-hour expiration
await c.env.KV.put(daily_count_key, ((current_count || 0) + 1).toString(), { expirationTtl: 24 * 60 * 60 });
return null;
}
/**
* Middleware to check access control for rate-limited endpoints.
* Composes three independent layers in order:
* Layer 1 — IP whitelist (strict allowlist; hit = trust, skip blacklist)
* Layer 2 — Blacklist (IP / ASN / fingerprint)
* Layer 3 — Daily request limit
*
* Returns 403/429 response if blocked, null if allowed or any error occurs.
*/
export async function checkAccessControl(
c: Context<HonoCustomType>
): Promise<Response | null> {
try {
// Get IP blacklist settings from database
const settings = await getIpBlacklistSettings(c);
if (!settings) {
return null;
}
if (!settings) return null;
// Get IP address from CloudFlare header
const reqIp = c.req.raw.headers.get("cf-connecting-ip");
if (!reqIp) {
return null;
// Layer 1: whitelist
const whitelistResult = checkIpWhitelist(c, settings, reqIp);
if (whitelistResult.response) return whitelistResult.response;
// Layer 2a: fingerprint blacklist (does not require IP)
if (!whitelistResult.hit) {
const fingerprintResp = checkFingerprintBlacklist(c, settings);
if (fingerprintResp) return fingerprintResp;
}
// Check if blacklist feature is enabled
if (settings.enabled) {
// Check if IP is blacklisted (case-sensitive matching)
if (settings.blacklist && settings.blacklist.length > 0) {
if (isBlacklisted(reqIp, settings.blacklist, true)) {
console.warn(`Blocked blacklisted IP: ${reqIp} for path: ${c.req.path}`);
return c.text(`Access denied: IP ${reqIp} is blacklisted`, 403);
}
}
// Without a client IP, skip IP-keyed layers below
if (!reqIp) return null;
// Check ASN organization blacklist
if (settings.asnBlacklist && settings.asnBlacklist.length > 0) {
const asOrganization = c.req.raw.cf?.asOrganization;
// Check ASN with case-insensitive matching
if (asOrganization && isBlacklisted(asOrganization as string, settings.asnBlacklist, false)) {
console.warn(`Blocked blacklisted ASN: ${asOrganization} (IP: ${reqIp}) for path: ${c.req.path}`);
return c.text(`Access denied: ASN organization is blacklisted`, 403);
}
}
// Check browser fingerprint blacklist
if (settings.fingerprintBlacklist && settings.fingerprintBlacklist.length > 0) {
const fingerprint = c.req.raw.headers.get("x-fingerprint");
// Check fingerprint with case-sensitive matching
if (fingerprint && isBlacklisted(fingerprint, settings.fingerprintBlacklist, true)) {
console.warn(`Blocked blacklisted fingerprint: ${fingerprint} (IP: ${reqIp}) for path: ${c.req.path}`);
return c.text(`Access denied: Browser fingerprint is blacklisted`, 403);
}
}
// Layer 2b: IP + ASN blacklist (skipped when whitelist trusted the IP)
if (!whitelistResult.hit) {
const ipAsnResp = checkIpAsnBlacklist(c, settings, reqIp);
if (ipAsnResp) return ipAsnResp;
}
// Check daily request limit (independent of blacklist feature)
if (settings.enableDailyLimit && settings.dailyRequestLimit && c.env.KV) {
const daily_count_key = `limit|${reqIp}|${new Date().toISOString().slice(0, 10)}`;
const dailyLimit = settings.dailyRequestLimit;
const current_count = parseInt(await c.env.KV.get(daily_count_key) || "0", 10);
if (current_count && current_count >= dailyLimit) {
console.warn(`Blocked IP ${reqIp} exceeded daily limit of ${dailyLimit} requests for path: ${c.req.path}`);
return c.text(`IP=${reqIp} Exceeded daily limit of ${dailyLimit} requests`, 429);
}
// Increment counter with 24-hour expiration
await c.env.KV.put(daily_count_key, ((current_count || 0) + 1).toString(), { expirationTtl: 24 * 60 * 60 });
}
return null;
// Layer 3: daily limit (always enforced)
return await checkDailyLimit(c, settings, reqIp);
} catch (error) {
// Log error but don't block request
console.error('Error checking IP blacklist and rate limit:', error);

View File

@@ -1,10 +1,8 @@
import { Context, Hono } from 'hono'
import { Hono } from 'hono'
import i18n from '../i18n';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue, getSplitStringListValue, isAddressCountLimitReached } from '../utils';
import { newAddress, handleMailListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains, updateAddressUpdatedAt, generateRandomName } from '../common'
import { CONSTANTS } from '../constants'
import { resolveRawEmailRow } from '../gzip'
import parsed_mail_api from './parsed_mail_api';
import mails_crud from './mails_crud';
import new_address from './new_address';
import auto_reply from './auto_reply'
import webhook_settings from './webhook_settings';
import s3_attachment from './s3_attachment';
@@ -12,208 +10,37 @@ import address_auth from './address_auth';
export const api = new Hono<HonoCustomType>()
// auto reply
api.get('/api/auto_reply', auto_reply.getAutoReply)
api.post('/api/auto_reply', auto_reply.saveAutoReply)
// webhook
api.get('/api/webhook/settings', webhook_settings.getWebhookSettings)
api.post('/api/webhook/settings', webhook_settings.saveWebhookSettings)
api.post('/api/webhook/test', webhook_settings.testWebhookSettings)
// attachment (S3)
api.get('/api/attachment/list', s3_attachment.list)
api.post('/api/attachment/delete', s3_attachment.deleteKey)
api.post('/api/attachment/put_url', s3_attachment.getSignedPutUrl)
api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
api.get('/api/mails', async (c) => {
const { address } = c.get("jwtPayload")
if (!address) {
return c.json({ "error": "No address" }, 400)
}
const { limit, offset } = c.req.query();
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
return await handleMailListQuery(c,
`SELECT * FROM raw_mails where address = ?`,
`SELECT count(*) as count FROM raw_mails where address = ?`,
[address], limit, offset
);
})
// mail crud
api.get('/api/mails', mails_crud.listMails)
api.get('/api/mail/:mail_id', mails_crud.getMail)
api.delete('/api/mails/:id', mails_crud.deleteMail)
api.get('/api/mail/:mail_id', async (c) => {
const { address } = c.get("jwtPayload")
const { mail_id } = c.req.param();
const result = await c.env.DB.prepare(
`SELECT * FROM raw_mails where id = ? and address = ?`
).bind(mail_id, address).first();
if (!result) return c.json(null);
return c.json(await resolveRawEmailRow(result));
})
// parsed mail (server-side parsed subject/text/html/attachments)
api.get('/api/parsed_mails', parsed_mail_api.listParsedMails)
api.get('/api/parsed_mail/:mail_id', parsed_mail_api.getParsedMail)
api.delete('/api/mails/:id', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { id } = c.req.param();
// TODO: add toLowerCase() to handle old data
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ? and id = ? `
).bind(address.toLowerCase(), id).run();
return c.json({
success: success
})
})
api.get('/api/settings', async (c) => {
const { address, address_id } = c.get("jwtPayload")
const user_role = c.get("userRolePayload")
const msgs = i18n.getMessagesbyContext(c);
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ? `
).bind(address_id).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
// check address id
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ? `
).bind(address).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
updateAddressUpdatedAt(c, address);
const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE);
const is_no_limit_send_balance = user_role && no_limit_roles.includes(user_role);
const balance = is_no_limit_send_balance ? 99999 : await c.env.DB.prepare(
`SELECT balance FROM address_sender where address = ? and enabled = 1`
).bind(address).first("balance");
return c.json({
address: address,
send_balance: balance || 0,
});
})
api.post('/api/new_address', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const userPayload = c.get("userPayload");
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL)
&& !userPayload
) {
return c.text(msgs.NewAddressAnonymousDisabledMsg, 403)
}
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
return c.text(msgs.NewAddressDisabledMsg, 403)
}
// 如果启用了禁止匿名创建,且用户已登录,检查地址数量限制
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL) && userPayload) {
const userRole = c.get("userRolePayload");
if (await isAddressCountLimitReached(c, userPayload.user_id, userRole)) {
return c.text(msgs.MaxAddressCountReachedMsg, 400)
}
}
// eslint-disable-next-line prefer-const
let { name, domain, cf_token, enableRandomSubdomain } = await c.req.json();
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
// Check if custom email names are disabled from environment variable
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
// if no name or custom names are disabled, generate random name
if (!name || disableCustomAddressName) {
// Generate random name with context-based length configuration
name = generateRandomName(c);
}
// check name block list
try {
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const blockList = (value || []) as string[];
if (blockList.some((item) => name.includes(item))) {
return c.text(`Name[${name}]is blocked`, 400)
}
} catch (error) {
console.error(error);
}
try {
const addressPrefix = await getAddressPrefix(c);
// Get client IP for source tracking
const sourceMeta = c.req.header('CF-Connecting-IP')
|| c.req.header('X-Forwarded-For')?.split(',')[0]?.trim()
|| c.req.header('X-Real-IP')
|| 'web:unknown';
const res = await newAddress(c, {
name, domain,
enablePrefix: true,
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
checkLengthByConfig: true,
addressPrefix,
sourceMeta
});
return c.json(res);
} catch (e) {
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
}
})
api.delete('/api/delete_address', async (c) => {
const { address, address_id } = c.get("jwtPayload")
const success = await deleteAddressWithData(c, address, address_id);
return c.json({
success: success
})
})
api.delete('/api/clear_inbox', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text(msgs.FailedClearInboxMsg, 500)
}
return c.json({
success: success
})
})
api.delete('/api/clear_sent_items', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text(msgs.FailedClearSentItemsMsg, 500)
}
return c.json({
success: success
})
})
// address settings / lifecycle
api.get('/api/settings', mails_crud.getSettings)
api.post('/api/new_address', new_address.createNewAddress)
api.delete('/api/delete_address', mails_crud.deleteAddress)
api.delete('/api/clear_inbox', mails_crud.clearInbox)
api.delete('/api/clear_sent_items', mails_crud.clearSentItems)
// address auth
api.post('/api/address_change_password', address_auth.changePassword)
api.post('/api/address_login', address_auth.login)

View File

@@ -0,0 +1,120 @@
import { Context } from 'hono'
import i18n from '../i18n';
import { getBooleanValue } from '../utils';
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
import { getSendBalanceState } from './send_balance';
const listMails = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
if (!address) {
return c.json({ "error": "No address" }, 400)
}
const { limit, offset } = c.req.query();
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
return await handleMailListQuery(c,
`SELECT * FROM raw_mails where address = ?`,
`SELECT count(*) as count FROM raw_mails where address = ?`,
[address], limit, offset
);
};
const getMail = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
const { mail_id } = c.req.param();
const result = await c.env.DB.prepare(
`SELECT * FROM raw_mails where id = ? and address = ?`
).bind(mail_id, address).first();
if (!result) return c.json(null);
return c.json(await resolveRawEmailRow(result));
};
const deleteMail = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { id } = c.req.param();
// TODO: add toLowerCase() to handle old data
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ? and id = ? `
).bind(address.toLowerCase(), id).run();
return c.json({ success });
};
const getSettings = async (c: Context<HonoCustomType>) => {
const { address, address_id } = c.get("jwtPayload")
const msgs = i18n.getMessagesbyContext(c);
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ? `
).bind(address_id).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ? `
).bind(address).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
updateAddressUpdatedAt(c, address);
const { balance } = await getSendBalanceState(c, address);
return c.json({
address: address,
send_balance: balance || 0,
});
};
const deleteAddress = async (c: Context<HonoCustomType>) => {
const { address, address_id } = c.get("jwtPayload")
const success = await deleteAddressWithData(c, address, address_id);
return c.json({ success });
};
const clearInbox = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text(msgs.FailedClearInboxMsg, 500)
}
return c.json({ success });
};
const clearSentItems = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text(msgs.FailedClearSentItemsMsg, 500)
}
return c.json({ success });
};
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };

View File

@@ -0,0 +1,74 @@
import { Context } from 'hono'
import i18n from '../i18n';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, isAddressCountLimitReached } from '../utils';
import { newAddress, getAddressPrefix, generateRandomName } from '../common'
import { CONSTANTS } from '../constants'
const createNewAddress = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const userPayload = c.get("userPayload");
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL)
&& !userPayload
) {
return c.text(msgs.NewAddressAnonymousDisabledMsg, 403)
}
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
return c.text(msgs.NewAddressDisabledMsg, 403)
}
// 如果启用了禁止匿名创建,且用户已登录,检查地址数量限制
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL) && userPayload) {
const userRole = c.get("userRolePayload");
if (await isAddressCountLimitReached(c, userPayload.user_id, userRole)) {
return c.text(msgs.MaxAddressCountReachedMsg, 400)
}
}
// eslint-disable-next-line prefer-const
let { name, domain, cf_token, enableRandomSubdomain } = await c.req.json();
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
// Check if custom email names are disabled from environment variable
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
// if no name or custom names are disabled, generate random name
if (!name || disableCustomAddressName) {
name = generateRandomName(c);
}
// check name block list
try {
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const blockList = (value || []) as string[];
if (blockList.some((item) => name.includes(item))) {
return c.text(`Name[${name}]is blocked`, 400)
}
} catch (error) {
console.error(error);
}
try {
const addressPrefix = await getAddressPrefix(c);
const sourceMeta = c.req.header('CF-Connecting-IP')
|| c.req.header('X-Forwarded-For')?.split(',')[0]?.trim()
|| c.req.header('X-Real-IP')
|| 'web:unknown';
const res = await newAddress(c, {
name, domain,
enablePrefix: true,
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
checkLengthByConfig: true,
addressPrefix,
sourceMeta
});
return c.json(res);
} catch (e) {
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
}
};
export default { createNewAddress };

View File

@@ -0,0 +1,52 @@
import { Context } from 'hono'
import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
const toParsedMailRow = async (row: Record<string, unknown>): Promise<Record<string, unknown>> => {
const raw = typeof row.raw === 'string' ? row.raw : '';
const parsed = raw ? await commonParseMail({ rawEmail: raw }) : undefined;
const { raw: _raw, ...rest } = row;
return {
...rest,
sender: parsed?.sender ?? '',
subject: parsed?.subject ?? '',
text: parsed?.text ?? '',
html: parsed?.html ?? '',
attachments: (parsed?.attachments ?? []).map(a => ({
filename: a.filename,
mimeType: a.mimeType,
disposition: a.disposition,
size: a.content?.length ?? 0,
})),
};
};
const listParsedMails = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload");
if (!address) return c.json({ "error": "No address" }, 400);
const { limit, offset } = c.req.query();
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
const listRes = await handleMailListQuery(c,
`SELECT * FROM raw_mails where address = ?`,
`SELECT count(*) as count FROM raw_mails where address = ?`,
[address], limit, offset
);
if (listRes.status !== 200) return listRes;
const { results, count } = await listRes.json() as { results: Record<string, unknown>[], count: number };
const parsed = await Promise.all(results.map(toParsedMailRow));
return c.json({ results: parsed, count });
};
const getParsedMail = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload");
const { mail_id } = c.req.param();
const row = await c.env.DB.prepare(
`SELECT * FROM raw_mails where id = ? and address = ?`
).bind(mail_id, address).first();
if (!row) return c.json(null);
const resolved = await resolveRawEmailRow(row);
return c.json(await toParsedMailRow(resolved as Record<string, unknown>));
};
export default { listParsedMails, getParsedMail };

View File

@@ -0,0 +1,107 @@
import { Context } from 'hono'
import { CONSTANTS } from '../constants'
import { getJsonSetting, getIntValue, getSplitStringListValue } from '../utils'
const ensureDefaultSendBalance = async (
c: Context<HonoCustomType>,
address: string
): Promise<void> => {
if (!address) {
return;
}
const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0);
if (default_balance <= 0) {
return;
}
// Auto-initialize a sender row only when one does not exist yet.
// Existing rows — including admin-disabled ones — are never touched.
await c.env.DB.prepare(
`INSERT INTO address_sender (address, balance, enabled) VALUES (?, ?, ?)
ON CONFLICT(address) DO NOTHING`
).bind(address, default_balance, 1).run();
}
export const getEnabledSendBalance = async (
c: Context<HonoCustomType>,
address: string
): Promise<number | null> => {
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender where address = ? and enabled = 1`
).bind(address).first<number>("balance");
return typeof balance === "number" ? balance : null;
}
export const getSendBalanceState = async (
c: Context<HonoCustomType>,
address: string,
options?: {
isAdmin?: boolean,
initializeDefaultBalance?: boolean
}
): Promise<{
isNoLimitSender: boolean,
needCheckBalance: boolean,
balance: number | null
}> => {
const user_role = c.get("userRolePayload");
const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE);
const is_no_limit_send_balance = typeof user_role === "string"
&& no_limit_roles.includes(user_role);
const noLimitSendAddressList = is_no_limit_send_balance ?
[] : await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY) || [];
const isNoLimitSendAddress = !!noLimitSendAddressList?.includes(address);
const isNoLimitSender = is_no_limit_send_balance || isNoLimitSendAddress;
const needCheckBalance = !options?.isAdmin && !isNoLimitSender;
if (needCheckBalance && options?.initializeDefaultBalance !== false) {
await ensureDefaultSendBalance(c, address);
}
if (isNoLimitSender) {
return {
isNoLimitSender: true,
needCheckBalance: false,
balance: 99999,
};
}
return {
isNoLimitSender: false,
needCheckBalance: needCheckBalance,
balance: await getEnabledSendBalance(c, address),
};
}
export const requestSendMailAccess = async (
c: Context<HonoCustomType>,
address: string
): Promise<{
status: 'ok' | 'already_requested' | 'operation_failed'
}> => {
const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0);
if (default_balance > 0) {
await ensureDefaultSendBalance(c, address);
const { balance } = await getSendBalanceState(c, address, {
initializeDefaultBalance: false,
});
if (balance && balance > 0) {
return { status: 'ok' };
}
return { status: 'already_requested' };
}
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address_sender (address, balance, enabled) VALUES (?, ?, ?)`
).bind(
address, default_balance, default_balance > 0 ? 1 : 0
).run();
if (!success) {
return { status: 'operation_failed' };
}
} catch (e) {
const message = (e as Error).message;
if (message && message.includes("UNIQUE")) {
return { status: 'already_requested' };
}
return { status: 'operation_failed' };
}
return { status: 'ok' };
}

View File

@@ -6,9 +6,11 @@ import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer';
import i18n from '../i18n';
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue, getJsonObjectValue, getSplitStringListValue } from '../utils';
import { getJsonSetting, getDomains, getBooleanValue, getJsonObjectValue } from '../utils';
import { GeoData } from '../models'
import { handleListQuery, updateAddressUpdatedAt } from '../common'
import { handleListQuery, isSendMailBindingEnabled, updateAddressUpdatedAt } from '../common'
import { getSendBalanceState, requestSendMailAccess } from './send_balance';
import { ensureSendMailLimit, increaseSendMailLimitCount } from './send_mail_limit_utils';
export const api = new Hono<HonoCustomType>()
@@ -19,24 +21,14 @@ api.post('/api/request_send_mail_access', async (c) => {
if (!address) {
return c.text(msgs.AddressNotFoundMsg, 400)
}
try {
const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0);
const { success } = await c.env.DB.prepare(
`INSERT INTO address_sender (address, balance, enabled) VALUES (?, ?, ?)`
).bind(
address, default_balance, default_balance > 0 ? 1 : 0
).run();
if (!success) {
return c.text(msgs.OperationFailedMsg, 500)
}
} catch (e) {
const message = (e as Error).message;
if (message && message.includes("UNIQUE")) {
return c.text(msgs.AlreadyRequestedMsg, 400)
}
return c.text(msgs.OperationFailedMsg, 500)
const result = await requestSendMailAccess(c, address);
if (result.status === "ok") {
return c.json({ status: "ok" })
}
return c.json({ status: "ok" })
if (result.status === "already_requested") {
return c.text(msgs.AlreadyRequestedMsg, 400)
}
return c.text(msgs.OperationFailedMsg, 500)
})
export const sendMailToVerifyAddress = async (
@@ -63,6 +55,25 @@ export const sendMailToVerifyAddress = async (
await c.env.SEND_MAIL.send(message);
}
export const sendMailByBinding = async (
c: Context<HonoCustomType>, address: string,
reqJson: {
from_name: string, to_mail: string, to_name: string,
subject: string, content: string, is_html: boolean
}
): Promise<void> => {
const {
from_name, to_mail, to_name,
subject, content, is_html
} = reqJson;
await c.env.SEND_MAIL.send({
from: from_name ? { email: address, name: from_name } : address,
to: to_name ? [`${to_name} <${to_mail}>`] : [to_mail],
subject,
...(is_html ? { html: content } : { text: content }),
});
}
const sendMailByResend = async (
c: Context<HonoCustomType>, address: string,
reqJson: {
@@ -137,21 +148,11 @@ export const sendMail = async (
if (!domains.includes(mailDomain)) {
throw new Error(msgs.InvalidDomainMsg)
}
const user_role = c.get("userRolePayload");
const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE);
const is_no_limit_send_balance = user_role && no_limit_roles.includes(user_role);
// no need find noLimitSendAddressList if is_no_limit_send_balance
const noLimitSendAddressList = is_no_limit_send_balance ?
[] : await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY) || [];
const isNoLimitSendAddress = noLimitSendAddressList?.includes(address);
const needCheckBalance = !is_no_limit_send_balance && !options?.isAdmin && !isNoLimitSendAddress;
if (needCheckBalance) {
// check permission
const balance = await c.env.DB.prepare(
`SELECT balance FROM address_sender
where address = ? and enabled = 1`
).bind(address).first<number>("balance");
if (!balance || balance <= 0) {
const sendBalanceState = await getSendBalanceState(c, address, {
isAdmin: options?.isAdmin,
});
if (sendBalanceState.needCheckBalance) {
if (!sendBalanceState.balance || sendBalanceState.balance <= 0) {
throw new Error(msgs.NoBalanceMsg)
}
}
@@ -173,6 +174,7 @@ export const sendMail = async (
if (!content) {
throw new Error(msgs.ContentEmptyMsg)
}
await ensureSendMailLimit(c);
// send to verified address list, do not update balance
const resendEnabled = c.env.RESEND_TOKEN || c.env[
@@ -190,6 +192,7 @@ export const sendMail = async (
sendByVerifiedAddressList = true;
}
}
const sendMailBindingEnabled = isSendMailBindingEnabled(c, mailDomain);
// send mail workflow
if (sendByVerifiedAddressList) {
@@ -202,15 +205,16 @@ export const sendMail = async (
else if (smtpConfig) {
await sendMailBySmtp(c, address, reqJson, smtpConfig);
}
else {
if (c.env.SEND_MAIL) {
throw new Error(`${msgs.EnableResendOrSmtpWithVerifiedMsg} (${mailDomain})`);
}
throw new Error(`${msgs.EnableResendOrSmtpMsg} (${mailDomain})`);
else if (sendMailBindingEnabled) {
await sendMailByBinding(c, address, reqJson);
}
else {
throw new Error(`${msgs.EnableResendOrSmtpOrSendMailMsg} (${mailDomain})`);
}
await increaseSendMailLimitCount(c);
// update balance
if (!sendByVerifiedAddressList && needCheckBalance) {
if (!sendByVerifiedAddressList && sendBalanceState.needCheckBalance) {
try {
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET balance = balance - 1 where address = ?`

View File

@@ -0,0 +1,193 @@
import { Context } from "hono";
import i18n from "../i18n";
import { SendMailLimitConfig } from "../models";
import { CONSTANTS } from "../constants";
import { getJsonObjectValue, getSetting } from "../utils";
class SendMailLimitError extends Error {
constructor(message: string) {
super(message);
}
}
const parseLimitValue = (value: unknown): number | null => {
if (value === null || typeof value === "undefined") {
return null;
}
if (!Number.isInteger(value) || (value as number) < -1) {
return null;
}
return value as number;
}
const isValidLimitValue = (value: number | null): boolean => {
return value === -1 || (value !== null && value >= 0);
}
const parseSendMailLimitConfig = (value: unknown): SendMailLimitConfig | null => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const config = value as Record<string, unknown>;
if (typeof config.dailyEnabled !== "boolean" || typeof config.monthlyEnabled !== "boolean") {
return null;
}
const dailyLimit = parseLimitValue(config.dailyLimit);
const monthlyLimit = parseLimitValue(config.monthlyLimit);
const monthlyValid = config.monthlyEnabled
? isValidLimitValue(monthlyLimit)
: (config.monthlyLimit === null || typeof config.monthlyLimit === "undefined" || monthlyLimit !== null);
const dailyValid = config.dailyEnabled
? isValidLimitValue(dailyLimit)
: (config.dailyLimit === null || typeof config.dailyLimit === "undefined" || dailyLimit !== null);
if (!dailyValid || !monthlyValid) {
return null;
}
return {
dailyEnabled: config.dailyEnabled,
monthlyEnabled: config.monthlyEnabled,
dailyLimit,
monthlyLimit,
};
}
export const validateSendMailLimitConfig = (value: unknown): boolean => {
return !!parseSendMailLimitConfig(value);
}
export const getSendMailLimitConfigToSave = (
value: unknown
): SendMailLimitConfig | null => {
const sendMailLimitConfig = parseSendMailLimitConfig(value);
if (!sendMailLimitConfig) {
return null;
}
return {
dailyEnabled: sendMailLimitConfig.dailyEnabled,
monthlyEnabled: sendMailLimitConfig.monthlyEnabled,
dailyLimit: sendMailLimitConfig.dailyEnabled ? sendMailLimitConfig.dailyLimit : null,
monthlyLimit: sendMailLimitConfig.monthlyEnabled ? sendMailLimitConfig.monthlyLimit : null,
};
}
export const getSendMailLimitConfig = async (
c: Context<HonoCustomType>
): Promise<SendMailLimitConfig | null> => {
return getSendMailLimitConfigToSave(getJsonObjectValue<SendMailLimitConfig>(
await getSetting(c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY)
));
}
const getDailyCountKey = (date: Date = new Date()): string => {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
return `${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}daily:${yyyy}-${mm}-${dd}`;
}
const getMonthlyCountKey = (date: Date = new Date()): string => {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}monthly:${yyyy}-${mm}`;
}
const getCount = async (
c: Context<HonoCustomType>,
key: string
): Promise<number> => {
const value = await getSetting(c, key);
if (!value) {
return 0;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
return 0;
}
return parsed;
}
const cleanupSendMailLimitCount = async (
c: Context<HonoCustomType>,
currentDailyKey: string,
currentMonthlyKey: string
): Promise<void> => {
await c.env.DB.batch([
c.env.DB.prepare(
`DELETE FROM settings
WHERE key LIKE ?
AND key < ?`
).bind(`${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}daily:%`, currentDailyKey),
c.env.DB.prepare(
`DELETE FROM settings
WHERE key LIKE ?
AND key < ?`
).bind(`${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}monthly:%`, currentMonthlyKey),
]);
}
export const ensureSendMailLimit = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const msgs = i18n.getMessagesbyContext(c);
const config = await getSendMailLimitConfig(c);
if (!config || (!config.dailyEnabled && !config.monthlyEnabled)) {
return;
}
if (config.dailyEnabled && config.dailyLimit !== null && config.dailyLimit !== -1) {
const current = await getCount(c, getDailyCountKey());
if (current >= config.dailyLimit) {
throw new SendMailLimitError(msgs.ServerSendMailDailyLimitMsg);
}
}
if (config.monthlyEnabled && config.monthlyLimit !== null && config.monthlyLimit !== -1) {
const current = await getCount(c, getMonthlyCountKey());
if (current >= config.monthlyLimit) {
throw new SendMailLimitError(msgs.ServerSendMailMonthlyLimitMsg);
}
}
} catch (error) {
if (error instanceof SendMailLimitError) {
throw error;
}
console.warn("Failed to ensure send mail limit", error);
}
}
const increaseCount = async (
c: Context<HonoCustomType>,
key: string,
): Promise<void> => {
await c.env.DB.prepare(
`INSERT INTO settings (key, value)
VALUES (?, '1')
ON CONFLICT(key) DO UPDATE SET
value = CAST(COALESCE(value, '0') AS INTEGER) + 1,
updated_at = datetime('now')`
).bind(key).run();
}
export const increaseSendMailLimitCount = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const config = await getSendMailLimitConfig(c);
if (!config || (!config.dailyEnabled && !config.monthlyEnabled)) {
return;
}
const dailyKey = getDailyCountKey();
const monthlyKey = getMonthlyCountKey();
if (config.dailyEnabled) {
await increaseCount(c, dailyKey);
}
if (config.monthlyEnabled) {
await increaseCount(c, monthlyKey);
}
await cleanupSendMailLimitCount(c, dailyKey, monthlyKey);
} catch (error) {
if (error instanceof SendMailLimitError) {
throw error;
}
console.warn(`Failed to increment send_mail_limit_count`, error);
}
}

View File

@@ -113,7 +113,7 @@ export class UserSettings {
this.verifyMailSender = verifyMailSender;
this.enableMailAllowList = enableMailAllowList;
this.mailAllowList = mailAllowList;
this.maxAddressCount = maxAddressCount || 5;
this.maxAddressCount = (typeof maxAddressCount === "number" && maxAddressCount >= 0) ? maxAddressCount : 5;
this.enableEmailCheckRegex = enableEmailCheckRegex;
this.emailCheckRegex = emailCheckRegex;
}
@@ -184,6 +184,13 @@ export type EmailRuleSettings = {
emailForwardingList: SubdomainForwardAddressList[]
}
export type SendMailLimitConfig = {
dailyEnabled: boolean;
monthlyEnabled: boolean;
dailyLimit: number | null;
monthlyLimit: number | null;
}
export type RoleConfig = {
maxAddressCount?: number;
// future configs can be added here

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