Compare commits

...
Author SHA1 Message Date
dreamhunter2333andClaude Opus 4.7 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 HunterandClaude Opus 4.7 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 HunterandClaude Opus 4.7 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 HunterandClaude Opus 4.6 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 HunterandClaude Opus 4.7 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 HunterandClaude Opus 4.6 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
64 changed files with 4156 additions and 1917 deletions
@@ -0,0 +1,3 @@
config.json
__pycache__/
*.py[cod]
@@ -0,0 +1,30 @@
---
name: cf-temp-mail-release-notify
description: Announce a cloudflare_temp_email GitHub release to the project's Telegram channel topic. Use when the user asks to notify/announce/broadcast a release to Telegram, push release notes to the channel, or send a release to the topic after running cf-temp-mail-release. Posts bilingual (中文 + English) changelog excerpts plus the release URL.
---
# Release Notify Workflow
Post an existing GitHub release's notes to the project's Telegram channel topic.
## Prerequisites
- `config.json` exists in this skill directory with `token`, `chat_id`, `message_thread_id` (gitignored, never commit).
- `gh` CLI authenticated.
- `uv` installed (`brew install uv` / `curl -LsSf https://astral.sh/uv/install.sh | sh`). Script uses PEP 723 inline metadata; `uv` auto-installs deps.
## Steps
1. **Resolve tag**: If the user didn't give one, use the latest release: `gh release list --limit 1 --json tagName --jq '.[0].tagName'`.
2. **Run the script**:
```bash
uv run scripts/send_release_to_telegram.py vX.Y.Z
```
The script fetches the release via `gh`, splits the body into zh/en sections, strips PR collapsibles and the cache-clearing link, truncates to fit Telegram's 4096-char limit, and posts to the configured `chat_id` + `message_thread_id`.
3. **Verify**: The script prints `ok: message_id=<id>` on success. Report the message id.
## Notes
- Message uses `parse_mode: MarkdownV2`; all content is escaped (via `md_escape`) to avoid parse errors on reserved chars `_ * [ ] ( ) ~ \` > # + - = | { } . !`.
- Only the zh/en changelog sections are posted. PRs list and the cache-clearing discussion link are stripped to keep the message concise.
- For very long release bodies, zh and en are each truncated to ~half of the 3500-char body budget.
@@ -0,0 +1,5 @@
{
"token": "<telegram bot token>",
"chat_id": "@cloudflare_temp_email",
"message_thread_id": 82
}
@@ -0,0 +1,221 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["httpx>=0.27"]
# ///
"""Send a cloudflare_temp_email release announcement to a Telegram channel topic.
Usage:
uv run scripts/send_release_to_telegram.py <tag>
Reads skill config from ../config.json (relative to this script):
{
"token": "...",
"chat_id": "@channel_or_-100...",
"message_thread_id": 82
}
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
import httpx
TG_API = "https://api.telegram.org"
TG_HARD_LIMIT = 4096
BODY_BUDGET = 3500 # leave room for header + footer
EN_MARKER_RE = re.compile(r"<details>\s*<summary>English</summary>", re.IGNORECASE)
MDV2_ESCAPE_RE = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
MDV2_CODE_ESCAPE_RE = re.compile(r"([`\\])")
MD_INLINE_RE = re.compile(r"\*\*(.+?)\*\*|`([^`]+)`")
MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
def die(msg: str) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def md_escape(text: str) -> str:
"""Escape all MarkdownV2 reserved characters."""
return MDV2_ESCAPE_RE.sub(r"\\\1", text)
def md_render(text: str) -> str:
"""Convert source Markdown (changelog) to Telegram MarkdownV2.
Handles:
- `### Heading` -> bold line
- `**bold**` -> `*bold*`
- `` `code` `` -> `` `code` `` (only ` and \\ escaped inside)
- everything else: literal text with MDV2 specials escaped
"""
out: list[str] = []
for raw in text.splitlines():
m = MD_HEADING_RE.match(raw)
if m:
out.append(f"*{md_escape(m.group(2).strip())}*")
continue
segments: list[str] = []
last = 0
for im in MD_INLINE_RE.finditer(raw):
segments.append(md_escape(raw[last:im.start()]))
if im.group(1) is not None:
segments.append(f"*{md_escape(im.group(1))}*")
else:
segments.append(f"`{MDV2_CODE_ESCAPE_RE.sub(r'\\\\\1', im.group(2))}`")
last = im.end()
segments.append(md_escape(raw[last:]))
out.append("".join(segments))
return "\n".join(out)
def load_config() -> dict:
cfg_path = Path(__file__).resolve().parent.parent / "config.json"
if not cfg_path.exists():
die(f"config missing: {cfg_path}")
try:
cfg = json.loads(cfg_path.read_text())
except json.JSONDecodeError as e:
die(f"config.json is not valid JSON: {e}")
for k in ("token", "chat_id", "message_thread_id"):
if k not in cfg:
die(f"config.json missing key: {k}")
return cfg
def fetch_release(tag: str) -> dict:
out = subprocess.run(
["gh", "release", "view", tag, "--json", "tagName,name,body,url"],
capture_output=True, text=True, check=False,
)
if out.returncode != 0:
die(f"gh release view failed: {out.stderr.strip()}")
return json.loads(out.stdout)
def extract_sections(body: str) -> tuple[str, str]:
m = EN_MARKER_RE.search(body)
if not m:
return body.strip(), ""
zh = body[: m.start()]
rest = body[m.end():]
close = rest.find("</details>")
if close < 0:
die("malformed release body: missing </details> after English marker")
en = rest[:close]
return zh.strip(), en.strip()
def strip_noise(text: str) -> str:
"""Drop PR collapsibles, cache-clearing link, and Full Changelog line."""
lines = text.splitlines()
out: list[str] = []
depth = 0
for line in lines:
stripped = line.strip()
if stripped.startswith("<details>"):
depth += 1
continue
if stripped.startswith("</details>"):
depth = max(0, depth - 1)
continue
if depth > 0:
continue
if "discussions/487" in stripped:
continue
if stripped.startswith("**Full Changelog**"):
continue
out.append(line)
result: list[str] = []
blanks = 0
for line in out:
if not line.strip():
blanks += 1
if blanks <= 1:
result.append(line)
else:
blanks = 0
result.append(line)
return "\n".join(result).strip()
def truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 3].rstrip() + "..."
def _budget(zh: str, en: str, total: int) -> tuple[int, int]:
"""Split budget between zh and en based on actual length. Short side keeps full, long side absorbs the rest."""
if not en:
return total, 0
if len(zh) + len(en) <= total:
return len(zh), len(en)
half = total // 2
if len(zh) <= half:
return len(zh), total - len(zh)
if len(en) <= half:
return total - len(en), len(en)
return half, total - half
def build_message(tag: str, name: str, url: str, body: str) -> str:
zh, en = extract_sections(body)
zh = strip_noise(zh)
en = strip_noise(en)
zh_limit, en_limit = _budget(zh, en, BODY_BUDGET)
zh = truncate(zh, zh_limit)
en = truncate(en, en_limit) if en else ""
title = md_escape(name or tag)
header = f"🚀 *{title} 已发布 / Released*"
parts = [header, "", md_render(zh)]
if en:
parts.extend(["", "__English__", "", md_render(en)])
parts.extend(["", f"🔗 {md_escape(url)}"])
return "\n".join(parts)
def send(cfg: dict, text: str) -> None:
payload = {
"chat_id": cfg["chat_id"],
"message_thread_id": cfg["message_thread_id"],
"text": text,
"parse_mode": "MarkdownV2",
"disable_web_page_preview": False,
}
try:
resp = httpx.post(
f"{TG_API}/bot{cfg['token']}/sendMessage",
json=payload,
timeout=30,
)
except httpx.HTTPError as e:
die(f"Telegram network error: {e}")
try:
data = resp.json()
except ValueError:
die(f"Telegram API returned non-JSON ({resp.status_code}): {resp.text[:200]!r}")
if resp.status_code != 200 or not data.get("ok"):
die(f"Telegram API rejected ({resp.status_code}): {data}")
print(f"ok: message_id={data['result'].get('message_id')}")
def main() -> None:
if len(sys.argv) != 2:
die("usage: send_release_to_telegram.py <tag>")
cfg = load_config()
rel = fetch_release(sys.argv[1])
text = build_message(rel["tagName"], rel.get("name", ""), rel["url"], rel.get("body", ""))
send(cfg, text)
if __name__ == "__main__":
main()
@@ -1,5 +1,5 @@
--- ---
name: release name: cf-temp-mail-release
description: Create a GitHub release for cloudflare_temp_email project. Use when the user asks to create a release, publish a version, tag a release, or make a new release. Reads CHANGELOG.md for release content, collects merged PRs via `gh` CLI, and creates a properly formatted GitHub release. 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.
--- ---
@@ -0,0 +1,43 @@
---
name: cf-temp-mail-upgrade-dependencies
description: Upgrade npm dependencies across all sub-packages of the project. Use when the user asks to upgrade/update dependencies, bump deps, refresh lockfiles, or update wrangler. Runs pnpm upgrades on frontend/, worker/, pages/, and vitepress-docs/.
---
# Upgrade Dependencies
Upgrade npm dependencies for the cloudflare_temp_email sub-packages.
## How to run
Execute the project-root script:
```bash
bash scripts/update-dependencies.sh
```
The script runs the following in order:
| Directory | Commands |
|-----------|----------|
| `frontend/` | `pnpm up` + `pnpm add -D wrangler@latest` |
| `worker/` | `pnpm up` + `pnpm add -D wrangler@latest` |
| `pages/` | `pnpm up` + `pnpm add -D wrangler@latest` |
| `vitepress-docs/` | `pnpm up --latest` + `pnpm add -D wrangler@latest` |
Note: `vitepress-docs/` uses `--latest` (crosses semver ranges); other packages upgrade within ranges only.
## Post-upgrade checklist
1. Inspect `git diff` on `package.json` / `pnpm-lock.yaml` files for reasonable changes.
2. Verify builds in each sub-package:
- `cd frontend && pnpm build`
- `cd worker && pnpm build && pnpm lint`
- `cd vitepress-docs && pnpm build`
3. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
4. Commit with Conventional Commits format, e.g. `chore: upgrade dependencies`.
## Do NOT
- Do not manually `pnpm add` each package instead of running the script.
- Do not run `pnpm deploy` locally — deployments go through GitHub Actions.
- Do not update CHANGELOG for routine dep bumps unless the user explicitly requests it.
+161
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.
@@ -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
```
-54
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)` 之前插入(即语言切换链接 `</p>` 之后):
```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
```
+16 -30
View File
@@ -1,16 +1,19 @@
name: Deploy Docs name: Deploy Docs
on: on:
push: workflow_run:
paths: workflows: ["Tag Build CI"]
- "vitepress-docs/**" types:
tags: - completed
- "*"
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest 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: permissions:
contents: write contents: write
steps: steps:
@@ -18,6 +21,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
@@ -31,34 +35,16 @@ jobs:
version: 10 version: 10
run_install: false run_install: false
- name: check github release done - name: Deploy Docs
run: | env:
for ((attempt=1; attempt<=10; attempt++)); do CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
echo "frontend.zip found." GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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: | run: |
cd vitepress-docs/ cd vitepress-docs/
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip 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 pnpm install --no-frozen-lockfile
if [[ ${{github.ref}} == refs/tags/* ]]; then TAG_NAME=$(gh release view --json tagName --jq '.tagName')
export TAG_NAME=${{github.ref_name}}
else
export TAG_NAME=$(git describe --tags --abbrev=0)
fi
echo "Deploying docs for tag $TAG_NAME" echo "Deploying docs for tag $TAG_NAME"
export TAG_NAME
pnpm run deploy pnpm run deploy
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+31
View File
@@ -6,6 +6,37 @@
<a href="CHANGELOG_EN.md">English</a> <a href="CHANGELOG_EN.md">English</a>
</p> </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) ## v1.6.0(main)
### Features ### Features
+31
View File
@@ -6,6 +6,37 @@
<a href="CHANGELOG_EN.md">English</a> <a href="CHANGELOG_EN.md">English</a>
</p> </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) ## v1.6.0(main)
### Features ### Features
+17
View File
@@ -150,9 +150,26 @@
- [x] Webhook 支持,消息推送集成 - [x] Webhook 支持,消息推送集成
- [x] 支持 `CF Turnstile` 人机验证 - [x] 支持 `CF Turnstile` 人机验证
- [x] 限流配置,防止滥用 - [x] 限流配置,防止滥用
- [x] **Agent 友好**:提供服务端解析的 `/api/parsed_mails` / `/api/parsed_mail/:id`,配合仓库内的 `cf-temp-mail-usage` skillOpenClaw / Codex / Cursor 等 AI agent 可直接使用用户提供的 JWT 读取验证码 / 链接,无需在客户端引入 MIME 解析器
</details> </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> <details>
+17
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] Webhook support and message push integration
- [x] Support `CF Turnstile` CAPTCHA verification - [x] Support `CF Turnstile` CAPTCHA verification
- [x] Rate limiting configuration to prevent abuse - [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> </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 ## Technical Architecture
<details> <details>
+21
View File
@@ -72,6 +72,24 @@ services:
start_period: 10s start_period: 10s
retries: 20 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: frontend:
build: build:
context: .. context: ..
@@ -128,6 +146,7 @@ services:
WORKER_URL_SUBDOMAIN: http://worker-subdomain:8789 WORKER_URL_SUBDOMAIN: http://worker-subdomain:8789
WORKER_URL_ENV_OFF: http://worker-env-off:8790 WORKER_URL_ENV_OFF: http://worker-env-off:8790
WORKER_GZIP_URL: http://worker-gzip:8788 WORKER_GZIP_URL: http://worker-gzip:8788
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
FRONTEND_URL: https://frontend:5173 FRONTEND_URL: https://frontend:5173
MAILPIT_API: http://mailpit:8025/api MAILPIT_API: http://mailpit:8025/api
SMTP_PROXY_HOST: smtp-proxy SMTP_PROXY_HOST: smtp-proxy
@@ -146,6 +165,8 @@ services:
condition: service_healthy condition: service_healthy
worker-gzip: worker-gzip:
condition: service_healthy condition: service_healthy
worker-send-mail-domain:
condition: service_healthy
frontend: frontend:
condition: service_started condition: service_started
smtp-proxy: smtp-proxy:
+60 -2
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_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || ''; 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_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 FRONTEND_URL = process.env.FRONTEND_URL!;
export const MAILPIT_API = process.env.MAILPIT_API!; export const MAILPIT_API = process.env.MAILPIT_API!;
export const TEST_DOMAIN = 'test.example.com'; export const TEST_DOMAIN = 'test.example.com';
@@ -182,8 +183,9 @@ export function onMailpitMessage(
/** /**
* Request send mail access for an address. * Request send mail access for an address.
* Must be called before sending mail — creates the address_sender row * Kept for backward compatibility and manual-request flows. When
* with the DEFAULT_SEND_BALANCE configured in the worker. * DEFAULT_SEND_BALANCE > 0, send balance may already be auto-initialized
* before this endpoint is called.
*/ */
export async function requestSendAccess( export async function requestSendAccess(
ctx: APIRequestContext, 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. * Delete a test address via its JWT.
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@ ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false
JWT_SECRET = "e2e-test-secret-key-env-off" JWT_SECRET = "e2e-test-secret-key-env-off"
BLACK_LIST = "" BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true ENABLE_USER_DELETE_EMAIL = false
ENABLE_AUTO_REPLY = true ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10 DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true ENABLE_ADDRESS_PASSWORD = true
@@ -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"
+3 -6
View File
@@ -1,18 +1,15 @@
import { test, expect } from '@playwright/test'; 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.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 // Create address
const { jwt, address, address_id } = await createTestAddress(request, 'lifecycle-test'); const { jwt, address, address_id } = await createTestAddress(request, 'lifecycle-test');
expect(address).toContain('@' + TEST_DOMAIN); expect(address).toContain('@' + TEST_DOMAIN);
expect(jwt).toBeTruthy(); expect(jwt).toBeTruthy();
expect(address_id).toBeGreaterThan(0); expect(address_id).toBeGreaterThan(0);
// Request send access (creates address_sender row with DEFAULT_SEND_BALANCE) // Fetch address settings — balance should auto-initialize from DEFAULT_SEND_BALANCE=10
await requestSendAccess(request, jwt);
// Fetch address settings — balance should match DEFAULT_SEND_BALANCE=10
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, { const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` }, headers: { Authorization: `Bearer ${jwt}` },
}); });
+102 -1
View File
@@ -1,7 +1,108 @@
import { createHash } from 'node:crypto';
import { test, expect } from '@playwright/test'; 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.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 }) => { test('delete a single mail by ID', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'del-single'); const { jwt, address } = await createTestAddress(request, 'del-single');
+122 -8
View File
@@ -1,12 +1,20 @@
import { test, expect } from '@playwright/test'; 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.describe('Send Access', () => {
test('request send access succeeds once, duplicate returns 400', async ({ request }) => { test('request send access stays idempotent when default balance is auto-initialized', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'send-access'); const { jwt, address } = await createTestAddress(request, 'send-access');
try { try {
// First request — should succeed // First request — should succeed even if balance will also auto-init elsewhere.
await requestSendAccess(request, jwt); await requestSendAccess(request, jwt);
// Verify balance is set via settings // Verify balance is set via settings
@@ -17,13 +25,119 @@ test.describe('Send Access', () => {
const settings = await settingsRes.json(); const settings = await settingsRes.json();
expect(settings.send_balance).toBe(10); 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`, { const dupRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, {
headers: { Authorization: `Bearer ${jwt}` }, headers: { Authorization: `Bearer ${jwt}` },
}); });
expect(dupRes.status()).toBe(400); expect(dupRes.ok()).toBe(true);
const dupBody = await dupRes.text();
expect(dupBody).toContain('Already'); 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 { } finally {
await deleteAddress(request, jwt); await deleteAddress(request, jwt);
} }
+517
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);
});
});
+8 -5
View File
@@ -3,7 +3,6 @@ import {
createTestAddress, createTestAddress,
deleteAddress, deleteAddress,
deleteAllMailpitMessages, deleteAllMailpitMessages,
requestSendAccess,
onMailpitMessage, onMailpitMessage,
WORKER_URL, WORKER_URL,
} from '../../fixtures/test-helpers'; } from '../../fixtures/test-helpers';
@@ -15,10 +14,6 @@ test.describe('Send Mail via SMTP', () => {
test('send HTML email and verify in Mailpit', async ({ request }) => { test('send HTML email and verify in Mailpit', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'sender-test'); 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 subject = `E2E Test ${Date.now()}`;
const htmlContent = '<h1>Hello</h1><p>This is an <b>E2E test</b> email.</p>'; 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.From.Address).toBe(address);
expect(mail.To[0].Address).toBe('recipient@test.example.com'); 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 // Cleanup
await deleteAddress(request, jwt); await deleteAddress(request, jwt);
}); });
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.6.0", "version": "1.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -28,8 +28,8 @@
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.15.0", "axios": "^1.15.1",
"dompurify": "^3.3.3", "dompurify": "^3.4.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2", "mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
@@ -43,7 +43,7 @@
"devDependencies": { "devDependencies": {
"@vicons/fa": "^0.13.0", "@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0", "@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.6",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"unplugin-auto-import": "^20.3.0", "unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0", "unplugin-vue-components": "^30.0.0",
@@ -53,7 +53,7 @@
"vitest": "^3.2.4", "vitest": "^3.2.4",
"workbox-build": "^7.4.0", "workbox-build": "^7.4.0",
"workbox-window": "^7.4.0", "workbox-window": "^7.4.0",
"wrangler": "^4.81.1" "wrangler": "^4.83.0"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+265 -257
View File
File diff suppressed because it is too large Load Diff
+86 -1
View File
@@ -21,6 +21,12 @@ const { t } = useI18n({
send_address_block_list: 'Address Block Keywords for send email', send_address_block_list: 'Address Block Keywords for send email',
noLimitSendAddressList: 'No Balance Limit Send Address List', noLimitSendAddressList: 'No Balance Limit Send Address List',
verified_address_list: 'Verified Address List(Can send email by cf internal api)', 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', fromBlockList: 'Block Keywords for receive email',
block_receive_unknow_address_email: 'Block receive unknow address email', block_receive_unknow_address_email: 'Block receive unknow address email',
email_forwarding_config: 'Email Forwarding Configuration', email_forwarding_config: 'Email Forwarding Configuration',
@@ -65,6 +71,12 @@ const { t } = useI18n({
send_address_block_list: '发送邮件地址屏蔽关键词', send_address_block_list: '发送邮件地址屏蔽关键词',
noLimitSendAddressList: '无余额限制发送地址列表', noLimitSendAddressList: '无余额限制发送地址列表',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)', 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: '接收邮件地址屏蔽关键词', fromBlockList: '接收邮件地址屏蔽关键词',
block_receive_unknow_address_email: '禁止接收未知地址邮件', block_receive_unknow_address_email: '禁止接收未知地址邮件',
email_forwarding_config: '邮件转发配置', email_forwarding_config: '邮件转发配置',
@@ -116,7 +128,13 @@ const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
FORCE_ENABLE: 'force_enable', FORCE_ENABLE: 'force_enable',
FORCE_DISABLE: 'force_disable' 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 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({ const addressCreationSubdomainMatchStatus = ref({
envConfigured: false, envConfigured: false,
envEnabled: false, envEnabled: false,
@@ -314,6 +332,31 @@ const getSubdomainMatchPayloadValue = (mode) => {
return null return null
} }
const getSendMailLimitPayload = () => {
return {
dailyEnabled: sendMailDailyLimitEnabled.value,
monthlyEnabled: sendMailMonthlyLimitEnabled.value,
dailyLimit: sendMailDailyLimitEnabled.value ? sendMailDailyLimit.value : null,
monthlyLimit: sendMailMonthlyLimitEnabled.value ? sendMailMonthlyLimit.value : null
}
}
const isValidSendMailLimit = (value) => {
return Number.isInteger(value) && value >= -1
}
const validateSendMailLimit = () => {
if (sendMailDailyLimitEnabled.value && !isValidSendMailLimit(sendMailDailyLimit.value)) {
message.error(t('send_mail_daily_limit_invalid'))
return false
}
if (sendMailMonthlyLimitEnabled.value && !isValidSendMailLimit(sendMailMonthlyLimit.value)) {
message.error(t('send_mail_monthly_limit_invalid'))
return false
}
return true
}
const fetchData = async ({ suppressErrorMessage = false } = {}) => { const fetchData = async ({ suppressErrorMessage = false } = {}) => {
try { try {
const res = await api.fetch(`/admin/account_settings`) const res = await api.fetch(`/admin/account_settings`)
@@ -337,6 +380,15 @@ const fetchData = async ({ suppressErrorMessage = false } = {}) => {
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue( addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
addressCreationSubdomainMatchStatus.value.storedEnabled 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) { } catch (error) {
if (!suppressErrorMessage) { if (!suppressErrorMessage) {
message.error(error.message || "error"); message.error(error.message || "error");
@@ -346,6 +398,9 @@ const fetchData = async ({ suppressErrorMessage = false } = {}) => {
} }
const save = async () => { const save = async () => {
if (!validateSendMailLimit()) {
return
}
try { try {
const payload = { const payload = {
blockList: addressBlockList.value || [], blockList: addressBlockList.value || [],
@@ -356,7 +411,8 @@ const save = async () => {
emailRuleSettings: emailRuleSettings.value, emailRuleSettings: emailRuleSettings.value,
addressCreationSettings: { addressCreationSettings: {
enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value) enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value)
} },
sendMailLimitConfig: getSendMailLimitPayload()
} }
await api.fetch(`/admin/account_settings`, { await api.fetch(`/admin/account_settings`, {
method: 'POST', method: 'POST',
@@ -437,6 +493,35 @@ onMounted(async () => {
</template> </template>
</n-select> </n-select>
</n-form-item-row> </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-form-item-row :label="t('fromBlockList')">
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')"> <n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')">
<template #empty> <template #empty>
+78 -12
View File
@@ -9,6 +9,7 @@ import { api } from '../../api'
const message = useMessage() const message = useMessage()
const isPreview = ref(false) const isPreview = ref(false)
const editorRef = shallowRef() const editorRef = shallowRef()
const sending = ref(false)
const sendMailModel = useSessionStorage('sendMailByAdminModel', { const sendMailModel = useSessionStorage('sendMailByAdminModel', {
fromName: "", fromName: "",
@@ -33,6 +34,10 @@ const { t } = useI18n({
preview: 'Preview', preview: 'Preview',
content: 'Content', content: 'Content',
send: 'Send', send: 'Send',
fromMailEmpty: 'Sender address is empty',
subjectEmpty: 'Subject is empty',
toMailEmpty: 'Recipient address is empty',
contentEmpty: 'Content is empty',
text: 'Text', text: 'Text',
html: 'HTML', html: 'HTML',
'rich text': 'Rich Text', 'rich text': 'Rich Text',
@@ -48,6 +53,10 @@ const { t } = useI18n({
preview: '预览', preview: '预览',
content: '内容', content: '内容',
send: '发送', send: '发送',
fromMailEmpty: '发件人地址不能为空',
subjectEmpty: '主题不能为空',
toMailEmpty: '收件人地址不能为空',
contentEmpty: '内容不能为空',
text: '文本', text: '文本',
html: 'HTML', html: 'HTML',
'rich text': '富文本', 'rich text': '富文本',
@@ -62,21 +71,77 @@ const contentTypes = [
{ label: t('rich text'), value: 'rich' }, { 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 () => { 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 { try {
await api.fetch(`/admin/send_mail`, await api.fetch(`/admin/send_mail`,
{ {
method: 'POST', method: 'POST',
body: body: JSON.stringify(payload)
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,
})
}) })
sendMailModel.value = { sendMailModel.value = {
fromName: "", fromName: "",
@@ -87,10 +152,11 @@ const send = async () => {
contentType: 'text', contentType: 'text',
content: "", content: "",
} }
message.success(t("successSend"));
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
} finally { } finally {
message.success(t("successSend")); sending.value = false
} }
} }
@@ -125,7 +191,7 @@ const handleCreated = (editor) => {
<div class="center"> <div class="center">
<n-card :bordered="false" embedded> <n-card :bordered="false" embedded>
<n-flex justify="end"> <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> </n-flex>
<div class="left"> <div class="left">
<n-form :model="sendMailModel"> <n-form :model="sendMailModel">
+74 -14
View File
@@ -11,6 +11,7 @@ import { api } from '../../api'
const message = useMessage() const message = useMessage()
const isPreview = ref(false) const isPreview = ref(false)
const editorRef = shallowRef() const editorRef = shallowRef()
const sending = ref(false)
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState() const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
@@ -28,8 +29,11 @@ const { t } = useI18n({
preview: 'Preview', preview: 'Preview',
content: 'Content', content: 'Content',
send: 'Send', send: 'Send',
subjectEmpty: 'Subject is empty',
toMailEmpty: 'Recipient address is empty',
contentEmpty: 'Content is empty',
requestAccess: 'Request Access', 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', send_balance: 'Send Mail Balance Left',
text: 'Text', text: 'Text',
html: 'HTML', html: 'HTML',
@@ -46,8 +50,11 @@ const { t } = useI18n({
preview: '预览', preview: '预览',
content: '内容', content: '内容',
send: '发送', send: '发送',
subjectEmpty: '主题不能为空',
toMailEmpty: '收件人地址不能为空',
contentEmpty: '内容不能为空',
requestAccess: '申请权限', requestAccess: '申请权限',
requestAccessTip: '您需要申请权限才能发送邮件, 如果已经申请过, 请联系管理员提升额度。', requestAccessTip: '当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。',
send_balance: '剩余发送邮件额度', send_balance: '剩余发送邮件额度',
text: '文本', text: '文本',
html: 'HTML', html: 'HTML',
@@ -63,20 +70,71 @@ const contentTypes = [
{ label: t('rich text'), value: 'rich' }, { 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 () => { 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 { try {
await api.fetch(`/api/send_mail`, await api.fetch(`/api/send_mail`,
{ {
method: 'POST', method: 'POST',
body: body: JSON.stringify(payload)
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,
})
}) })
sendMailModel.value = { sendMailModel.value = {
fromName: "", fromName: "",
@@ -86,11 +144,13 @@ const send = async () => {
contentType: 'text', contentType: 'text',
content: "", content: "",
} }
isPreview.value = false
message.success(t("successSend"));
indexTab.value = 'sendbox'
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
} finally { } finally {
message.success(t("successSend")); sending.value = false
indexTab.value = 'sendbox'
} }
} }
@@ -158,7 +218,7 @@ onMounted(async () => {
{{ t('send_balance') }}: {{ settings.send_balance }} {{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert> </n-alert>
<n-flex justify="end"> <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> </n-flex>
<div class="left"> <div class="left">
<n-form :model="sendMailModel"> <n-form :model="sendMailModel">
+3 -1
View File
@@ -3,9 +3,11 @@ import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { api } from '../../api' import { api } from '../../api'
import { useGlobalState } from '../../store'
import MailBox from '../../components/MailBox.vue'; import MailBox from '../../components/MailBox.vue';
const message = useMessage() const message = useMessage()
const { openSettings } = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
messages: { messages: {
@@ -78,7 +80,7 @@ onMounted(() => {
</n-button> </n-button>
</n-input-group> </n-input-group>
<div style="margin-top: 10px;"></div> <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" /> :deleteMail="deleteMail" :showFilterInput="true" />
</div> </div>
</template> </template>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "temp-email-pages", "name": "temp-email-pages",
"version": "1.6.0", "version": "1.8.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -11,7 +11,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"wrangler": "^4.81.1" "wrangler": "^4.83.0"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
@@ -34,7 +34,7 @@
| Issue | Solution | | Issue | Solution |
| --------------- | --------------------------------------------------------- | | --------------- | --------------------------------------------------------- |
| Set `DEFAULT_SEND_BALANCE` but still getting `No balance` | `DEFAULT_SEND_BALANCE` is the default quota when users **request sending permission**. Users must first click "Request Send Permission" in the frontend. Alternatively, add the address to the "No Limit Send Address List" in the admin console, or configure `NO_LIMIT_SEND_ROLE` | | 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) | | 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) | | `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) |
@@ -1,12 +1,49 @@
# Configure Email Sending # Configure Email Sending
::: warning Note ::: tip Recommended
All three methods can be configured simultaneously. When sending emails, it will prioritize using `resend`, if `resend` is not configured, it will use `smtp`. 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 ## Send Emails Using Resend
Register at `https://resend.com/domains` and add DNS records according to the instructions. Register at `https://resend.com/domains` and add DNS records according to the instructions.
@@ -111,26 +148,25 @@ wrangler secret put SMTP_CONFIG
Users need a send balance to send emails. The balance mechanism works as follows: Users need a send balance to send emails. The balance mechanism works as follows:
1. **Request Send Permission**: Users must first click the "Request Send Permission" button in the frontend 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. **Default Quota**: Upon requesting, users receive the default quota set by the `DEFAULT_SEND_BALANCE` environment variable (defaults to 0 if not set) 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: 3. **Unlimited Sending**: The following methods can bypass balance checks:
- Add the address to the "No Limit Send Address List" in the admin console - 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 - Configure the `NO_LIMIT_SEND_ROLE` environment variable to specify roles that can send without limits
> [!NOTE] > [!NOTE]
> `DEFAULT_SEND_BALANCE` does **NOT** automatically grant balance to all addresses. Users must actively request send permission first for the quota to take effect. > `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 ## 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. In this compatibility mode, mail is sent via `SEND_MAIL` binding only when the recipient is in the admin `Verified Address List`.
```toml
# Send emails through Cloudflare
send_email = [
{ name = "SEND_MAIL" },
]
```
Admin console account configuration `Verified address list (can send emails through CF internal API)`
+5 -1
View File
@@ -37,8 +37,9 @@
| `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` | | `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"]` | | `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` | | `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` | | `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] > [!NOTE]
> `RANDOM_SUBDOMAIN_DOMAINS` only controls automatic random subdomain generation during mailbox > `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**, > 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 > and **Force Disable**. Saving **Follow Environment Variable** clears the admin override and returns
> the feature to the "unset" fallback behavior. > 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 ## Email Reception Related Variables
@@ -34,7 +34,7 @@
| 问题 | 解决方案 | | 问题 | 解决方案 |
| --------------- | ---------------------------------------- | | --------------- | ---------------------------------------- |
| 设置了 `DEFAULT_SEND_BALANCE` 但仍提示 `No balance` | `DEFAULT_SEND_BALANCE` 是用户**申请发信权限时**的默认额度,用户需要先在前端界面点击「申请发信权限」才会生效。也可以在 admin 后台将地址加入「无限制发送地址列表」或配置 `NO_LIMIT_SEND_ROLE` | | 设置了 `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) | | 提示 `请先为此域名启用 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-发送邮件) | | `SMTP_CONFIG` 配置了但发送失败 | 请确认 JSON 中的 key 是**你自己的发信域名**(如 `your-domain.com`),不要直接复制示例 key。详见 [配置发送邮件](/zh/guide/config-send-mail#使用-smtp-发送邮件) |
@@ -1,13 +1,50 @@
# 配置发送邮件 # 配置发送邮件
::: warning 注意 ::: tip 推荐方案
三种方式可以同时配置,发送邮件时会优先使用 `resend`,如果没有配置 `resend`,则会使用 `smtp`. 推荐使用 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 记录, 注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
@@ -111,26 +148,25 @@ wrangler secret put SMTP_CONFIG
用户发送邮件需要有发信余额。余额机制如下: 用户发送邮件需要有发信余额。余额机制如下:
1. **申请发信权限**:用户需要先在前端界面点击「申请发信权限」按钮 1. **自动初始化默认额度**:当 `DEFAULT_SEND_BALANCE > 0` 时,用户打开前端发信页或第一次调用发信接口时,系统会自动为该地址初始化默认额度
2. **默认额度**申请时会获得 `DEFAULT_SEND_BALANCE` 环境变量设置的默认额度(如果未设置则为 0) 2. **手动申请**如果 `DEFAULT_SEND_BALANCE = 0`,用户仍可以在前端界面点击「申请发信权限」按钮,创建待管理员处理的发信权限记录
3. **无限制发送**:以下方式可以跳过余额检查: 3. **无限制发送**:以下方式可以跳过余额检查:
- 在 admin 后台将地址加入「无限制发送地址列表」 - 在 admin 后台将地址加入「无限制发送地址列表」
- 配置 `NO_LIMIT_SEND_ROLE` 环境变量,指定可以无限发送的用户角色 - 配置 `NO_LIMIT_SEND_ROLE` 环境变量,指定可以无限发送的用户角色
> [!NOTE] > [!NOTE]
> `DEFAULT_SEND_BALANCE` **不会**自动给所有地址充值余额,用户必须先主动申请发信权限,额度才会生效 > `DEFAULT_SEND_BALANCE` 仅在地址尚无 `address_sender` 记录时自动插入初始额度(`ON CONFLICT DO NOTHING`),已有记录(包括管理员禁用或手动设置的行)一律保持原样,runtime 不会修改;历史异常或被禁用的地址需由管理员在后台手动启用并设置余额
>
> 第 1 层 `verifiedAddressList` 命中时不扣余额,但同样计入发信额度;第 2/3/4 层统一扣 balance。
>
> 发信额度对**全部**发信渠道生效,admin 发信接口也会一起计入。
>
> 每日和每月额度按 **UTC** 时间窗口计算。
>
> 当前额度实现属于 **soft guard**,适合日常额度控制;在数据库异常或高并发场景下,它不适合作为绝对严格的成本硬闸。
## 给 Cloudflare 上已认证的转发邮箱发送邮件 ## 给 Cloudflare 上已认证的转发邮箱发送邮件
仅支持 CLI 部署时使用,在 `wrangler.toml` 中添加 `send_email` 配置 适合未完成 Email Routing onboarding 的域名,或 Workers 免费版。
发送的目的邮箱地址必须是 Cloudflare 上已认证的邮箱地址,局限性较大,如果需要发送邮件给其他邮箱,可以使用 `resend` 或者 `smtp` 发送邮件 只有收件人在 admin 后台的 `已验证地址列表` 中时,才会通过 `SEND_MAIL` binding 发信。
```toml
# 通过 Cloudflare 发送邮件
send_email = [
{ name = "SEND_MAIL" },
]
```
admin 后台 账号配置 `已验证地址列表(可通过 cf 内部 api 发送邮件)`
+5 -1
View File
@@ -37,8 +37,9 @@
| `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` | | `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` |
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` | | `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/` | `true` | | `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` | | `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
| `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
> [!NOTE] > [!NOTE]
> `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare > `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare
@@ -54,6 +55,9 @@
> >
> 管理后台提供三种显式状态:**跟随环境变量**、**强制开启**、**强制关闭**。当你选择 > 管理后台提供三种显式状态:**跟随环境变量**、**强制开启**、**强制关闭**。当你选择
> “跟随环境变量”并保存时,会清空后台覆盖,恢复到“未设置”的回退行为。 > “跟随环境变量”并保存时,会清空后台覆盖,恢复到“未设置”的回退行为。
>
> `SEND_MAIL_DOMAINS` 只影响 `SEND_MAIL` binding 的兜底发信路径和 `/admin/send_mail_by_binding`
> 它不影响 Resend、SMTP、`verifiedAddressList` 等其他发信通道。
## 接受邮件相关变量 ## 接受邮件相关变量
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "temp-mail-docs", "name": "temp-mail-docs",
"private": true, "private": true,
"version": "1.6.0", "version": "1.8.0",
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"vitepress": "^1.6.4", "vitepress": "^1.6.4",
"wrangler": "^4.81.1" "wrangler": "^4.83.0"
}, },
"scripts": { "scripts": {
"dev": "vitepress dev docs", "dev": "vitepress dev docs",
+283 -283
View File
@@ -17,15 +17,15 @@ importers:
version: 25.6.0 version: 25.6.0
vitepress: vitepress:
specifier: ^1.6.4 specifier: ^1.6.4
version: 1.6.4(@algolia/client-search@5.50.1)(@types/node@25.6.0)(postcss@8.5.9)(search-insights@2.13.0)(typescript@5.4.5) version: 1.6.4(@algolia/client-search@5.50.2)(@types/node@25.6.0)(postcss@8.5.10)(search-insights@2.13.0)(typescript@5.4.5)
wrangler: wrangler:
specifier: ^4.81.1 specifier: ^4.83.0
version: 4.81.1 version: 4.83.0
packages: packages:
'@algolia/abtesting@1.16.1': '@algolia/abtesting@1.16.2':
resolution: {integrity: sha512-Xxk4l00pYI+jE0PNw8y0MvsQWh5278WRtZQav8/BMMi3HKi2xmeuqe11WJ3y8/6nuBHdv39w76OpJb09TMfAVQ==} resolution: {integrity: sha512-n9s6bEV6imdtIEd+BGP7WkA4pEZ5YTdgQ05JQhHwWawHg3hyjpNwC0TShGz6zWhv+jfLDGA/6FFNbySFS0P9cw==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/autocomplete-core@1.17.7': '@algolia/autocomplete-core@1.17.7':
@@ -48,56 +48,56 @@ packages:
'@algolia/client-search': '>= 4.9.1 < 6' '@algolia/client-search': '>= 4.9.1 < 6'
algoliasearch: '>= 4.9.1 < 6' algoliasearch: '>= 4.9.1 < 6'
'@algolia/client-abtesting@5.50.1': '@algolia/client-abtesting@5.50.2':
resolution: {integrity: sha512-4peZlPXMwTOey9q1rQKMdCnwZb/E95/1e+7KujXpLLSh0FawJzg//U2NM+r4AiJy4+naT2MTBhj0K30yshnVTA==} resolution: {integrity: sha512-52iq0vHy1sphgnwoZyx5PmbEt8hsh+m7jD123LmBs6qy4GK7LbYZIeKd+nSnSipN2zvKRZ2zScS6h9PW3J7SXg==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/client-analytics@5.50.1': '@algolia/client-analytics@5.50.2':
resolution: {integrity: sha512-i+aWHHG8NZvGFHtPeMZkxL2Loc6Fm7iaRo15lYSMx8gFL+at9vgdWxhka7mD1fqxkrxXsQstUBCIsSY8FvkEOw==} resolution: {integrity: sha512-WpPIUg+cSG2aPUG0gS8Ko9DwRgbRPUZxJkolhL2aCsmSlcEEZT65dILrfg5ovcxtx0Kvr+xtBVsTMtsQWRtPDQ==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/client-common@5.50.1': '@algolia/client-common@5.50.2':
resolution: {integrity: sha512-Hw52Fwapyk/7hMSV/fI4+s3H9MGZEUcRh4VphyXLAk2oLYdndVUkc6KBi0zwHSzwPAr+ZBwFPe2x6naUt9mZGw==} resolution: {integrity: sha512-Gj2MgtArGcsr82kIqRlo6/dCAFjrs2gLByEqyRENuT7ugrSMFuqg1vDzeBjRL1t3EJEJCFtT0PLX3gB8A6Hq4Q==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/client-insights@5.50.1': '@algolia/client-insights@5.50.2':
resolution: {integrity: sha512-Bn/wtwhJ7p1OD/6pY+Zzn+zlu2N/SJnH46md/PAbvqIzmjVuwjNwD4y0vV5Ov8naeukXdd7UU9v550+v8+mtlg==} resolution: {integrity: sha512-CUqoid5jDpmrc0oK3/xuZXFt6kwT0P9Lw7/nsM14YTr6puvmi+OUKmURpmebQF22S2vCG8L1DAoXXujxQUi/ug==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/client-personalization@5.50.1': '@algolia/client-personalization@5.50.2':
resolution: {integrity: sha512-0V4Tu0RWR8YxkgI9EPVOZHGE4K5pEIhkLNN0CTkP/rnPsqaaSQpNMYW3/mGWdiKOWbX0iVmwLB9QESk3H0jS5g==} resolution: {integrity: sha512-AndZWFoc0gbP5901OeQJ73BazgGgSGiBEba4ohdoJuZwHTO2Gio8Q4L1VLmytMBYcviVigB0iICToMvEJxI4ug==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/client-query-suggestions@5.50.1': '@algolia/client-query-suggestions@5.50.2':
resolution: {integrity: sha512-jofcWNYMXJDDr87Z2eivlWY6o71Zn7F7aOvQCXSDAo9QTlyf7BhXEsZymLUvF0O1yU9Q9wvrjAWn8uVHYnAvgw==} resolution: {integrity: sha512-NWoL+psEkz5dIzweaByVXuEB45wS8/rk0E0AhMMnaVJdVs7TcACPH2/OURm+N0xRDITkTHqCna823rd6Uqntdg==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/client-search@5.50.1': '@algolia/client-search@5.50.2':
resolution: {integrity: sha512-OteRb8WubcmEvU0YlMJwCXs3Q6xrdkb0v50/qZBJP1TF0CvujFZQM++9BjEkTER/Jr9wbPHvjSFKnbMta0b4dQ==} resolution: {integrity: sha512-ypSboUJ3XJoQz5DeDo82hCnrRuwq3q9ZdFhVKAik9TnZh1DvLqoQsrbBjXg7C7zQOtV/Qbge/HmyoV6V5L7MhQ==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/ingestion@1.50.1': '@algolia/ingestion@1.50.2':
resolution: {integrity: sha512-0GmfSgDQK6oiIVXnJvGxtNFOfosBspRTR7csCOYCTL1P8QtxX2vDCIKwTM7xdSAEbJaZ43QlWg25q0Qdsndz8Q==} resolution: {integrity: sha512-VlR2FRXLw2bCB94SQo6zxg/Qi+547aOji6Pb+dKE7h1DMCCY317St+OpjpmgzE+bT2O9ALIc0V4nVIBOd7Gy+Q==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/monitoring@1.50.1': '@algolia/monitoring@1.50.2':
resolution: {integrity: sha512-ySuigKEe4YjYV3si8NVk9BHQpFj/1B+ON7DhhvTvbrZJseHQQloxzq0yHwKmznSdlO6C956fx4pcfOKkZClsyg==} resolution: {integrity: sha512-Cmvfp2+qopzQt8OilU97rhLhosq7ZrB6uieok3EwFUqG/aalPg6DgfCmu0yJMrYe+KMC1qRVt1MTRAUwLknUMQ==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/recommend@5.50.1': '@algolia/recommend@5.50.2':
resolution: {integrity: sha512-Cp8T/B0gVmjFlzzp6eP47hwKh5FGyeqQp1N48/ANDdvdiQkPqLyFHQVDwLBH0LddfIPQE+yqmZIgmKc82haF4A==} resolution: {integrity: sha512-jrkuyKoOM7dFWQ/6Y4hQAse2SC3L/RldG6GnPjMvAj65h+7Ubb51S0pKk4ofSStF0xm4LCNe0C4T6XX4nOFDiQ==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/requester-browser-xhr@5.50.1': '@algolia/requester-browser-xhr@5.50.2':
resolution: {integrity: sha512-XKdGGLikfrlK66ZSXh/vWcXZZ8Vg3byDFbJD8pwEvN1FoBRGxhxya476IY2ohoTymLa4qB5LBRlIa+2TLHx3Uw==} resolution: {integrity: sha512-4107YLJqCudPiBUlwnk6oTSUVwU7ab+qL1SfQGEDYI8DZH5gsf1ekPt9JykXRKYXf2IfouFL5GiCY/PHTFIjYw==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/requester-fetch@5.50.1': '@algolia/requester-fetch@5.50.2':
resolution: {integrity: sha512-mBAU6WyVsDwhHyGM+nodt1/oebHxgvuLlOAoMGbj/1i6LygDHZWDgL1t5JEs37x9Aywv7ZGhqbM1GsfZ54sU6g==} resolution: {integrity: sha512-vOrd3MQpLgmf6wXAueTuZ/cA0W4uRwIHHaxNy3h+a6YcNn6bCV/gFdZuv3F13v593zRU2k5R75NmvRWLenvMrw==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@algolia/requester-node-http@5.50.1': '@algolia/requester-node-http@5.50.2':
resolution: {integrity: sha512-qmo1LXrNKLHvJE6mdQbLnsZAoZvj7VyF2ft4xmbSGWI2WWm87fx/CjUX4kEExt4y0a6T6nEts6ofpUfH5TEE1A==} resolution: {integrity: sha512-Mu9BFtgzGqDUy5Bcs2nMyoILIFSN13GKQaklKAFIsd0K3/9CpNyfeBc+/+Qs6mFZLlxG9qzullO7h+bjcTBuGQ==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
'@babel/helper-string-parser@7.27.1': '@babel/helper-string-parser@7.27.1':
@@ -130,32 +130,32 @@ packages:
workerd: workerd:
optional: true optional: true
'@cloudflare/workerd-darwin-64@1.20260409.1': '@cloudflare/workerd-darwin-64@1.20260415.1':
resolution: {integrity: sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w==} resolution: {integrity: sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260409.1': '@cloudflare/workerd-darwin-arm64@1.20260415.1':
resolution: {integrity: sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ==} resolution: {integrity: sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@cloudflare/workerd-linux-64@1.20260409.1': '@cloudflare/workerd-linux-64@1.20260415.1':
resolution: {integrity: sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg==} resolution: {integrity: sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260409.1': '@cloudflare/workerd-linux-arm64@1.20260415.1':
resolution: {integrity: sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg==} resolution: {integrity: sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@cloudflare/workerd-windows-64@1.20260409.1': '@cloudflare/workerd-windows-64@1.20260415.1':
resolution: {integrity: sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q==} resolution: {integrity: sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -187,8 +187,8 @@ packages:
search-insights: search-insights:
optional: true optional: true
'@emnapi/runtime@1.9.2': '@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -484,8 +484,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@iconify-json/simple-icons@1.2.77': '@iconify-json/simple-icons@1.2.79':
resolution: {integrity: sha512-oaENvo6C3BkAEWMlcQA3XemxU9v2SFOTlApSUCODAkIu1haeLCjzrmH3HgmGqjRnJjM+LevO8sA+MgdMHBFBDA==} resolution: {integrity: sha512-aNyO7Fd1qej9oQfIyohYFRv0lhQLaZ+6UkK1c1qwax0MDPUOZOdq65MlU500kow97pD/W+b2u1And3e25eE24Q==}
'@iconify/types@2.0.0': '@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -646,128 +646,128 @@ packages:
'@poppinss/exception@1.2.3': '@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@rollup/rollup-android-arm-eabi@4.60.1': '@rollup/rollup-android-arm-eabi@4.60.2':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@rollup/rollup-android-arm64@4.60.1': '@rollup/rollup-android-arm64@4.60.2':
resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@rollup/rollup-darwin-arm64@4.60.1': '@rollup/rollup-darwin-arm64@4.60.2':
resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rollup/rollup-darwin-x64@4.60.1': '@rollup/rollup-darwin-x64@4.60.2':
resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.1': '@rollup/rollup-freebsd-arm64@4.60.2':
resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==}
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.1': '@rollup/rollup-freebsd-x64@4.60.2':
resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.1': '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.60.1': '@rollup/rollup-linux-arm-musleabihf@4.60.2':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.60.1': '@rollup/rollup-linux-arm64-gnu@4.60.2':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm64-musl@4.60.1': '@rollup/rollup-linux-arm64-musl@4.60.2':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rollup/rollup-linux-loong64-gnu@4.60.1': '@rollup/rollup-linux-loong64-gnu@4.60.2':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@rollup/rollup-linux-loong64-musl@4.60.1': '@rollup/rollup-linux-loong64-musl@4.60.2':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.60.1': '@rollup/rollup-linux-ppc64-gnu@4.60.2':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@rollup/rollup-linux-ppc64-musl@4.60.1': '@rollup/rollup-linux-ppc64-musl@4.60.2':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.60.1': '@rollup/rollup-linux-riscv64-gnu@4.60.2':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.60.1': '@rollup/rollup-linux-riscv64-musl@4.60.2':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.60.1': '@rollup/rollup-linux-s390x-gnu@4.60.2':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@rollup/rollup-linux-x64-gnu@4.60.1': '@rollup/rollup-linux-x64-gnu@4.60.2':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rollup/rollup-linux-x64-musl@4.60.1': '@rollup/rollup-linux-x64-musl@4.60.2':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rollup/rollup-openbsd-x64@4.60.1': '@rollup/rollup-openbsd-x64@4.60.2':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
cpu: [x64] cpu: [x64]
os: [openbsd] os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.1': '@rollup/rollup-openharmony-arm64@4.60.2':
resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==}
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.1': '@rollup/rollup-win32-arm64-msvc@4.60.2':
resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.1': '@rollup/rollup-win32-ia32-msvc@4.60.2':
resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.1': '@rollup/rollup-win32-x64-gnu@4.60.2':
resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.1': '@rollup/rollup-win32-x64-msvc@4.60.2':
resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -927,8 +927,8 @@ packages:
'@vueuse/shared@12.8.2': '@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
algoliasearch@5.50.1: algoliasearch@5.50.2:
resolution: {integrity: sha512-/bwdue1/8LWELn/DBalGRfuLsXBLXULJo/yOeavJtDu8rBwxIzC6/Rz9Jg19S21VkJvRuZO1k8CZXBMS73mYbA==} resolution: {integrity: sha512-Tfp26yoNWurUjfgK4GOrVJQhSNXu9tJtHfFFNosgT2YClG+vPyUjX/gbC8rG39qLncnZg8Fj34iarQWpMkqefw==}
engines: {node: '>= 14.0.0'} engines: {node: '>= 14.0.0'}
birpc@2.9.0: birpc@2.9.0:
@@ -1064,8 +1064,8 @@ packages:
micromark-util-types@2.0.2: micromark-util-types@2.0.2:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
miniflare@4.20260409.0: miniflare@4.20260415.0:
resolution: {integrity: sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA==} resolution: {integrity: sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
@@ -1098,8 +1098,8 @@ packages:
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.5.9: postcss@8.5.10:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
preact@10.29.1: preact@10.29.1:
@@ -1126,8 +1126,8 @@ packages:
rfdc@1.4.1: rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup@4.60.1: rollup@4.60.2:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
@@ -1194,8 +1194,8 @@ packages:
undici-types@7.19.2: undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
undici@7.24.4: undici@7.24.8:
resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24: unenv@2.0.0-rc.24:
@@ -1276,17 +1276,17 @@ packages:
typescript: typescript:
optional: true optional: true
workerd@1.20260409.1: workerd@1.20260415.1:
resolution: {integrity: sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w==} resolution: {integrity: sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
wrangler@4.81.1: wrangler@4.83.0:
resolution: {integrity: sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w==} resolution: {integrity: sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A==}
engines: {node: '>=20.3.0'} engines: {node: '>=20.3.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@cloudflare/workers-types': ^4.20260409.1 '@cloudflare/workers-types': ^4.20260415.1
peerDependenciesMeta: peerDependenciesMeta:
'@cloudflare/workers-types': '@cloudflare/workers-types':
optional: true optional: true
@@ -1314,117 +1314,117 @@ packages:
snapshots: snapshots:
'@algolia/abtesting@1.16.1': '@algolia/abtesting@1.16.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1)(search-insights@2.13.0)': '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)(search-insights@2.13.0)':
dependencies: dependencies:
'@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1)(search-insights@2.13.0) '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)(search-insights@2.13.0)
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1) '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)
transitivePeerDependencies: transitivePeerDependencies:
- '@algolia/client-search' - '@algolia/client-search'
- algoliasearch - algoliasearch
- search-insights - search-insights
'@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1)(search-insights@2.13.0)': '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)(search-insights@2.13.0)':
dependencies: dependencies:
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1) '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)
search-insights: 2.13.0 search-insights: 2.13.0
transitivePeerDependencies: transitivePeerDependencies:
- '@algolia/client-search' - '@algolia/client-search'
- algoliasearch - algoliasearch
'@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1)': '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)':
dependencies: dependencies:
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1) '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)
'@algolia/client-search': 5.50.1 '@algolia/client-search': 5.50.2
algoliasearch: 5.50.1 algoliasearch: 5.50.2
'@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1)': '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)':
dependencies: dependencies:
'@algolia/client-search': 5.50.1 '@algolia/client-search': 5.50.2
algoliasearch: 5.50.1 algoliasearch: 5.50.2
'@algolia/client-abtesting@5.50.1': '@algolia/client-abtesting@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/client-analytics@5.50.1': '@algolia/client-analytics@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/client-common@5.50.1': {} '@algolia/client-common@5.50.2': {}
'@algolia/client-insights@5.50.1': '@algolia/client-insights@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/client-personalization@5.50.1': '@algolia/client-personalization@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/client-query-suggestions@5.50.1': '@algolia/client-query-suggestions@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/client-search@5.50.1': '@algolia/client-search@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/ingestion@1.50.1': '@algolia/ingestion@1.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/monitoring@1.50.1': '@algolia/monitoring@1.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/recommend@5.50.1': '@algolia/recommend@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
'@algolia/requester-browser-xhr@5.50.1': '@algolia/requester-browser-xhr@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-fetch@5.50.1': '@algolia/requester-fetch@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/requester-node-http@5.50.1': '@algolia/requester-node-http@5.50.2':
dependencies: dependencies:
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.27.1': {}
@@ -1441,25 +1441,25 @@ snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260409.1)': '@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)':
dependencies: dependencies:
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
optionalDependencies: optionalDependencies:
workerd: 1.20260409.1 workerd: 1.20260415.1
'@cloudflare/workerd-darwin-64@1.20260409.1': '@cloudflare/workerd-darwin-64@1.20260415.1':
optional: true optional: true
'@cloudflare/workerd-darwin-arm64@1.20260409.1': '@cloudflare/workerd-darwin-arm64@1.20260415.1':
optional: true optional: true
'@cloudflare/workerd-linux-64@1.20260409.1': '@cloudflare/workerd-linux-64@1.20260415.1':
optional: true optional: true
'@cloudflare/workerd-linux-arm64@1.20260409.1': '@cloudflare/workerd-linux-arm64@1.20260415.1':
optional: true optional: true
'@cloudflare/workerd-windows-64@1.20260409.1': '@cloudflare/workerd-windows-64@1.20260415.1':
optional: true optional: true
'@cspotcode/source-map-support@0.8.1': '@cspotcode/source-map-support@0.8.1':
@@ -1468,9 +1468,9 @@ snapshots:
'@docsearch/css@3.8.2': {} '@docsearch/css@3.8.2': {}
'@docsearch/js@3.8.2(@algolia/client-search@5.50.1)(search-insights@2.13.0)': '@docsearch/js@3.8.2(@algolia/client-search@5.50.2)(search-insights@2.13.0)':
dependencies: dependencies:
'@docsearch/react': 3.8.2(@algolia/client-search@5.50.1)(search-insights@2.13.0) '@docsearch/react': 3.8.2(@algolia/client-search@5.50.2)(search-insights@2.13.0)
preact: 10.29.1 preact: 10.29.1
transitivePeerDependencies: transitivePeerDependencies:
- '@algolia/client-search' - '@algolia/client-search'
@@ -1479,18 +1479,18 @@ snapshots:
- react-dom - react-dom
- search-insights - search-insights
'@docsearch/react@3.8.2(@algolia/client-search@5.50.1)(search-insights@2.13.0)': '@docsearch/react@3.8.2(@algolia/client-search@5.50.2)(search-insights@2.13.0)':
dependencies: dependencies:
'@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1)(search-insights@2.13.0) '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)(search-insights@2.13.0)
'@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.50.1)(algoliasearch@5.50.1) '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.50.2)(algoliasearch@5.50.2)
'@docsearch/css': 3.8.2 '@docsearch/css': 3.8.2
algoliasearch: 5.50.1 algoliasearch: 5.50.2
optionalDependencies: optionalDependencies:
search-insights: 2.13.0 search-insights: 2.13.0
transitivePeerDependencies: transitivePeerDependencies:
- '@algolia/client-search' - '@algolia/client-search'
'@emnapi/runtime@1.9.2': '@emnapi/runtime@1.10.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
@@ -1642,7 +1642,7 @@ snapshots:
'@esbuild/win32-x64@0.27.3': '@esbuild/win32-x64@0.27.3':
optional: true optional: true
'@iconify-json/simple-icons@1.2.77': '@iconify-json/simple-icons@1.2.79':
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
@@ -1732,7 +1732,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
dependencies: dependencies:
'@emnapi/runtime': 1.9.2 '@emnapi/runtime': 1.10.0
optional: true optional: true
'@img/sharp-win32-arm64@0.34.5': '@img/sharp-win32-arm64@0.34.5':
@@ -1765,79 +1765,79 @@ snapshots:
'@poppinss/exception@1.2.3': {} '@poppinss/exception@1.2.3': {}
'@rollup/rollup-android-arm-eabi@4.60.1': '@rollup/rollup-android-arm-eabi@4.60.2':
optional: true optional: true
'@rollup/rollup-android-arm64@4.60.1': '@rollup/rollup-android-arm64@4.60.2':
optional: true optional: true
'@rollup/rollup-darwin-arm64@4.60.1': '@rollup/rollup-darwin-arm64@4.60.2':
optional: true optional: true
'@rollup/rollup-darwin-x64@4.60.1': '@rollup/rollup-darwin-x64@4.60.2':
optional: true optional: true
'@rollup/rollup-freebsd-arm64@4.60.1': '@rollup/rollup-freebsd-arm64@4.60.2':
optional: true optional: true
'@rollup/rollup-freebsd-x64@4.60.1': '@rollup/rollup-freebsd-x64@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.1': '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.1': '@rollup/rollup-linux-arm-musleabihf@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.1': '@rollup/rollup-linux-arm64-gnu@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-arm64-musl@4.60.1': '@rollup/rollup-linux-arm64-musl@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.1': '@rollup/rollup-linux-loong64-gnu@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-loong64-musl@4.60.1': '@rollup/rollup-linux-loong64-musl@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.1': '@rollup/rollup-linux-ppc64-gnu@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.1': '@rollup/rollup-linux-ppc64-musl@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.1': '@rollup/rollup-linux-riscv64-gnu@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.1': '@rollup/rollup-linux-riscv64-musl@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.1': '@rollup/rollup-linux-s390x-gnu@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-x64-gnu@4.60.1': '@rollup/rollup-linux-x64-gnu@4.60.2':
optional: true optional: true
'@rollup/rollup-linux-x64-musl@4.60.1': '@rollup/rollup-linux-x64-musl@4.60.2':
optional: true optional: true
'@rollup/rollup-openbsd-x64@4.60.1': '@rollup/rollup-openbsd-x64@4.60.2':
optional: true optional: true
'@rollup/rollup-openharmony-arm64@4.60.1': '@rollup/rollup-openharmony-arm64@4.60.2':
optional: true optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.1': '@rollup/rollup-win32-arm64-msvc@4.60.2':
optional: true optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.1': '@rollup/rollup-win32-ia32-msvc@4.60.2':
optional: true optional: true
'@rollup/rollup-win32-x64-gnu@4.60.1': '@rollup/rollup-win32-x64-gnu@4.60.2':
optional: true optional: true
'@rollup/rollup-win32-x64-msvc@4.60.1': '@rollup/rollup-win32-x64-msvc@4.60.2':
optional: true optional: true
'@shikijs/core@2.5.0': '@shikijs/core@2.5.0':
@@ -1940,7 +1940,7 @@ snapshots:
'@vue/shared': 3.5.32 '@vue/shared': 3.5.32
estree-walker: 2.0.2 estree-walker: 2.0.2
magic-string: 0.30.21 magic-string: 0.30.21
postcss: 8.5.9 postcss: 8.5.10
source-map-js: 1.2.1 source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.32': '@vue/compiler-ssr@3.5.32':
@@ -2017,22 +2017,22 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
algoliasearch@5.50.1: algoliasearch@5.50.2:
dependencies: dependencies:
'@algolia/abtesting': 1.16.1 '@algolia/abtesting': 1.16.2
'@algolia/client-abtesting': 5.50.1 '@algolia/client-abtesting': 5.50.2
'@algolia/client-analytics': 5.50.1 '@algolia/client-analytics': 5.50.2
'@algolia/client-common': 5.50.1 '@algolia/client-common': 5.50.2
'@algolia/client-insights': 5.50.1 '@algolia/client-insights': 5.50.2
'@algolia/client-personalization': 5.50.1 '@algolia/client-personalization': 5.50.2
'@algolia/client-query-suggestions': 5.50.1 '@algolia/client-query-suggestions': 5.50.2
'@algolia/client-search': 5.50.1 '@algolia/client-search': 5.50.2
'@algolia/ingestion': 1.50.1 '@algolia/ingestion': 1.50.2
'@algolia/monitoring': 1.50.1 '@algolia/monitoring': 1.50.2
'@algolia/recommend': 5.50.1 '@algolia/recommend': 5.50.2
'@algolia/requester-browser-xhr': 5.50.1 '@algolia/requester-browser-xhr': 5.50.2
'@algolia/requester-fetch': 5.50.1 '@algolia/requester-fetch': 5.50.2
'@algolia/requester-node-http': 5.50.1 '@algolia/requester-node-http': 5.50.2
birpc@2.9.0: {} birpc@2.9.0: {}
@@ -2212,12 +2212,12 @@ snapshots:
micromark-util-types@2.0.2: {} micromark-util-types@2.0.2: {}
miniflare@4.20260409.0: miniflare@4.20260415.0:
dependencies: dependencies:
'@cspotcode/source-map-support': 0.8.1 '@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5 sharp: 0.34.5
undici: 7.24.4 undici: 7.24.8
workerd: 1.20260409.1 workerd: 1.20260415.1
ws: 8.18.0 ws: 8.18.0
youch: 4.1.0-beta.10 youch: 4.1.0-beta.10
transitivePeerDependencies: transitivePeerDependencies:
@@ -2246,7 +2246,7 @@ snapshots:
picocolors@1.1.1: {} picocolors@1.1.1: {}
postcss@8.5.9: postcss@8.5.10:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
picocolors: 1.1.1 picocolors: 1.1.1
@@ -2280,35 +2280,35 @@ snapshots:
rfdc@1.4.1: {} rfdc@1.4.1: {}
rollup@4.60.1: rollup@4.60.2:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
optionalDependencies: optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.1 '@rollup/rollup-android-arm-eabi': 4.60.2
'@rollup/rollup-android-arm64': 4.60.1 '@rollup/rollup-android-arm64': 4.60.2
'@rollup/rollup-darwin-arm64': 4.60.1 '@rollup/rollup-darwin-arm64': 4.60.2
'@rollup/rollup-darwin-x64': 4.60.1 '@rollup/rollup-darwin-x64': 4.60.2
'@rollup/rollup-freebsd-arm64': 4.60.1 '@rollup/rollup-freebsd-arm64': 4.60.2
'@rollup/rollup-freebsd-x64': 4.60.1 '@rollup/rollup-freebsd-x64': 4.60.2
'@rollup/rollup-linux-arm-gnueabihf': 4.60.1 '@rollup/rollup-linux-arm-gnueabihf': 4.60.2
'@rollup/rollup-linux-arm-musleabihf': 4.60.1 '@rollup/rollup-linux-arm-musleabihf': 4.60.2
'@rollup/rollup-linux-arm64-gnu': 4.60.1 '@rollup/rollup-linux-arm64-gnu': 4.60.2
'@rollup/rollup-linux-arm64-musl': 4.60.1 '@rollup/rollup-linux-arm64-musl': 4.60.2
'@rollup/rollup-linux-loong64-gnu': 4.60.1 '@rollup/rollup-linux-loong64-gnu': 4.60.2
'@rollup/rollup-linux-loong64-musl': 4.60.1 '@rollup/rollup-linux-loong64-musl': 4.60.2
'@rollup/rollup-linux-ppc64-gnu': 4.60.1 '@rollup/rollup-linux-ppc64-gnu': 4.60.2
'@rollup/rollup-linux-ppc64-musl': 4.60.1 '@rollup/rollup-linux-ppc64-musl': 4.60.2
'@rollup/rollup-linux-riscv64-gnu': 4.60.1 '@rollup/rollup-linux-riscv64-gnu': 4.60.2
'@rollup/rollup-linux-riscv64-musl': 4.60.1 '@rollup/rollup-linux-riscv64-musl': 4.60.2
'@rollup/rollup-linux-s390x-gnu': 4.60.1 '@rollup/rollup-linux-s390x-gnu': 4.60.2
'@rollup/rollup-linux-x64-gnu': 4.60.1 '@rollup/rollup-linux-x64-gnu': 4.60.2
'@rollup/rollup-linux-x64-musl': 4.60.1 '@rollup/rollup-linux-x64-musl': 4.60.2
'@rollup/rollup-openbsd-x64': 4.60.1 '@rollup/rollup-openbsd-x64': 4.60.2
'@rollup/rollup-openharmony-arm64': 4.60.1 '@rollup/rollup-openharmony-arm64': 4.60.2
'@rollup/rollup-win32-arm64-msvc': 4.60.1 '@rollup/rollup-win32-arm64-msvc': 4.60.2
'@rollup/rollup-win32-ia32-msvc': 4.60.1 '@rollup/rollup-win32-ia32-msvc': 4.60.2
'@rollup/rollup-win32-x64-gnu': 4.60.1 '@rollup/rollup-win32-x64-gnu': 4.60.2
'@rollup/rollup-win32-x64-msvc': 4.60.1 '@rollup/rollup-win32-x64-msvc': 4.60.2
fsevents: 2.3.3 fsevents: 2.3.3
safe-buffer@5.1.2: {} safe-buffer@5.1.2: {}
@@ -2394,7 +2394,7 @@ snapshots:
undici-types@7.19.2: {} undici-types@7.19.2: {}
undici@7.24.4: {} undici@7.24.8: {}
unenv@2.0.0-rc.24: unenv@2.0.0-rc.24:
dependencies: dependencies:
@@ -2438,17 +2438,17 @@ snapshots:
vite@5.4.21(@types/node@25.6.0): vite@5.4.21(@types/node@25.6.0):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.5.9 postcss: 8.5.10
rollup: 4.60.1 rollup: 4.60.2
optionalDependencies: optionalDependencies:
'@types/node': 25.6.0 '@types/node': 25.6.0
fsevents: 2.3.3 fsevents: 2.3.3
vitepress@1.6.4(@algolia/client-search@5.50.1)(@types/node@25.6.0)(postcss@8.5.9)(search-insights@2.13.0)(typescript@5.4.5): vitepress@1.6.4(@algolia/client-search@5.50.2)(@types/node@25.6.0)(postcss@8.5.10)(search-insights@2.13.0)(typescript@5.4.5):
dependencies: dependencies:
'@docsearch/css': 3.8.2 '@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.50.1)(search-insights@2.13.0) '@docsearch/js': 3.8.2(@algolia/client-search@5.50.2)(search-insights@2.13.0)
'@iconify-json/simple-icons': 1.2.77 '@iconify-json/simple-icons': 1.2.79
'@shikijs/core': 2.5.0 '@shikijs/core': 2.5.0
'@shikijs/transformers': 2.5.0 '@shikijs/transformers': 2.5.0
'@shikijs/types': 2.5.0 '@shikijs/types': 2.5.0
@@ -2465,7 +2465,7 @@ snapshots:
vite: 5.4.21(@types/node@25.6.0) vite: 5.4.21(@types/node@25.6.0)
vue: 3.5.32(typescript@5.4.5) vue: 3.5.32(typescript@5.4.5)
optionalDependencies: optionalDependencies:
postcss: 8.5.9 postcss: 8.5.10
transitivePeerDependencies: transitivePeerDependencies:
- '@algolia/client-search' - '@algolia/client-search'
- '@types/node' - '@types/node'
@@ -2503,24 +2503,24 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.4.5 typescript: 5.4.5
workerd@1.20260409.1: workerd@1.20260415.1:
optionalDependencies: optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260409.1 '@cloudflare/workerd-darwin-64': 1.20260415.1
'@cloudflare/workerd-darwin-arm64': 1.20260409.1 '@cloudflare/workerd-darwin-arm64': 1.20260415.1
'@cloudflare/workerd-linux-64': 1.20260409.1 '@cloudflare/workerd-linux-64': 1.20260415.1
'@cloudflare/workerd-linux-arm64': 1.20260409.1 '@cloudflare/workerd-linux-arm64': 1.20260415.1
'@cloudflare/workerd-windows-64': 1.20260409.1 '@cloudflare/workerd-windows-64': 1.20260415.1
wrangler@4.81.1: wrangler@4.83.0:
dependencies: dependencies:
'@cloudflare/kv-asset-handler': 0.4.2 '@cloudflare/kv-asset-handler': 0.4.2
'@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260409.1) '@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)
blake3-wasm: 2.1.5 blake3-wasm: 2.1.5
esbuild: 0.27.3 esbuild: 0.27.3
miniflare: 4.20260409.0 miniflare: 4.20260415.0
path-to-regexp: 6.3.0 path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
workerd: 1.20260409.1 workerd: 1.20260415.1
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
transitivePeerDependencies: transitivePeerDependencies:
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.6.0", "version": "1.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,23 +11,23 @@
"build": "wrangler deploy --dry-run --outdir dist --minify" "build": "wrangler deploy --dry-run --outdir dist --minify"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260411.1", "@cloudflare/workers-types": "^4.20260420.1",
"@eslint/js": "9.39.1", "@eslint/js": "9.39.1",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"eslint": "9.39.1", "eslint": "9.39.1",
"globals": "^16.5.0", "globals": "^16.5.0",
"typescript-eslint": "^8.58.1", "typescript-eslint": "^8.58.2",
"wrangler": "^4.81.1" "wrangler": "^4.83.0"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.888.0", "@aws-sdk/client-s3": "3.888.0",
"@aws-sdk/s3-request-presigner": "3.888.0", "@aws-sdk/s3-request-presigner": "3.888.0",
"@simplewebauthn/server": "13.2.3", "@simplewebauthn/server": "13.2.3",
"hono": "^4.12.12", "hono": "^4.12.14",
"jsonpath-plus": "^10.4.0", "jsonpath-plus": "^10.4.0",
"mimetext": "^3.0.28", "mimetext": "^3.0.28",
"postal-mime": "^2.7.4", "postal-mime": "^2.7.4",
"resend": "^6.10.0", "resend": "^6.12.0",
"telegraf": "4.16.3", "telegraf": "4.16.3",
"worker-mailer": "^1.2.1" "worker-mailer": "^1.2.1"
}, },
+516 -507
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
import { Context } from 'hono'
import i18n from '../i18n'
import { getJsonSetting, saveSetting } from '../utils'
import { getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
import { CONSTANTS } from '../constants'
import {
getSendMailLimitConfig,
getSendMailLimitConfigToSave,
validateSendMailLimitConfig
} from '../mails_api/send_mail_limit_utils'
import { EmailRuleSettings } from '../models'
const normalizeAddressCreationSettingsUpdate = (
value: unknown
): {
shouldUpdate: boolean,
shouldClear: boolean,
nextEnableSubdomainMatch?: boolean,
} | null => {
if (typeof value === 'undefined') {
return { shouldUpdate: false, shouldClear: false };
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
if (typeof nextEnableSubdomainMatch === 'undefined') {
return { shouldUpdate: false, shouldClear: false };
}
// null 代表"清空后台覆盖,恢复为未设置并回退到 env",这是给前端三态显式使用的正式路径。
if (nextEnableSubdomainMatch === null) {
return { shouldUpdate: true, shouldClear: true };
}
if (typeof nextEnableSubdomainMatch !== 'boolean') {
return null;
}
return {
shouldUpdate: true,
shouldClear: false,
nextEnableSubdomainMatch,
};
};
const get = async (c: Context<HonoCustomType>) => {
try {
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
const addressCreationSettings = await getAddressCreationSettings(c);
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
const sendMailLimitConfig = await getSendMailLimitConfig(c);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || [],
noLimitSendAddressList: noLimitSendAddressList || [],
emailRuleSettings: emailRuleSettings || {},
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
: {},
addressCreationSubdomainMatchStatus,
sendMailLimitConfig,
})
} catch (error) {
console.error(error);
return c.json({})
}
};
const save = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings,
sendMailLimitConfig
} = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text(msgs.InvalidInputMsg, 400)
}
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
if (!addressCreationSettingsUpdate) {
return c.text(msgs.InvalidInputMsg, 400)
}
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
return c.text(msgs.EnableSendMailMsg, 400)
}
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text(msgs.EnableKVMsg, 400)
}
if (sendMailLimitConfig && !validateSendMailLimitConfig(sendMailLimitConfig)) {
return c.text(msgs.InvalidInputMsg, 400)
}
const sendMailLimitConfigToSave = sendMailLimitConfig
? getSendMailLimitConfigToSave(sendMailLimitConfig)
: null;
await saveSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY, JSON.stringify(blockList));
await saveSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY, JSON.stringify(sendBlockList));
await saveSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY, JSON.stringify(verifiedAddressList));
if (fromBlockList?.length > 0 && c.env.KV) {
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
}
await saveSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY, JSON.stringify(noLimitSendAddressList || []));
await saveSetting(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY, JSON.stringify(emailRuleSettings || {}));
if (addressCreationSettingsUpdate.shouldUpdate) {
if (addressCreationSettingsUpdate.shouldClear) {
await c.env.DB.prepare(
`DELETE FROM settings WHERE key = ?`
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
} else {
await saveSetting(
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
JSON.stringify({
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
})
)
}
}
if (sendMailLimitConfigToSave) {
await saveSetting(
c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY,
JSON.stringify(sendMailLimitConfigToSave)
)
}
return c.json({ success: true });
};
export default { get, save };
+159
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
};
@@ -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 };
+48 -418
View File
@@ -1,10 +1,11 @@
import { Hono } from 'hono' import { Context, Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n' import { getUserRoles } from '../utils'
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils' import address_api from './address_api'
import { newAddress, handleListQuery, getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common' import address_sender_api from './address_sender_api'
import { CONSTANTS } from '../constants' 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 cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api' import admin_user_api from './admin_user_api'
import webhook_settings from './webhook_settings' import webhook_settings from './webhook_settings'
@@ -12,415 +13,43 @@ import mail_webhook_settings from './mail_webhook_settings'
import oauth2_settings from './oauth2_settings' import oauth2_settings from './oauth2_settings'
import worker_config from './worker_config' import worker_config from './worker_config'
import admin_mail_api from './admin_mail_api' 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 db_api from './db_api'
import ip_blacklist_settings from './ip_blacklist_settings' import ip_blacklist_settings from './ip_blacklist_settings'
import ai_extract_settings from './ai_extract_settings' import ai_extract_settings from './ai_extract_settings'
import { EmailRuleSettings } from '../models'
import e2e_test_api from './e2e_test_api' import e2e_test_api from './e2e_test_api'
export const api = new Hono<HonoCustomType>() export const api = new Hono<HonoCustomType>()
const normalizeAddressCreationSettingsUpdate = ( // address
value: unknown api.get('/admin/address', address_api.listAddresses)
): { api.post('/admin/new_address', address_api.createNewAddress)
shouldUpdate: boolean, api.delete('/admin/delete_address/:id', address_api.deleteAddress)
shouldClear: boolean, api.delete('/admin/clear_inbox/:id', address_api.clearInbox)
nextEnableSubdomainMatch?: boolean, api.delete('/admin/clear_sent_items/:id', address_api.clearSentItems)
} | null => { api.get('/admin/show_password/:id', address_api.showPassword)
if (typeof value === 'undefined') { api.post('/admin/address/:id/reset_password', address_api.resetPassword)
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) {
// 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
);
})
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 });
})
// mail api // mail api
api.get('/admin/mails', admin_mail_api.getMails); api.get('/admin/mails', admin_mail_api.getMails)
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails); api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails)
api.delete('/admin/mails/:id', admin_mail_api.deleteMail) api.delete('/admin/mails/:id', admin_mail_api.deleteMail)
api.get('/admin/address_sender', async (c) => { // address sender
const { address, limit, offset } = c.req.query(); api.get('/admin/address_sender', address_sender_api.list)
if (address) { api.post('/admin/address_sender', address_sender_api.update)
return await handleListQuery(c, api.delete('/admin/address_sender/:id', address_sender_api.remove)
`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
);
})
api.post('/admin/address_sender', async (c) => { // sendbox
const msgs = i18n.getMessagesbyContext(c); api.get('/admin/sendbox', sendbox_api.list)
/* eslint-disable prefer-const */ api.delete('/admin/sendbox/:id', sendbox_api.remove)
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
})
})
api.delete('/admin/address_sender/:id', async (c) => { // statistics
const { id } = c.req.param(); api.get('/admin/statistics', statistics_api.get)
const { success } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
api.get('/admin/sendbox', async (c) => { // account settings
const { address, limit, offset } = c.req.query(); api.get('/admin/account_settings', account_settings_api.get)
if (address) { api.post('/admin/account_settings', account_settings_api.save)
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
})
})
// cleanup // cleanup
api.post('/admin/cleanup', cleanup_api.cleanup) api.post('/admin/cleanup', cleanup_api.cleanup)
@@ -434,7 +63,7 @@ api.get('/admin/users', admin_user_api.getUsers)
api.delete('/admin/users/:user_id', admin_user_api.deleteUser) api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
api.post('/admin/users', admin_user_api.createUser) api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword) 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.post('/admin/user_roles', admin_user_api.updateUserRoles)
api.get('/admin/role_address_config', admin_user_api.getRoleAddressConfig) api.get('/admin/role_address_config', admin_user_api.getRoleAddressConfig)
api.post('/admin/role_address_config', admin_user_api.saveRoleAddressConfig) api.post('/admin/role_address_config', admin_user_api.saveRoleAddressConfig)
@@ -446,33 +75,34 @@ api.get('/admin/user_oauth2_settings', oauth2_settings.getUserOauth2Settings)
api.post('/admin/user_oauth2_settings', oauth2_settings.saveUserOauth2Settings) api.post('/admin/user_oauth2_settings', oauth2_settings.saveUserOauth2Settings)
// webhook settings // webhook settings
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings); api.get('/admin/webhook/settings', webhook_settings.getWebhookSettings)
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings); api.post('/admin/webhook/settings', webhook_settings.saveWebhookSettings)
// mail webhook settings // mail webhook settings
api.get("/admin/mail_webhook/settings", mail_webhook_settings.getWebhookSettings); 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/settings', mail_webhook_settings.saveWebhookSettings)
api.post("/admin/mail_webhook/test", mail_webhook_settings.testWebhookSettings); api.post('/admin/mail_webhook/test', mail_webhook_settings.testWebhookSettings)
// worker config // worker config
api.get("/admin/worker/configs", worker_config.getConfig); api.get('/admin/worker/configs', worker_config.getConfig)
// send mail by admin // 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 // db api
api.get('admin/db_version', db_api.getVersion); api.get('admin/db_version', db_api.getVersion)
api.post('admin/db_initialize', db_api.initialize); api.post('admin/db_initialize', db_api.initialize)
api.post('admin/db_migration', db_api.migrate); api.post('admin/db_migration', db_api.migrate)
// IP blacklist settings // IP blacklist settings
api.get("/admin/ip_blacklist/settings", ip_blacklist_settings.getIpBlacklistSettings); api.get('/admin/ip_blacklist/settings', ip_blacklist_settings.getIpBlacklistSettings)
api.post("/admin/ip_blacklist/settings", ip_blacklist_settings.saveIpBlacklistSettings); api.post('/admin/ip_blacklist/settings', ip_blacklist_settings.saveIpBlacklistSettings)
// AI extract settings // AI extract settings
api.get("/admin/ai_extract/settings", ai_extract_settings.getAiExtractSettings); api.get('/admin/ai_extract/settings', ai_extract_settings.getAiExtractSettings)
api.post("/admin/ai_extract/settings", ai_extract_settings.saveAiExtractSettings); api.post('/admin/ai_extract/settings', ai_extract_settings.saveAiExtractSettings)
// E2E test endpoints // E2E test endpoints
api.post('/admin/test/seed_mail', e2e_test_api.seedMail); api.post('/admin/test/seed_mail', e2e_test_api.seedMail)
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail); api.post('/admin/test/receive_mail', e2e_test_api.receiveMail)
+80 -1
View File
@@ -1,12 +1,34 @@
import { Context } from "hono"; import { Context } from "hono";
import { isSendMailBindingEnabled } from "../common";
import i18n from "../i18n";
import { sendMail } from "../mails_api/send_mail_api"; 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>) => { 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 { const {
from_name, from_mail, from_name, from_mail,
to_mail, to_name, to_mail, to_name,
subject, content, is_html subject, content, is_html
} = await c.req.json(); } = reqJson;
try {
await sendMail(c, from_mail, { await sendMail(c, from_mail, {
from_name: from_name, from_name: from_name,
to_name: to_name, to_name: to_name,
@@ -17,5 +39,62 @@ export const sendMailbyAdmin = async (c: Context<HonoCustomType>) => {
}, { }, {
isAdmin: true 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" }); return c.json({ status: "ok" });
} }
+29
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 };
+32
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 };
+20 -5
View File
@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt' import { Jwt } from 'hono/utils/jwt'
import { WorkerMailerOptions } from 'worker-mailer'; 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 { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants'; import { CONSTANTS } from './constants';
import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models'; import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
@@ -44,11 +44,26 @@ export const isSendMailEnabled = (
if (smtpConfigMap && smtpConfigMap[mailDomain]) return true; if (smtpConfigMap && smtpConfigMap[mailDomain]) return true;
// Check SEND_MAIL binding // Check SEND_MAIL binding
if (c.env.SEND_MAIL) return true; if (isSendMailBindingEnabled(c, mailDomain)) return true;
return false; 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 * 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> => { export const getAddressPrefix = async (c: Context<HonoCustomType>): Promise<string | undefined> => {
const user = c.get("userPayload"); const user = c.get("userPayload");
if (!user) { if (!user) {
return getStringValue(c.env.PREFIX); return getStringValue(c.env.PREFIX).trim().toLowerCase();
} }
const user_role = await commonGetUserRole(c, user.user_id); const user_role = await commonGetUserRole(c, user.user_id);
if (typeof user_role?.prefix === "string") { 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[]> => { export const getAllowDomains = async (c: Context<HonoCustomType>): Promise<string[]> => {
+3 -1
View File
@@ -1,5 +1,5 @@
export const CONSTANTS = { export const CONSTANTS = {
VERSION: 'v' + '1.6.0', VERSION: 'v' + '1.8.0',
// DB Version // DB Version
DB_VERSION_KEY: 'db_version', DB_VERSION_KEY: 'db_version',
@@ -26,4 +26,6 @@ export const CONSTANTS = {
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings", WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list", EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list",
WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY: "temp-mail-webhook-admin-mail-settings", 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",
} }
+4 -1
View File
@@ -71,13 +71,16 @@ const messages: LocaleMessages = {
ContentEmptyMsg: "Content is empty", ContentEmptyMsg: "Content is empty",
AlreadyRequestedMsg: "Already requested", AlreadyRequestedMsg: "Already requested",
EnableResendOrSmtpMsg: "Please enable resend or smtp for this domain", 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", InvalidToMailMsg: "Invalid recipient address",
// Admin related // Admin related
InvalidAddressIdMsg: "Invalid address_id", InvalidAddressIdMsg: "Invalid address_id",
EnableKVMsg: "Please enable KV first", EnableKVMsg: "Please enable KV first",
EnableSendMailMsg: "Please enable SEND_MAIL first", EnableSendMailMsg: "Please enable SEND_MAIL first",
EnableSendMailForDomainMsg: "Please enable SEND_MAIL for this domain first",
InvalidCleanupConfigMsg: "Invalid cleanType or cleanDays", InvalidCleanupConfigMsg: "Invalid cleanType or cleanDays",
InvalidCleanTypeMsg: "Invalid cleanType", InvalidCleanTypeMsg: "Invalid cleanType",
EnableKVForMailVerifyMsg: "Please enable KV first if you want to enable mail verify", EnableKVForMailVerifyMsg: "Please enable KV first if you want to enable mail verify",
+4 -1
View File
@@ -69,13 +69,16 @@ export type LocaleMessages = {
ContentEmptyMsg: string ContentEmptyMsg: string
AlreadyRequestedMsg: string AlreadyRequestedMsg: string
EnableResendOrSmtpMsg: string EnableResendOrSmtpMsg: string
EnableResendOrSmtpWithVerifiedMsg: string EnableResendOrSmtpOrSendMailMsg: string
ServerSendMailDailyLimitMsg: string
ServerSendMailMonthlyLimitMsg: string
InvalidToMailMsg: string InvalidToMailMsg: string
// Admin related // Admin related
InvalidAddressIdMsg: string InvalidAddressIdMsg: string
EnableKVMsg: string EnableKVMsg: string
EnableSendMailMsg: string EnableSendMailMsg: string
EnableSendMailForDomainMsg: string
InvalidCleanupConfigMsg: string InvalidCleanupConfigMsg: string
InvalidCleanTypeMsg: string InvalidCleanTypeMsg: string
EnableKVForMailVerifyMsg: string EnableKVForMailVerifyMsg: string
+4 -1
View File
@@ -71,13 +71,16 @@ const messages: LocaleMessages = {
ContentEmptyMsg: "内容不能为空", ContentEmptyMsg: "内容不能为空",
AlreadyRequestedMsg: "已经申请过了", AlreadyRequestedMsg: "已经申请过了",
EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp", EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp",
EnableResendOrSmtpWithVerifiedMsg: "请先为此域名启用 resendsmtp,或将收件人添加到已验证地址列表", EnableResendOrSmtpOrSendMailMsg: "请先为此域名启用 resendsmtp 或 SEND_MAIL",
ServerSendMailDailyLimitMsg: "服务器今日发信次数已达上限",
ServerSendMailMonthlyLimitMsg: "服务器本月发信次数已达上限",
InvalidToMailMsg: "收件人地址无效", InvalidToMailMsg: "收件人地址无效",
// Admin related // Admin related
InvalidAddressIdMsg: "无效的 address_id", InvalidAddressIdMsg: "无效的 address_id",
EnableKVMsg: "请先启用 KV", EnableKVMsg: "请先启用 KV",
EnableSendMailMsg: "请先启用 SEND_MAIL", EnableSendMailMsg: "请先启用 SEND_MAIL",
EnableSendMailForDomainMsg: "请先为此域名启用 SEND_MAIL",
InvalidCleanupConfigMsg: "无效的 cleanType 或 cleanDays", InvalidCleanupConfigMsg: "无效的 cleanType 或 cleanDays",
InvalidCleanTypeMsg: "无效的 cleanType", InvalidCleanTypeMsg: "无效的 cleanType",
EnableKVForMailVerifyMsg: "如果要启用邮件验证,请先启用 KV", EnableKVForMailVerifyMsg: "如果要启用邮件验证,请先启用 KV",
+23 -196
View File
@@ -1,10 +1,8 @@
import { Context, Hono } from 'hono' import { Hono } from 'hono'
import i18n from '../i18n'; import parsed_mail_api from './parsed_mail_api';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue, getSplitStringListValue, isAddressCountLimitReached } from '../utils'; import mails_crud from './mails_crud';
import { newAddress, handleMailListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains, updateAddressUpdatedAt, generateRandomName } from '../common' import new_address from './new_address';
import { CONSTANTS } from '../constants'
import { resolveRawEmailRow } from '../gzip'
import auto_reply from './auto_reply' import auto_reply from './auto_reply'
import webhook_settings from './webhook_settings'; import webhook_settings from './webhook_settings';
import s3_attachment from './s3_attachment'; import s3_attachment from './s3_attachment';
@@ -12,208 +10,37 @@ import address_auth from './address_auth';
export const api = new Hono<HonoCustomType>() export const api = new Hono<HonoCustomType>()
// auto reply
api.get('/api/auto_reply', auto_reply.getAutoReply) api.get('/api/auto_reply', auto_reply.getAutoReply)
api.post('/api/auto_reply', auto_reply.saveAutoReply) api.post('/api/auto_reply', auto_reply.saveAutoReply)
// webhook
api.get('/api/webhook/settings', webhook_settings.getWebhookSettings) api.get('/api/webhook/settings', webhook_settings.getWebhookSettings)
api.post('/api/webhook/settings', webhook_settings.saveWebhookSettings) api.post('/api/webhook/settings', webhook_settings.saveWebhookSettings)
api.post('/api/webhook/test', webhook_settings.testWebhookSettings) api.post('/api/webhook/test', webhook_settings.testWebhookSettings)
// attachment (S3)
api.get('/api/attachment/list', s3_attachment.list) api.get('/api/attachment/list', s3_attachment.list)
api.post('/api/attachment/delete', s3_attachment.deleteKey) api.post('/api/attachment/delete', s3_attachment.deleteKey)
api.post('/api/attachment/put_url', s3_attachment.getSignedPutUrl) api.post('/api/attachment/put_url', s3_attachment.getSignedPutUrl)
api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl) api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
api.get('/api/mails', async (c) => { // mail crud
const { address } = c.get("jwtPayload") api.get('/api/mails', mails_crud.listMails)
if (!address) { api.get('/api/mail/:mail_id', mails_crud.getMail)
return c.json({ "error": "No address" }, 400) api.delete('/api/mails/:id', mails_crud.deleteMail)
}
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
);
})
api.get('/api/mail/:mail_id', async (c) => { // parsed mail (server-side parsed subject/text/html/attachments)
const { address } = c.get("jwtPayload") api.get('/api/parsed_mails', parsed_mail_api.listParsedMails)
const { mail_id } = c.req.param(); api.get('/api/parsed_mail/:mail_id', parsed_mail_api.getParsedMail)
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));
})
api.delete('/api/mails/:id', async (c) => { // address settings / lifecycle
const msgs = i18n.getMessagesbyContext(c); api.get('/api/settings', mails_crud.getSettings)
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) { api.post('/api/new_address', new_address.createNewAddress)
return c.text(msgs.UserDeleteEmailDisabledMsg, 403) api.delete('/api/delete_address', mails_crud.deleteAddress)
} api.delete('/api/clear_inbox', mails_crud.clearInbox)
const { address } = c.get("jwtPayload") api.delete('/api/clear_sent_items', mails_crud.clearSentItems)
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 auth
api.post('/api/address_change_password', address_auth.changePassword) api.post('/api/address_change_password', address_auth.changePassword)
api.post('/api/address_login', address_auth.login) api.post('/api/address_login', address_auth.login)
+120
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 };
+74
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 };
+52
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 };
+107
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' };
}
+40 -36
View File
@@ -6,9 +6,11 @@ import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer';
import i18n from '../i18n'; import i18n from '../i18n';
import { CONSTANTS } from '../constants' 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 { 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>() export const api = new Hono<HonoCustomType>()
@@ -19,24 +21,14 @@ api.post('/api/request_send_mail_access', async (c) => {
if (!address) { if (!address) {
return c.text(msgs.AddressNotFoundMsg, 400) return c.text(msgs.AddressNotFoundMsg, 400)
} }
try { const result = await requestSendMailAccess(c, address);
const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0); if (result.status === "ok") {
const { success } = await c.env.DB.prepare( return c.json({ status: "ok" })
`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) { if (result.status === "already_requested") {
const message = (e as Error).message;
if (message && message.includes("UNIQUE")) {
return c.text(msgs.AlreadyRequestedMsg, 400) return c.text(msgs.AlreadyRequestedMsg, 400)
} }
return c.text(msgs.OperationFailedMsg, 500) return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({ status: "ok" })
}) })
export const sendMailToVerifyAddress = async ( export const sendMailToVerifyAddress = async (
@@ -63,6 +55,25 @@ export const sendMailToVerifyAddress = async (
await c.env.SEND_MAIL.send(message); 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 ( const sendMailByResend = async (
c: Context<HonoCustomType>, address: string, c: Context<HonoCustomType>, address: string,
reqJson: { reqJson: {
@@ -137,21 +148,11 @@ export const sendMail = async (
if (!domains.includes(mailDomain)) { if (!domains.includes(mailDomain)) {
throw new Error(msgs.InvalidDomainMsg) throw new Error(msgs.InvalidDomainMsg)
} }
const user_role = c.get("userRolePayload"); const sendBalanceState = await getSendBalanceState(c, address, {
const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE); isAdmin: options?.isAdmin,
const is_no_limit_send_balance = user_role && no_limit_roles.includes(user_role); });
// no need find noLimitSendAddressList if is_no_limit_send_balance if (sendBalanceState.needCheckBalance) {
const noLimitSendAddressList = is_no_limit_send_balance ? if (!sendBalanceState.balance || sendBalanceState.balance <= 0) {
[] : 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) {
throw new Error(msgs.NoBalanceMsg) throw new Error(msgs.NoBalanceMsg)
} }
} }
@@ -173,6 +174,7 @@ export const sendMail = async (
if (!content) { if (!content) {
throw new Error(msgs.ContentEmptyMsg) throw new Error(msgs.ContentEmptyMsg)
} }
await ensureSendMailLimit(c);
// send to verified address list, do not update balance // send to verified address list, do not update balance
const resendEnabled = c.env.RESEND_TOKEN || c.env[ const resendEnabled = c.env.RESEND_TOKEN || c.env[
@@ -190,6 +192,7 @@ export const sendMail = async (
sendByVerifiedAddressList = true; sendByVerifiedAddressList = true;
} }
} }
const sendMailBindingEnabled = isSendMailBindingEnabled(c, mailDomain);
// send mail workflow // send mail workflow
if (sendByVerifiedAddressList) { if (sendByVerifiedAddressList) {
@@ -202,15 +205,16 @@ export const sendMail = async (
else if (smtpConfig) { else if (smtpConfig) {
await sendMailBySmtp(c, address, reqJson, smtpConfig); await sendMailBySmtp(c, address, reqJson, smtpConfig);
} }
else if (sendMailBindingEnabled) {
await sendMailByBinding(c, address, reqJson);
}
else { else {
if (c.env.SEND_MAIL) { throw new Error(`${msgs.EnableResendOrSmtpOrSendMailMsg} (${mailDomain})`);
throw new Error(`${msgs.EnableResendOrSmtpWithVerifiedMsg} (${mailDomain})`);
}
throw new Error(`${msgs.EnableResendOrSmtpMsg} (${mailDomain})`);
} }
await increaseSendMailLimitCount(c);
// update balance // update balance
if (!sendByVerifiedAddressList && needCheckBalance) { if (!sendByVerifiedAddressList && sendBalanceState.needCheckBalance) {
try { try {
const { success } = await c.env.DB.prepare( const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET balance = balance - 1 where address = ?` `UPDATE address_sender SET balance = balance - 1 where address = ?`
@@ -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);
}
}
+7
View File
@@ -184,6 +184,13 @@ export type EmailRuleSettings = {
emailForwardingList: SubdomainForwardAddressList[] emailForwardingList: SubdomainForwardAddressList[]
} }
export type SendMailLimitConfig = {
dailyEnabled: boolean;
monthlyEnabled: boolean;
dailyLimit: number | null;
monthlyLimit: number | null;
}
export type RoleConfig = { export type RoleConfig = {
maxAddressCount?: number; maxAddressCount?: number;
// future configs can be added here // future configs can be added here
+3 -2
View File
@@ -8,8 +8,8 @@ type Bindings = {
// bindings // bindings
DB: D1Database DB: D1Database
KV: KVNamespace KV: KVNamespace
RATE_LIMITER: any RATE_LIMITER: RateLimit
SEND_MAIL: any SEND_MAIL: SendEmail
ASSETS: Fetcher ASSETS: Fetcher
AI: Ai AI: Ai
@@ -83,6 +83,7 @@ type Bindings = {
// SMTP config // SMTP config
SMTP_CONFIG: string | object | undefined SMTP_CONFIG: string | object | undefined
SEND_MAIL_DOMAINS: string | string[] | undefined
// telegram config // telegram config
TELEGRAM_BOT_TOKEN: string TELEGRAM_BOT_TOKEN: string
+6
View File
@@ -1,6 +1,8 @@
import { Context } from "hono"; import { Context } from "hono";
import i18n from "../i18n";
import { handleMailListQuery } from "../common"; import { handleMailListQuery } from "../common";
import UserBindAddressModule from "./bind_address"; import UserBindAddressModule from "./bind_address";
import { getBooleanValue } from "../utils";
export default { export default {
getMails: async (c: Context<HonoCustomType>) => { getMails: async (c: Context<HonoCustomType>) => {
@@ -26,6 +28,10 @@ export default {
); );
}, },
deleteMail: async (c: Context<HonoCustomType>) => { 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 { id } = c.req.param(); const { id } = c.req.param();
const { user_id } = c.get("userPayload"); const { user_id } = c.get("userPayload");
const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id); const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);
+2 -1
View File
@@ -21,6 +21,7 @@ keep_vars = true
# send_email = [ # send_email = [
# { name = "SEND_MAIL" }, # { name = "SEND_MAIL" },
# ] # ]
# SEND_MAIL_DOMAINS = ["example.com", "mail.example.com"]
[vars] [vars]
# DEFAULT_LANG = "zh" # DEFAULT_LANG = "zh"
@@ -85,7 +86,7 @@ ENABLE_AUTO_REPLY = false
# DISABLE_SHOW_GITHUB = true # DISABLE_SHOW_GITHUB = true
# Status monitoring page URL # Status monitoring page URL
# STATUS_URL = "https://status.example.com" # STATUS_URL = "https://status.example.com"
# default send balance, if not set, it will be 0 # default send balance, auto initialized when users open settings or send mail; if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1 # DEFAULT_SEND_BALANCE = 1
# the role which can send emails without limit, multiple roles can be separated by , # the role which can send emails without limit, multiple roles can be separated by ,
# NO_LIMIT_SEND_ROLE = "vip" # NO_LIMIT_SEND_ROLE = "vip"