mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-09 17:36:58 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e32cf472b | ||
|
|
296ddb8619 | ||
|
|
a5b64e1dc9 | ||
|
|
fa19dbbe02 | ||
|
|
ebeb94ed23 | ||
|
|
d1fb1f773b | ||
|
|
5c40eeec80 | ||
|
|
000cd0ddfa | ||
|
|
e772db8c3e | ||
|
|
a5aa475380 | ||
|
|
3221f5ae30 | ||
|
|
15e339282d |
@@ -0,0 +1,3 @@
|
|||||||
|
config.json
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: cf-temp-mail-release-notify
|
||||||
|
description: Announce a cloudflare_temp_email GitHub release to the project's Telegram channel topic. Use when the user asks to notify/announce/broadcast a release to Telegram, push release notes to the channel, or send a release to the topic after running cf-temp-mail-release. Posts bilingual (中文 + English) changelog excerpts plus the release URL.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Release Notify Workflow
|
||||||
|
|
||||||
|
Post an existing GitHub release's notes to the project's Telegram channel topic.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- `config.json` exists in this skill directory with `token`, `chat_id`, `message_thread_id` (gitignored, never commit).
|
||||||
|
- `gh` CLI authenticated.
|
||||||
|
- `uv` installed (`brew install uv` / `curl -LsSf https://astral.sh/uv/install.sh | sh`). Script uses PEP 723 inline metadata; `uv` auto-installs deps.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Resolve tag**: If the user didn't give one, use the latest release: `gh release list --limit 1 --json tagName --jq '.[0].tagName'`.
|
||||||
|
2. **Run the script**:
|
||||||
|
```bash
|
||||||
|
uv run scripts/send_release_to_telegram.py vX.Y.Z
|
||||||
|
```
|
||||||
|
The script fetches the release via `gh`, splits the body into zh/en sections, strips PR collapsibles and the cache-clearing link, truncates to fit Telegram's 4096-char limit, and posts to the configured `chat_id` + `message_thread_id`.
|
||||||
|
3. **Verify**: The script prints `ok: message_id=<id>` on success. Report the message id.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Message uses `parse_mode: MarkdownV2`; all content is escaped (via `md_escape`) to avoid parse errors on reserved chars `_ * [ ] ( ) ~ \` > # + - = | { } . !`.
|
||||||
|
- Only the zh/en changelog sections are posted. PRs list and the cache-clearing discussion link are stripped to keep the message concise.
|
||||||
|
- For very long release bodies, zh and en are each truncated to ~half of the 3500-char body budget.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"token": "<telegram bot token>",
|
||||||
|
"chat_id": "@cloudflare_temp_email",
|
||||||
|
"message_thread_id": 82
|
||||||
|
}
|
||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = ["httpx>=0.27"]
|
||||||
|
# ///
|
||||||
|
"""Send a cloudflare_temp_email release announcement to a Telegram channel topic.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run scripts/send_release_to_telegram.py <tag>
|
||||||
|
|
||||||
|
Reads skill config from ../config.json (relative to this script):
|
||||||
|
{
|
||||||
|
"token": "...",
|
||||||
|
"chat_id": "@channel_or_-100...",
|
||||||
|
"message_thread_id": 82
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
TG_API = "https://api.telegram.org"
|
||||||
|
TG_HARD_LIMIT = 4096
|
||||||
|
BODY_BUDGET = 3500 # leave room for header + footer
|
||||||
|
EN_MARKER_RE = re.compile(r"<details>\s*<summary>English</summary>", re.IGNORECASE)
|
||||||
|
MDV2_ESCAPE_RE = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
|
||||||
|
MDV2_CODE_ESCAPE_RE = re.compile(r"([`\\])")
|
||||||
|
MD_INLINE_RE = re.compile(r"\*\*(.+?)\*\*|`([^`]+)`")
|
||||||
|
MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
print(f"error: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def md_escape(text: str) -> str:
|
||||||
|
"""Escape all MarkdownV2 reserved characters."""
|
||||||
|
return MDV2_ESCAPE_RE.sub(r"\\\1", text)
|
||||||
|
|
||||||
|
|
||||||
|
def md_render(text: str) -> str:
|
||||||
|
"""Convert source Markdown (changelog) to Telegram MarkdownV2.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- `### Heading` -> bold line
|
||||||
|
- `**bold**` -> `*bold*`
|
||||||
|
- `` `code` `` -> `` `code` `` (only ` and \\ escaped inside)
|
||||||
|
- everything else: literal text with MDV2 specials escaped
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
for raw in text.splitlines():
|
||||||
|
m = MD_HEADING_RE.match(raw)
|
||||||
|
if m:
|
||||||
|
out.append(f"*{md_escape(m.group(2).strip())}*")
|
||||||
|
continue
|
||||||
|
segments: list[str] = []
|
||||||
|
last = 0
|
||||||
|
for im in MD_INLINE_RE.finditer(raw):
|
||||||
|
segments.append(md_escape(raw[last:im.start()]))
|
||||||
|
if im.group(1) is not None:
|
||||||
|
segments.append(f"*{md_escape(im.group(1))}*")
|
||||||
|
else:
|
||||||
|
segments.append(f"`{MDV2_CODE_ESCAPE_RE.sub(r'\\\\\1', im.group(2))}`")
|
||||||
|
last = im.end()
|
||||||
|
segments.append(md_escape(raw[last:]))
|
||||||
|
out.append("".join(segments))
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
cfg_path = Path(__file__).resolve().parent.parent / "config.json"
|
||||||
|
if not cfg_path.exists():
|
||||||
|
die(f"config missing: {cfg_path}")
|
||||||
|
try:
|
||||||
|
cfg = json.loads(cfg_path.read_text())
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
die(f"config.json is not valid JSON: {e}")
|
||||||
|
for k in ("token", "chat_id", "message_thread_id"):
|
||||||
|
if k not in cfg:
|
||||||
|
die(f"config.json missing key: {k}")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_release(tag: str) -> dict:
|
||||||
|
out = subprocess.run(
|
||||||
|
["gh", "release", "view", tag, "--json", "tagName,name,body,url"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if out.returncode != 0:
|
||||||
|
die(f"gh release view failed: {out.stderr.strip()}")
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_sections(body: str) -> tuple[str, str]:
|
||||||
|
m = EN_MARKER_RE.search(body)
|
||||||
|
if not m:
|
||||||
|
return body.strip(), ""
|
||||||
|
zh = body[: m.start()]
|
||||||
|
rest = body[m.end():]
|
||||||
|
close = rest.find("</details>")
|
||||||
|
if close < 0:
|
||||||
|
die("malformed release body: missing </details> after English marker")
|
||||||
|
en = rest[:close]
|
||||||
|
return zh.strip(), en.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def strip_noise(text: str) -> str:
|
||||||
|
"""Drop PR collapsibles, cache-clearing link, and Full Changelog line."""
|
||||||
|
lines = text.splitlines()
|
||||||
|
out: list[str] = []
|
||||||
|
depth = 0
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("<details>"):
|
||||||
|
depth += 1
|
||||||
|
continue
|
||||||
|
if stripped.startswith("</details>"):
|
||||||
|
depth = max(0, depth - 1)
|
||||||
|
continue
|
||||||
|
if depth > 0:
|
||||||
|
continue
|
||||||
|
if "discussions/487" in stripped:
|
||||||
|
continue
|
||||||
|
if stripped.startswith("**Full Changelog**"):
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
result: list[str] = []
|
||||||
|
blanks = 0
|
||||||
|
for line in out:
|
||||||
|
if not line.strip():
|
||||||
|
blanks += 1
|
||||||
|
if blanks <= 1:
|
||||||
|
result.append(line)
|
||||||
|
else:
|
||||||
|
blanks = 0
|
||||||
|
result.append(line)
|
||||||
|
return "\n".join(result).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(text: str, limit: int) -> str:
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: limit - 3].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _budget(zh: str, en: str, total: int) -> tuple[int, int]:
|
||||||
|
"""Split budget between zh and en based on actual length. Short side keeps full, long side absorbs the rest."""
|
||||||
|
if not en:
|
||||||
|
return total, 0
|
||||||
|
if len(zh) + len(en) <= total:
|
||||||
|
return len(zh), len(en)
|
||||||
|
half = total // 2
|
||||||
|
if len(zh) <= half:
|
||||||
|
return len(zh), total - len(zh)
|
||||||
|
if len(en) <= half:
|
||||||
|
return total - len(en), len(en)
|
||||||
|
return half, total - half
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(tag: str, name: str, url: str, body: str) -> str:
|
||||||
|
zh, en = extract_sections(body)
|
||||||
|
zh = strip_noise(zh)
|
||||||
|
en = strip_noise(en)
|
||||||
|
|
||||||
|
zh_limit, en_limit = _budget(zh, en, BODY_BUDGET)
|
||||||
|
zh = truncate(zh, zh_limit)
|
||||||
|
en = truncate(en, en_limit) if en else ""
|
||||||
|
|
||||||
|
title = md_escape(name or tag)
|
||||||
|
header = f"🚀 *{title} 已发布 / Released*"
|
||||||
|
parts = [header, "", md_render(zh)]
|
||||||
|
if en:
|
||||||
|
parts.extend(["", "__English__", "", md_render(en)])
|
||||||
|
parts.extend(["", f"🔗 {md_escape(url)}"])
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def send(cfg: dict, text: str) -> None:
|
||||||
|
payload = {
|
||||||
|
"chat_id": cfg["chat_id"],
|
||||||
|
"message_thread_id": cfg["message_thread_id"],
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "MarkdownV2",
|
||||||
|
"disable_web_page_preview": False,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{TG_API}/bot{cfg['token']}/sendMessage",
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
die(f"Telegram network error: {e}")
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
die(f"Telegram API returned non-JSON ({resp.status_code}): {resp.text[:200]!r}")
|
||||||
|
if resp.status_code != 200 or not data.get("ok"):
|
||||||
|
die(f"Telegram API rejected ({resp.status_code}): {data}")
|
||||||
|
print(f"ok: message_id={data['result'].get('message_id')}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
die("usage: send_release_to_telegram.py <tag>")
|
||||||
|
cfg = load_config()
|
||||||
|
rel = fetch_release(sys.argv[1])
|
||||||
|
text = build_message(rel["tagName"], rel.get("name", ""), rel["url"], rel.get("body", ""))
|
||||||
|
send(cfg, text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
name: cf-temp-mail-usage
|
||||||
|
description: Read mails from a cloudflare_temp_email mailbox using a user-supplied Address JWT and API base URL. Use when the user (or an agent such as OpenClaw / Codex / Cursor) needs to list the inbox, fetch a specific message, or extract a verification code / magic link. Prefers the server-parsed endpoints so the agent gets subject/text/html/attachments directly. Does NOT handle mailbox creation — the user provides the JWT themselves.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Temp-Mail Read-Only Usage
|
||||||
|
|
||||||
|
Consume an existing mailbox. The user hands over the JWT (obtained in a browser after creating an address); the agent only reads mail.
|
||||||
|
|
||||||
|
## Inputs the user must provide
|
||||||
|
|
||||||
|
- `BASE` — API base URL, e.g. `https://mail.example.com` or the Worker's `*.workers.dev` host.
|
||||||
|
- `JWT` — Address JWT. In the frontend it is stored in `localStorage` under the key `jwt` (raw string, no JSON wrap).
|
||||||
|
- *(optional)* `SITE_PASSWORD` — only if the deployment enabled `x-custom-auth`.
|
||||||
|
|
||||||
|
If anything is missing, ask the user before making requests.
|
||||||
|
|
||||||
|
## Required headers
|
||||||
|
|
||||||
|
- `Authorization: Bearer <JWT>` — on every `/api/*` request.
|
||||||
|
- `x-custom-auth: <SITE_PASSWORD>` — only when the site requires it.
|
||||||
|
- `x-lang: en` or `zh` — optional, error-message language.
|
||||||
|
|
||||||
|
Do not send the Address JWT as `x-user-token` — that is a different JWT type and will yield `401 InvalidAddressCredentialMsg`.
|
||||||
|
|
||||||
|
## Endpoints (read-only)
|
||||||
|
|
||||||
|
| Task | Method | Path | Returns |
|
||||||
|
| --------------------------- | ------ | ---------------------------------- | ----------------------------------------- |
|
||||||
|
| Address info | GET | `/api/settings` | `{ address, send_balance }` |
|
||||||
|
| **List parsed mails** | GET | `/api/parsed_mails?limit=&offset=` | `{ results: [parsedMail], count }` |
|
||||||
|
| **Get one parsed mail** | GET | `/api/parsed_mail/:id` | `parsedMail` |
|
||||||
|
| List raw mails | GET | `/api/mails?limit=&offset=` | `{ results: [{...,raw}], count }` |
|
||||||
|
| Get one raw mail | GET | `/api/mail/:id` | `{ ..., raw }` |
|
||||||
|
|
||||||
|
`limit` 1–100, `offset` 0-based. On `429`, back off.
|
||||||
|
|
||||||
|
**Prefer the `parsed_*` endpoints.** They run the same `commonParseMail` (postal-mime) the frontend uses and return structured fields directly, so the agent does not need to ship a MIME parser.
|
||||||
|
|
||||||
|
`parsedMail` shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"message_id": "<...>",
|
||||||
|
"source": "noreply@foo.com",
|
||||||
|
"to": "abc@yourdomain.com",
|
||||||
|
"created_at": "2026-04-21 10:00:00",
|
||||||
|
"sender": "Foo <noreply@foo.com>",
|
||||||
|
"subject": "Your code is 123456",
|
||||||
|
"text": "Your code is 123456\n",
|
||||||
|
"html": "<p>Your code is <b>123456</b></p>",
|
||||||
|
"attachments": [
|
||||||
|
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Attachment **binary content is not included** in `parsed_*` responses — only metadata. If you need the bytes, fetch the raw mail via `/api/mail/:id` and parse it client-side (see below).
|
||||||
|
|
||||||
|
## Recipes
|
||||||
|
|
||||||
|
### 1. Smoke-test the JWT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$BASE/api/settings" -H "Authorization: Bearer $JWT"
|
||||||
|
# → { "address": "abc123@example.com", "send_balance": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
If this returns `401`, JWT is wrong / expired / mismatched with `BASE` — ask the user for a fresh one.
|
||||||
|
|
||||||
|
### 2. List the inbox (parsed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
|
||||||
|
-H "Authorization: Bearer $JWT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Get one mail (parsed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$BASE/api/parsed_mail/<id>" -H "Authorization: Bearer $JWT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Extract a verification code (end-to-end, parsed API)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re, time, requests
|
||||||
|
|
||||||
|
BASE, JWT = "<BASE>", "<JWT>"
|
||||||
|
H = {"Authorization": f"Bearer {JWT}"}
|
||||||
|
|
||||||
|
def wait_for_code(pattern=r"\b\d{4,8}\b", timeout=120, poll=3):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
seen = set()
|
||||||
|
while time.time() < deadline:
|
||||||
|
lst = requests.get(f"{BASE}/api/parsed_mails?limit=5&offset=0", headers=H).json()
|
||||||
|
for m in lst.get("results", []):
|
||||||
|
if m["id"] in seen: continue
|
||||||
|
seen.add(m["id"])
|
||||||
|
body = (m.get("subject") or "") + "\n" + (m.get("text") or "") + "\n" + (m.get("html") or "")
|
||||||
|
hit = re.search(pattern, body)
|
||||||
|
if hit:
|
||||||
|
return hit.group(0)
|
||||||
|
time.sleep(poll)
|
||||||
|
raise TimeoutError("no matching mail within window")
|
||||||
|
|
||||||
|
print(wait_for_code())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Raw endpoints (fallback — only if you need attachment bytes or the original MIME)
|
||||||
|
|
||||||
|
`/api/mails` and `/api/mail/:id` return the gzip-resolved RFC822 source in `raw`. Parse it client-side.
|
||||||
|
|
||||||
|
### Node.js (postal-mime, pure JS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i postal-mime
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import PostalMime from 'postal-mime';
|
||||||
|
|
||||||
|
const mail = await (await fetch(`${BASE}/api/mail/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${JWT}` },
|
||||||
|
})).json();
|
||||||
|
const parsed = await PostalMime.parse(mail.raw);
|
||||||
|
// parsed.subject / parsed.from / parsed.text / parsed.html
|
||||||
|
// parsed.attachments[i].content is a Uint8Array
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python (stdlib, no deps)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import email, requests
|
||||||
|
from email import policy
|
||||||
|
|
||||||
|
r = requests.get(f"{BASE}/api/mail/{mid}", headers={"Authorization": f"Bearer {JWT}"}).json()
|
||||||
|
msg = email.message_from_string(r["raw"], policy=policy.default)
|
||||||
|
subject = msg["subject"]
|
||||||
|
text = (msg.get_body(preferencelist=("plain",)) or None) and msg.get_body(preferencelist=("plain",)).get_content()
|
||||||
|
html = (msg.get_body(preferencelist=("html",)) or None) and msg.get_body(preferencelist=("html",)).get_content()
|
||||||
|
for part in msg.iter_attachments():
|
||||||
|
name, mime, data = part.get_filename(), part.get_content_type(), part.get_content()
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend's reference implementation is `frontend/src/utils/email-parser.js` — tries `mail-parser-wasm` first, falls back to `postal-mime`. The server uses `postal-mime` only.
|
||||||
|
|
||||||
|
## Polling discipline
|
||||||
|
|
||||||
|
- Start at `poll=3s`, exponential backoff capped at 10s.
|
||||||
|
- Dedupe by mail `id`.
|
||||||
|
- Never poll faster than once per second.
|
||||||
|
- Respect `429` — sleep and retry.
|
||||||
|
|
||||||
|
## Common errors
|
||||||
|
|
||||||
|
- `401 InvalidAddressCredentialMsg` — JWT wrong/expired/sent via wrong header. Ask the user for a fresh JWT.
|
||||||
|
- `401 CustomAuthPasswordMsg` — site requires `x-custom-auth`; attach `SITE_PASSWORD`.
|
||||||
|
- `400 InvalidLimitMsg` / `InvalidOffsetMsg` — `limit` must be 1..100, `offset ≥ 0`.
|
||||||
|
- `429` — rate limited; back off.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: cf-temp-mail-version-upgrade
|
||||||
|
description: Upgrade the project version number. Use when the user asks to bump the version, upgrade the version, or prepare a new release version. Supports major, minor, and patch upgrades.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Version Upgrade
|
||||||
|
|
||||||
|
Upgrade the version number of the cloudflare_temp_email project.
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
1. `frontend/package.json` — `version` field
|
||||||
|
2. `worker/package.json` — `version` field
|
||||||
|
3. `worker/src/constants.ts` — `VERSION` constant (format: `VERSION: 'v' + '1.4.0'`)
|
||||||
|
4. `pages/package.json` — `version` field
|
||||||
|
5. `vitepress-docs/package.json` — `version` field
|
||||||
|
6. `CHANGELOG.md` — add new version placeholder
|
||||||
|
7. `CHANGELOG_EN.md` — add new version placeholder (English)
|
||||||
|
|
||||||
|
## Upgrade workflow
|
||||||
|
|
||||||
|
1. Read `frontend/package.json` to get the current version.
|
||||||
|
2. Compute the new version based on the upgrade type:
|
||||||
|
- major: 1.3.0 → 2.0.0
|
||||||
|
- minor: 1.3.0 → 1.4.0
|
||||||
|
- patch: 1.3.0 → 1.3.1
|
||||||
|
3. Update the `version` field in every `package.json` listed above.
|
||||||
|
4. Update the `VERSION` constant in `worker/src/constants.ts`.
|
||||||
|
5. Insert a new version placeholder at the top of `CHANGELOG.md`.
|
||||||
|
6. Insert a new version placeholder at the top of `CHANGELOG_EN.md`.
|
||||||
|
|
||||||
|
## CHANGELOG format
|
||||||
|
|
||||||
|
In `CHANGELOG.md`, insert before the existing `## v{OLD_VERSION}(main)` line (i.e. right after the closing `</p>` of the language-switch link):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## v{VERSION}(main)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
`CHANGELOG_EN.md` uses the same format.
|
||||||
|
|
||||||
|
## Commit message format
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: upgrade version to v{VERSION}
|
||||||
|
|
||||||
|
- Update version number to {VERSION} in all package.json files
|
||||||
|
- Add v{VERSION} placeholder in CHANGELOG.md
|
||||||
|
```
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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: |
|
|
||||||
for ((attempt=1; attempt<=10; attempt++)); do
|
|
||||||
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then
|
|
||||||
echo "frontend.zip found."
|
|
||||||
break
|
|
||||||
else
|
|
||||||
if [ $attempt -eq 10 ]; then
|
|
||||||
echo "Exceeded maximum retries. frontend.zip not found."
|
|
||||||
else
|
|
||||||
echo "frontend.zip not found. Retrying in 30 seconds..."
|
|
||||||
sleep 30
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Deploy Docs for ${{github.ref_name}}
|
|
||||||
run: |
|
|
||||||
cd vitepress-docs/
|
|
||||||
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
|
||||||
pnpm install --no-frozen-lockfile
|
|
||||||
if [[ ${{github.ref}} == refs/tags/* ]]; then
|
|
||||||
export TAG_NAME=${{github.ref_name}}
|
|
||||||
else
|
|
||||||
export TAG_NAME=$(git describe --tags --abbrev=0)
|
|
||||||
fi
|
|
||||||
echo "Deploying docs for tag $TAG_NAME"
|
|
||||||
pnpm run deploy
|
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
cd vitepress-docs/
|
||||||
|
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
TAG_NAME=$(gh release view --json tagName --jq '.tagName')
|
||||||
|
echo "Deploying docs for tag $TAG_NAME"
|
||||||
|
export TAG_NAME
|
||||||
|
pnpm run deploy
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` skill,OpenClaw / 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
|
||||||
|
# 方式 1:degit 拷贝子目录
|
||||||
|
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage .claude/skills/cf-temp-mail-usage
|
||||||
|
|
||||||
|
# 方式 2:安装到全局
|
||||||
|
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage ~/.claude/skills/cf-temp-mail-usage
|
||||||
|
```
|
||||||
|
|
||||||
|
细节见 [.claude/skills/cf-temp-mail-usage/SKILL.md](.claude/skills/cf-temp-mail-usage/SKILL.md)。
|
||||||
|
|
||||||
## 技术架构
|
## 技术架构
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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}` },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
import { test, expect, APIRequestContext } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
WORKER_URL_SEND_MAIL_DOMAIN,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
deleteAllMailpitMessages,
|
||||||
|
requestSendAccess,
|
||||||
|
onMailpitMessage,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD };
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNT_SETTINGS = {
|
||||||
|
blockList: [],
|
||||||
|
sendBlockList: [],
|
||||||
|
verifiedAddressList: [],
|
||||||
|
fromBlockList: [],
|
||||||
|
noLimitSendAddressList: [],
|
||||||
|
emailRuleSettings: {},
|
||||||
|
addressCreationSettings: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISABLED_LIMIT_CONFIG = {
|
||||||
|
dailyEnabled: false,
|
||||||
|
monthlyEnabled: false,
|
||||||
|
dailyLimit: null as number | null,
|
||||||
|
monthlyLimit: null as number | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function saveLimitConfig(
|
||||||
|
request: APIRequestContext,
|
||||||
|
sendMailLimitConfig: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { ...DEFAULT_ACCOUNT_SETTINGS, sendMailLimitConfig },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetLimitConfig(request: APIRequestContext) {
|
||||||
|
const res = await saveLimitConfig(request, DISABLED_LIMIT_CONFIG);
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOneMail(
|
||||||
|
request: APIRequestContext,
|
||||||
|
jwt: string,
|
||||||
|
tag: string,
|
||||||
|
opts: { expectDelivery?: boolean; lang?: string } = {}
|
||||||
|
) {
|
||||||
|
const { expectDelivery = true, lang } = opts;
|
||||||
|
const subject = `limit-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const headers: Record<string, string> = { Authorization: `Bearer ${jwt}` };
|
||||||
|
if (lang) headers['x-lang'] = lang;
|
||||||
|
|
||||||
|
let listener: ReturnType<typeof onMailpitMessage> | undefined;
|
||||||
|
if (expectDelivery) {
|
||||||
|
listener = onMailpitMessage((m) => m.Subject === subject);
|
||||||
|
await listener.ready;
|
||||||
|
}
|
||||||
|
const res = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
from_name: 'Limit E2E',
|
||||||
|
to_name: 'Recipient',
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject,
|
||||||
|
content: `Limit test body ${tag}`,
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { res, listener, subject };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeLimitBaseline(
|
||||||
|
request: APIRequestContext,
|
||||||
|
jwt: string,
|
||||||
|
config: {
|
||||||
|
dailyEnabled: boolean;
|
||||||
|
monthlyEnabled: boolean;
|
||||||
|
dailyLimit: number | null;
|
||||||
|
monthlyLimit: number | null;
|
||||||
|
},
|
||||||
|
subjectPrefix: string,
|
||||||
|
maxProbeLimit: number = 50
|
||||||
|
): Promise<number> {
|
||||||
|
for (let limit = 1; limit <= maxProbeLimit; limit++) {
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
...config,
|
||||||
|
dailyLimit: config.dailyEnabled ? limit : null,
|
||||||
|
monthlyLimit: config.monthlyEnabled ? limit : null,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const probe = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
data: {
|
||||||
|
from_name: 'probe',
|
||||||
|
to_name: '',
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: `${subjectPrefix}-${limit}-${Date.now()}`,
|
||||||
|
content: 'probe',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (probe.ok()) {
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to probe send mail limit baseline within ${maxProbeLimit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeDailyBaseline(
|
||||||
|
request: APIRequestContext,
|
||||||
|
jwt: string
|
||||||
|
): Promise<number> {
|
||||||
|
return probeLimitBaseline(request, jwt, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: false,
|
||||||
|
dailyLimit: 1,
|
||||||
|
monthlyLimit: null,
|
||||||
|
}, 'probe-daily');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeMonthlyBaseline(
|
||||||
|
request: APIRequestContext,
|
||||||
|
jwt: string
|
||||||
|
): Promise<number> {
|
||||||
|
return probeLimitBaseline(request, jwt, {
|
||||||
|
dailyEnabled: false,
|
||||||
|
monthlyEnabled: true,
|
||||||
|
dailyLimit: null,
|
||||||
|
monthlyLimit: 1,
|
||||||
|
}, 'probe-monthly');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Send Mail Limit', () => {
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
await deleteAllMailpitMessages(request);
|
||||||
|
await resetLimitConfig(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
await resetLimitConfig(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save + read roundtrip preserves all fields', async ({ request }) => {
|
||||||
|
const config = {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: true,
|
||||||
|
dailyLimit: 7,
|
||||||
|
monthlyLimit: 1234,
|
||||||
|
};
|
||||||
|
const save = await saveLimitConfig(request, config);
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(read.ok()).toBe(true);
|
||||||
|
const body = await read.json();
|
||||||
|
expect(body.sendMailLimitConfig).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled flags coerce numeric limits to null', async ({ request }) => {
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: false,
|
||||||
|
monthlyEnabled: false,
|
||||||
|
dailyLimit: 10,
|
||||||
|
monthlyLimit: 20,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
const body = await read.json();
|
||||||
|
expect(body.sendMailLimitConfig).toEqual(DISABLED_LIMIT_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minus one is accepted as unlimited', async ({ request }) => {
|
||||||
|
const config = {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: true,
|
||||||
|
dailyLimit: -1,
|
||||||
|
monthlyLimit: -1,
|
||||||
|
};
|
||||||
|
const save = await saveLimitConfig(request, config);
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
const body = await read.json();
|
||||||
|
expect(body.sendMailLimitConfig).toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid payloads rejected with 400', async ({ request }) => {
|
||||||
|
const cases: Array<Record<string, unknown>> = [
|
||||||
|
{ dailyEnabled: 'yes', monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
|
||||||
|
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: -2, monthlyLimit: null },
|
||||||
|
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: 1.5, monthlyLimit: null },
|
||||||
|
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
|
||||||
|
{ dailyEnabled: false, monthlyEnabled: true, dailyLimit: null, monthlyLimit: null },
|
||||||
|
];
|
||||||
|
for (const bad of cases) {
|
||||||
|
const res = await saveLimitConfig(request, bad);
|
||||||
|
expect(res.status(), `payload: ${JSON.stringify(bad)}`).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled limit allows unlimited sends', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-off');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
await resetLimitConfig(request);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const { res, listener } = await sendOneMail(request, jwt, `off${i}`);
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
await listener!.message;
|
||||||
|
}
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero limit blocks sending immediately', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-zero');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: false,
|
||||||
|
dailyLimit: 0,
|
||||||
|
monthlyLimit: null,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const { res } = await sendOneMail(request, jwt, 'zero', {
|
||||||
|
expectDelivery: false,
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(false);
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain('Server daily send quota has been reached');
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily limit blocks once reached and returns English message', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-daily');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
const baseline = await probeDailyBaseline(request, jwt);
|
||||||
|
const allowed = 2;
|
||||||
|
const limit = baseline + allowed;
|
||||||
|
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: false,
|
||||||
|
dailyLimit: limit,
|
||||||
|
monthlyLimit: null,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
for (let i = 0; i < allowed; i++) {
|
||||||
|
const { res, listener } = await sendOneMail(request, jwt, `d${i}`);
|
||||||
|
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||||
|
await listener!.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { res: blocked } = await sendOneMail(request, jwt, 'd-over', {
|
||||||
|
expectDelivery: false,
|
||||||
|
});
|
||||||
|
expect(blocked.ok()).toBe(false);
|
||||||
|
const text = await blocked.text();
|
||||||
|
expect(text).toContain('Server daily send quota has been reached');
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monthly limit blocks once reached', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-monthly');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
const baseline = await probeMonthlyBaseline(request, jwt);
|
||||||
|
const allowed = 2;
|
||||||
|
const limit = baseline + allowed;
|
||||||
|
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: false,
|
||||||
|
monthlyEnabled: true,
|
||||||
|
dailyLimit: null,
|
||||||
|
monthlyLimit: limit,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
for (let i = 0; i < allowed; i++) {
|
||||||
|
const { res, listener } = await sendOneMail(request, jwt, `m${i}`);
|
||||||
|
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||||
|
await listener!.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { res: blocked } = await sendOneMail(request, jwt, 'm-over', {
|
||||||
|
expectDelivery: false,
|
||||||
|
});
|
||||||
|
expect(blocked.ok()).toBe(false);
|
||||||
|
const text = await blocked.text();
|
||||||
|
expect(text).toContain('Server monthly send quota has been reached');
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zh-lang header returns Chinese daily limit message', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-zh');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
const baseline = await probeDailyBaseline(request, jwt);
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: false,
|
||||||
|
dailyLimit: baseline,
|
||||||
|
monthlyLimit: null,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const { res } = await sendOneMail(request, jwt, 'zh-over', {
|
||||||
|
expectDelivery: false,
|
||||||
|
lang: 'zh',
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(false);
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain('服务器今日发信次数已达上限');
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validation failures (missing subject) do not consume quota', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-noconsume');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
const baseline = await probeDailyBaseline(request, jwt);
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: false,
|
||||||
|
dailyLimit: baseline + 1,
|
||||||
|
monthlyLimit: null,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
// Empty subject → rejected by validation BEFORE the counter increments.
|
||||||
|
const badRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
data: {
|
||||||
|
from_name: '',
|
||||||
|
to_name: '',
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: '',
|
||||||
|
content: 'no subject',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(badRes.ok()).toBe(false);
|
||||||
|
|
||||||
|
const { res, listener } = await sendOneMail(request, jwt, 'after-bad');
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
await listener!.message;
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both daily + monthly enabled: tighter daily limit wins', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-both');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
const baseline = await probeDailyBaseline(request, jwt);
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: true,
|
||||||
|
dailyLimit: baseline,
|
||||||
|
monthlyLimit: baseline + 10_000,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const { res } = await sendOneMail(request, jwt, 'both-over', {
|
||||||
|
expectDelivery: false,
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(false);
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain('Server daily send quota has been reached');
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minus one means unlimited at runtime', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-unlimited');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: true,
|
||||||
|
dailyLimit: -1,
|
||||||
|
monthlyLimit: -1,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const { res, listener } = await sendOneMail(request, jwt, `unl${i}`);
|
||||||
|
expect(res.ok(), `send #${i} should succeed`).toBe(true);
|
||||||
|
await listener!.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/admin/send_mail_by_binding returns 400 when SEND_MAIL binding is missing', async ({ request }) => {
|
||||||
|
const res = await request.post(`${WORKER_URL}/admin/send_mail_by_binding`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
from: 'admin@test.example.com',
|
||||||
|
to: ['recipient@test.example.com'],
|
||||||
|
subject: 'no-binding',
|
||||||
|
text: 'body',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/admin/send_mail_by_binding returns 200 when domain is allowed', async ({ request }) => {
|
||||||
|
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
from: 'admin@test.example.com',
|
||||||
|
to: ['recipient@test.example.com'],
|
||||||
|
subject: `send-mail-domain-ok-${Date.now()}`,
|
||||||
|
text: 'body',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
expect(await res.json()).toEqual({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/admin/send_mail_by_binding returns 400 when domain is not allowed', async ({ request }) => {
|
||||||
|
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
from: 'admin@blocked.example.com',
|
||||||
|
to: ['recipient@test.example.com'],
|
||||||
|
subject: `send-mail-domain-blocked-${Date.now()}`,
|
||||||
|
text: 'body',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
expect(await res.text()).toContain('Please enable SEND_MAIL for this domain first');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily and monthly counters both increment on successful send', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'limit-both-inc');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
const dailyBaseline = await probeDailyBaseline(request, jwt);
|
||||||
|
const monthlyBaseline = await probeMonthlyBaseline(request, jwt);
|
||||||
|
|
||||||
|
// Give plenty of headroom so sends succeed.
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: true,
|
||||||
|
dailyLimit: dailyBaseline + 10,
|
||||||
|
monthlyLimit: monthlyBaseline + 10,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const { res, listener } = await sendOneMail(request, jwt, 'inc');
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
await listener!.message;
|
||||||
|
|
||||||
|
// Re-probe to confirm both counters moved forward after the successful send.
|
||||||
|
const dailyAfter = await probeDailyBaseline(request, jwt);
|
||||||
|
expect(dailyAfter).toBeGreaterThanOrEqual(dailyBaseline + 1);
|
||||||
|
const monthlyAfter = await probeMonthlyBaseline(request, jwt);
|
||||||
|
expect(monthlyAfter).toBeGreaterThanOrEqual(monthlyBaseline + 1);
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin /admin/send_mail also respects daily limit', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'limit-admin');
|
||||||
|
await requestSendAccess(request, jwt);
|
||||||
|
|
||||||
|
// Probe via a user-facing send to establish baseline.
|
||||||
|
const baseline = await probeDailyBaseline(request, jwt);
|
||||||
|
const save = await saveLimitConfig(request, {
|
||||||
|
dailyEnabled: true,
|
||||||
|
monthlyEnabled: false,
|
||||||
|
dailyLimit: baseline,
|
||||||
|
monthlyLimit: null,
|
||||||
|
});
|
||||||
|
expect(save.ok()).toBe(true);
|
||||||
|
|
||||||
|
const res = await request.post(`${WORKER_URL}/admin/send_mail`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
from_name: '',
|
||||||
|
from_mail: address,
|
||||||
|
to_name: '',
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: `admin-over-${Date.now()}`,
|
||||||
|
content: 'admin blocked body',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(false);
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain('Server daily send quota has been reached');
|
||||||
|
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
createTestAddress,
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+265
-257
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,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
@@ -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)`
|
|
||||||
|
|||||||
@@ -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 发送邮件)`
|
|
||||||
|
|||||||
@@ -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` 等其他发信通道。
|
||||||
|
|
||||||
## 接受邮件相关变量
|
## 接受邮件相关变量
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+283
-283
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+516
-507
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 };
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -1,21 +1,100 @@
|
|||||||
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;
|
||||||
await sendMail(c, from_mail, {
|
try {
|
||||||
from_name: from_name,
|
await sendMail(c, from_mail, {
|
||||||
to_name: to_name,
|
from_name: from_name,
|
||||||
to_mail: to_mail,
|
to_name: to_name,
|
||||||
subject: subject,
|
to_mail: to_mail,
|
||||||
content: content,
|
subject: subject,
|
||||||
is_html: is_html,
|
content: content,
|
||||||
}, {
|
is_html: is_html,
|
||||||
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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
@@ -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[]> => {
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -71,13 +71,16 @@ const messages: LocaleMessages = {
|
|||||||
ContentEmptyMsg: "内容不能为空",
|
ContentEmptyMsg: "内容不能为空",
|
||||||
AlreadyRequestedMsg: "已经申请过了",
|
AlreadyRequestedMsg: "已经申请过了",
|
||||||
EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp",
|
EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp",
|
||||||
EnableResendOrSmtpWithVerifiedMsg: "请先为此域名启用 resend 或 smtp,或将收件人添加到已验证地址列表",
|
EnableResendOrSmtpOrSendMailMsg: "请先为此域名启用 resend、smtp 或 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
@@ -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)
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -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) {
|
|
||||||
const message = (e as Error).message;
|
|
||||||
if (message && message.includes("UNIQUE")) {
|
|
||||||
return c.text(msgs.AlreadyRequestedMsg, 400)
|
|
||||||
}
|
|
||||||
return c.text(msgs.OperationFailedMsg, 500)
|
|
||||||
}
|
}
|
||||||
return c.json({ status: "ok" })
|
if (result.status === "already_requested") {
|
||||||
|
return c.text(msgs.AlreadyRequestedMsg, 400)
|
||||||
|
}
|
||||||
|
return c.text(msgs.OperationFailedMsg, 500)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sendMailToVerifyAddress = async (
|
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 {
|
else if (sendMailBindingEnabled) {
|
||||||
if (c.env.SEND_MAIL) {
|
await sendMailByBinding(c, address, reqJson);
|
||||||
throw new Error(`${msgs.EnableResendOrSmtpWithVerifiedMsg} (${mailDomain})`);
|
|
||||||
}
|
|
||||||
throw new Error(`${msgs.EnableResendOrSmtpMsg} (${mailDomain})`);
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`${msgs.EnableResendOrSmtpOrSendMailMsg} (${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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Vendored
+3
-2
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user