mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-09 01:17:02 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e32cf472b | ||
|
|
296ddb8619 |
@@ -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()
|
||||||
@@ -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.
|
||||||
@@ -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,19 @@
|
|||||||
<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)
|
## v1.7.0(main)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -6,6 +6,19 @@
|
|||||||
<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)
|
## v1.7.0(main)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloudflare_temp_email",
|
"name": "cloudflare_temp_email",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+4
-4
@@ -2654,8 +2654,8 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
safe-array-concat@1.1.3:
|
safe-array-concat@1.1.4:
|
||||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
safe-buffer@5.1.2:
|
safe-buffer@5.1.2:
|
||||||
@@ -5133,7 +5133,7 @@ snapshots:
|
|||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
own-keys: 1.0.1
|
own-keys: 1.0.1
|
||||||
regexp.prototype.flags: 1.5.4
|
regexp.prototype.flags: 1.5.4
|
||||||
safe-array-concat: 1.1.3
|
safe-array-concat: 1.1.4
|
||||||
safe-push-apply: 1.0.0
|
safe-push-apply: 1.0.0
|
||||||
safe-regex-test: 1.1.0
|
safe-regex-test: 1.1.0
|
||||||
set-proto: 1.0.0
|
set-proto: 1.0.0
|
||||||
@@ -5953,7 +5953,7 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.60.2
|
'@rollup/rollup-win32-x64-msvc': 4.60.2
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
safe-array-concat@1.1.3:
|
safe-array-concat@1.1.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temp-email-pages",
|
"name": "temp-email-pages",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "temp-mail-docs",
|
"name": "temp-mail-docs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloudflare_temp_email",
|
"name": "cloudflare_temp_email",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 };
|
||||||
+47
-438
@@ -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'
|
||||||
@@ -13,434 +14,42 @@ 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, sendMailByBindingAdmin } from './send_mail'
|
import { sendMailbyAdmin, sendMailByBindingAdmin } from './send_mail'
|
||||||
import {
|
|
||||||
getSendMailLimitConfig,
|
|
||||||
getSendMailLimitConfigToSave,
|
|
||||||
validateSendMailLimitConfig
|
|
||||||
} from '../mails_api/send_mail_limit_utils'
|
|
||||||
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);
|
|
||||||
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({})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
api.post('/admin/cleanup', cleanup_api.cleanup)
|
api.post('/admin/cleanup', cleanup_api.cleanup)
|
||||||
@@ -454,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)
|
||||||
@@ -466,34 +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);
|
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)
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const CONSTANTS = {
|
export const CONSTANTS = {
|
||||||
VERSION: 'v' + '1.7.0',
|
VERSION: 'v' + '1.8.0',
|
||||||
|
|
||||||
// DB Version
|
// DB Version
|
||||||
DB_VERSION_KEY: 'db_version',
|
DB_VERSION_KEY: 'db_version',
|
||||||
|
|||||||
+23
-193
@@ -1,216 +1,46 @@
|
|||||||
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';
|
||||||
import address_auth from './address_auth';
|
import address_auth from './address_auth';
|
||||||
import { getSendBalanceState } from './send_balance';
|
|
||||||
|
|
||||||
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 { balance } = await getSendBalanceState(c, address);
|
|
||||||
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 };
|
||||||
Reference in New Issue
Block a user