Compare commits

...
Author SHA1 Message Date
dreamhunter2333 ce937fc4a9 refactor: simplify remote content handling 2026-07-05 00:46:08 +08:00
dreamhunter2333 5162f0a478 fix: default block remote content loading 2026-07-04 17:03:03 +08:00
dreamhunter2333 e06b7ccbb2 Merge remote-tracking branch 'origin/main' into fix/issue-1073-external-images 2026-07-04 16:59:16 +08:00
dreamhunter2333 24016a0051 fix: restore per-mail external image loading 2026-07-04 16:58:54 +08:00
Dream Hunter 3f1d800e90 fix: validate AI extracted link domains (#1075)
* fix: validate AI extracted link domains

* fix: validate extracted links against full email content

* refactor: simplify AI link domain guard

* refactor: keep AI domain fix prompt-only
2026-07-04 16:56:12 +08:00
dreamhunter2333 acd9a8f099 refactor: remove manual external image loading 2026-07-04 16:03:07 +08:00
dreamhunter2333 148fb09754 refactor: simplify external image policy 2026-07-04 15:15:41 +08:00
dreamhunter2333 5951998aad fix: block additional external image sources 2026-07-03 23:53:15 +08:00
dreamhunter2333 01aa97e384 feat: add external image loading toggle 2026-07-03 23:11:09 +08:00
Dream Hunter 70b30c2494 chore: upgrade Twisted to stable 26.4.0 (#1071)
chore: upgrade twisted to stable 26.4.0
2026-06-25 00:23:17 +08:00
Dream Hunter 1a1dd720c8 chore: upgrade smtp proxy and e2e dependencies (#1069) 2026-06-24 23:59:16 +08:00
Dream Hunter 2d501d82cf chore: upgrade dependencies (#1068) 2026-06-24 23:39:10 +08:00
Chánh NiệmandCommandCodeBot 7c57592742 docs: add Resend DNS-only proxy warning to prevent #515-style verification failures (#1062)
docs: add Resend DNS-only proxy warning to send-mail config

Resend domain verification CNAME records must use DNS-only (gray
cloud) on Cloudflare. Proxied (orange cloud) records prevent
verification, and a single failed attempt can take hours before
retry. This is a recurring issue (#515) that the Resend setup
docs did not warn about.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-06-19 15:49:03 +08:00
凉心anddreamhunter2333 41105ed803 fix: add page header padding for mobile layout (#1056)
* fix: add page header padding for mobile layout

* fix: limit page header padding to mobile layout

* docs: update changelog for mobile header fix

---------

Co-authored-by: dreamhunter2333 <dreamhunter2333@gmail.com>
2026-06-13 11:39:51 +08:00
Dream Hunter c924b71a5c fix: compact HTML before AI email extraction (#1057)
fix: compact html before ai extraction
2026-06-12 00:41:19 +08:00
Dream Hunter 4f0b44de2e chore: upgrade Vitest to v4 (#1052)
chore: upgrade vitest to v4
2026-06-03 23:29:56 +08:00
Dream Hunter f6fcbe793c feat: upgrade version to v1.10.0 (#1051)
- Update version number to 1.10.0 in all package.json files

- Add v1.10.0 placeholder in CHANGELOG.md
2026-06-03 22:14:44 +08:00
Dream Hunter 05a8ebb590 chore: upgrade dependencies (#1050) 2026-06-02 20:39:36 +08:00
Gene Dai b7718100c5 feat: regex fallback for verification code extraction without Workers AI (#1048)
feat: add regex fallback for verification code extraction without Workers AI

When AI email extraction is enabled but no Workers AI binding is available,
fall back to a built-in, zero-dependency regex extractor so self-hosted
deployments without Workers AI still surface verification codes in Telegram
notifications and webhooks.

- Add worker/src/email/extract_code.ts: rule-based multilingual
  (English / Chinese / Japanese / Korean) verification-code extractor with
  year and YYYYMMDD date rejection to avoid false positives.
- ai_extract.ts: share the allowlist check and content parsing across both
  paths, extract a saveExtractMetadata helper, and use the regex fallback
  when env.AI is absent.
- Reuse the existing aiExtractResult pipeline (auth_code type), so Telegram
  and webhook output need no changes.
- Update bilingual CHANGELOG and AI-extract feature docs.
2026-06-02 15:35:43 +08:00
Dream Hunter bf786947e3 feat: add AI extract webhook placeholders 2026-05-29 02:27:46 +08:00
Dream Hunter cfb31807f1 fix: keep Telegram AI extract result on metadata errors (#1045) 2026-05-29 01:48:02 +08:00
Wolf-L 308fbe2f9a feat: show AI extraction results in Telegram
Show AI extraction results in Telegram notifications and /mails views.
2026-05-29 01:13:31 +08:00
Dream Hunter 44b29aa646 feat: hide GitHub links for normal users
Add DISABLE_SHOW_GITHUB_FOR_USER to hide the Header GitHub/version entry from normal users while keeping it visible to admin users. Refs #1041
2026-05-21 23:38:49 +08:00
tuanaiseo 2221342560 fix: sanitize footer copyright html
Sanitize footer copyright HTML before rendering it with v-html.
2026-05-17 15:56:06 +08:00
Hging add0124cfd fix: normalize domain casing
Fix domain casing normalization for configured domains and inbound recipient domains.
2026-05-16 18:35:39 +08:00
Dream HunterandClaude Opus 4.7 8324b133fb docs: clarify wildcard MX requirement for random subdomain (#1036)
Random subdomain mailbox creation only generates addresses; mail delivery
depends on DNS / Cloudflare Email Routing covering *.<base-domain>.
Cloudflare Email Routing does not inherit apex configuration onto
subdomains, so a wildcard `*` MX record on the base domain is required
for random subdomains to actually receive mail.

- Add `[!IMPORTANT]` block in subdomain.md (zh/en) explaining the two
  deliverable paths: DNS-only wildcard MX (recommended for random
  subdomains) vs Cloudflare dashboard "Add subdomain"
- Link to Cloudflare Email Routing — Subdomains official docs from
  worker-vars.md and subdomain.md
- Instruct copying apex MX records to host `*` preserving each record's
  priority/target, instead of hardcoding specific MX targets
- Shorten frontend `randomSubdomainTip` for CreateAccount and Login
  views (6 locales: zh/en/de/es/ja/pt-BR), drop Markdown backticks
  (Vue text interpolation renders them literally), and point users to
  the docs instead of embedding DNS instructions
- Trim overlap between existing `[!NOTE]` and new `[!IMPORTANT]` in
  subdomain.md
- Update CHANGELOG.md / CHANGELOG_EN.md under v1.9.0(main)

Closes #1035
Closes #1026

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:09:32 +08:00
Dream Hunter 74c8e8f7e4 fix: prevent iOS input focus zoom (#1033)
fix: prevent ios input focus zoom
2026-05-10 02:05:31 +08:00
Dream Hunter 2df4de22d9 chore: update SimpleWebAuthn dependencies (#1032)
chore: update simplewebauthn dependencies
2026-05-10 01:54:51 +08:00
Dream Hunter 437db7c050 fix: update AI extract default model
* fix: update AI extract default model

* fix: update e2e worker node version

* fix: use node lts for e2e worker

* fix: align AI model and CI node version
2026-05-10 01:18:23 +08:00
Dream Hunter dd294037ab chore: upgrade project dependencies 2026-05-10 00:55:16 +08:00
Charlsonanddreamhunter2333 a9e2c89246 ci: allow docs deploy without GitHub release
* ci: allow docs deploy without GitHub release

* fix: use workflow run branch for docs tag fallback

---------

Co-authored-by: dreamhunter2333 <dreamhunter2333@gmail.com>
2026-05-10 00:50:08 +08:00
tar-xz 72bbfe8fd6 docs: fix GitHub Actions title typo
Fix a typo in the Chinese GitHub Actions deployment prerequisite title.
2026-05-01 00:20:17 +08:00
Dream Hunter 796a5e4ac5 feat: improve address credential connections 2026-04-30 15:33:06 +08:00
Dream Hunter 347be5c762 chore: prepare v1.9.0
- bump project version metadata to v1.9.0
- refresh npm dependencies and lockfiles across frontend, worker, pages, and docs
- link .agents/skills to .claude/skills
2026-04-30 02:03:51 +08:00
100 changed files with 5343 additions and 3664 deletions
+1
View File
@@ -0,0 +1 @@
../.claude/skills
-1
View File
@@ -1 +0,0 @@
../../skills/cf-temp-mail-agent-mail
@@ -1 +0,0 @@
../../.claude/skills/cf-temp-mail-upgrade-dependencies
@@ -1 +0,0 @@
../../.claude/skills/cf-temp-mail-version-upgrade
@@ -28,6 +28,7 @@ Upgrade the version number of the cloudflare_temp_email project.
4. Update the `VERSION` constant in `worker/src/constants.ts`.
5. Insert a new version placeholder at the top of `CHANGELOG.md`.
6. Insert a new version placeholder at the top of `CHANGELOG_EN.md`.
7. Rename the previous version heading in both changelogs from `## v{OLD_VERSION}(main)` to `## v{OLD_VERSION}` so only the new active development version keeps `(main)`.
## CHANGELOG format
@@ -44,7 +45,14 @@ In `CHANGELOG.md`, insert before the existing `## v{OLD_VERSION}(main)` line (i.
```
`CHANGELOG_EN.md` uses the same format.
After inserting the new placeholder, update the old heading:
```diff
-## v{OLD_VERSION}(main)
+## v{OLD_VERSION}
```
`CHANGELOG_EN.md` uses the same format and must receive the same old-heading update.
## Commit message format
@@ -53,4 +61,5 @@ feat: upgrade version to v{VERSION}
- Update version number to {VERSION} in all package.json files
- Add v{VERSION} placeholder in CHANGELOG.md
- Move (main) marker from v{OLD_VERSION} to v{VERSION}
```
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: pnpm/action-setup@v5
name: Install pnpm
+8 -2
View File
@@ -26,7 +26,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: pnpm/action-setup@v5
name: Install pnpm
@@ -40,11 +40,17 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
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')
if TAG_NAME=$(gh release view --json tagName --jq '.tagName' 2>/dev/null); then
echo "Using release tag $TAG_NAME"
else
TAG_NAME="${WORKFLOW_RUN_HEAD_BRANCH:-${GITHUB_REF_NAME:-main}}"
echo "No GitHub release found for this repo; fallback TAG_NAME=$TAG_NAME"
fi
echo "Deploying docs for tag $TAG_NAME"
export TAG_NAME
pnpm run deploy
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: pnpm/action-setup@v5
name: Install pnpm
@@ -56,7 +56,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: pnpm/action-setup@v5
name: Install pnpm
@@ -37,7 +37,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: pnpm/action-setup@v5
name: Install pnpm
+3 -3
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: pnpm/action-setup@v5
name: Install pnpm
@@ -44,7 +44,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: pnpm/action-setup@v5
name: Install pnpm
@@ -73,7 +73,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: pnpm/action-setup@v5
name: Install pnpm
+37 -1
View File
@@ -6,7 +6,43 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.8.0(main)
## v1.10.0(main)
### Features
- feat: |Frontend| 外观设置新增“自动加载远程内容”开关,默认关闭;关闭后邮件 HTML 中的远程图片默认以占位图显示,并支持单封邮件临时加载远程内容(issue #1073
### Bug Fixes
- fix: |AI 提取| 强化提示词,要求 AI 保持邮件原始链接域名,避免小模型改写验证链接域名导致错误跳转(issue #1072
- fix: |AI 提取| HTML-only 邮件在发送给 Workers AI 前会先压缩为可读文本,避免样式模板过长导致验证码位于 4000 字截断之后而无法识别
- fix: |Frontend| 移动端 Header 增加页头内边距,避免标题、菜单按钮与屏幕边缘过近
### Improvements
## v1.9.0
### Features
- feat: |AI 识别| 未配置 Workers AI 绑定时,自动回退到内置正则提取验证码(支持中英日韩,并排除年份与 `YYYYMMDD` 日期误判),让无 Workers AI 的自部署用户也能在 Telegram 推送与 Webhook 中拿到验证码
- feat: |Telegram| Telegram 新邮件推送与 `/mails` 历史邮件查看支持展示 AI 提取结果,包含验证码、验证链接、服务链接、订阅链接等关键信息
- feat: |Webhook| 邮件 Webhook 模板支持填充 AI 提取结果占位符,包括 `aiExtractType``aiExtractResult``aiExtractResultText`
- feat: |Frontend| 新增 `DISABLE_SHOW_GITHUB_FOR_USER` 配置,可仅对普通用户隐藏 Header 的 GitHub/版本入口,admin 仍可见(issue #1041
- feat: |Frontend| 将邮箱地址凭证弹窗升级为“地址凭证与连接方式”,复用普通用户与 admin 创建邮箱结果弹窗;支持通过 `ENABLE_AGENT_EMAIL_INFO` 展示 AI Agent 接入信息,并通过 `SMTP_IMAP_PROXY_CONFIG` 展示 SMTP/IMAP 客户端连接信息
- docs: |随机子域名| 在前端“启用随机子域名”提示与 `subdomain` / `worker-vars` 文档(中英)中明确说明:要让 `name@<随机>.abc.com` 真正收到邮件,必须在基础域名 DNS 中为 `*` 子域添加通配 MX 记录,Email Routing 子域不继承父域配置(issue #1035
### Bug Fixes
- fix: |Admin| 管理员重置邮箱地址密码时改为前端 SHA-256 后提交,后端只接受并存储哈希值,避免该接口继续接收明文密码
- fix: |Address| 管理员邮箱地址列表与用户绑定地址列表不再返回已存储的地址密码哈希值,避免列表接口暴露敏感字段
- fix: |Address| 统一规范化配置域名、收件地址域名与前缀的空白和大小写,覆盖 `DOMAINS``DEFAULT_DOMAINS``USER_ROLES.domains`、随机子域名、转发规则、SMTP 与 `SEND_MAIL` 域名匹配,保留转发规则空域名 catch-all 行为,并明确空 `DEFAULT_DOMAINS` / 角色域名回退到 `DOMAINS` 的行为,避免大小写配置或入站收件域名导致创建、收件、转发或发信失败(issue #926
- fix: |AI 提取| 将 AI 邮件识别默认 Workers AI 模型切换为支持 JSON Mode 且未弃用的 `@cf/meta/llama-3.1-8b-instruct-fast`,并在文档中补充 `@cf/zai-org/glm-4.7-flash` 结构化输出兼容性提示(issue #1029
- fix: |CI| 将 GitHub Actions 与 e2e Docker 镜像统一升级到 Node.js 24,适配 Wrangler 4.90.0 的运行时要求
- fix: |Frontend| 修复 iOS Safari 点击输入框时因移动端表单控件字号过小导致页面自动放大的问题
### Improvements
## v1.8.0
### Features
+37 -1
View File
@@ -6,7 +6,43 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.8.0(main)
## v1.10.0(main)
### Features
- feat: |Frontend| Add an Appearance toggle for automatically loading remote content, disabled by default; when disabled, HTML mail shows blocked-image placeholders by default and supports temporarily loading remote content for a single mail (issue #1073)
### Bug Fixes
- fix: |AI Extract| Strengthen the prompt to keep original link domains from the email, preventing small models from rewriting verification-link domains (issue #1072)
- fix: |AI Extract| Convert HTML-only mail bodies into compact readable text before sending them to Workers AI, preventing long templates from pushing verification codes past the 4000-character truncation window
- fix: |Frontend| Add mobile Header page padding so the title and menu button no longer sit too close to the screen edge
### Improvements
## v1.9.0
### Features
- feat: |AI Extract| Fall back to a built-in regex verification-code extractor (English / Chinese / Japanese / Korean, with year and `YYYYMMDD` date rejection) when no Workers AI binding is configured, so self-hosted deployments without Workers AI still surface codes in Telegram pushes and webhooks
- feat: |Telegram| Show AI extraction results in Telegram new-mail notifications and `/mails` history views, including verification codes, auth links, service links, and subscription links
- feat: |Webhook| Support AI extraction placeholders in mail webhook templates, including `aiExtractType`, `aiExtractResult`, and `aiExtractResultText`
- feat: |Frontend| Add `DISABLE_SHOW_GITHUB_FOR_USER` to hide the Header GitHub/version entry from normal users while keeping it visible to admin users (issue #1041)
- feat: |Frontend| Upgrade the address credential dialog to "Address Credentials & Connection Methods" and reuse it for both normal users and admin-created addresses; support showing AI Agent access via `ENABLE_AGENT_EMAIL_INFO` and SMTP/IMAP client settings via `SMTP_IMAP_PROXY_CONFIG`
- docs: |Random Subdomain| Clarify in the "Use Random Subdomain" frontend tip and the `subdomain` / `worker-vars` docs (zh & en) that receiving mail on `name@<random>.abc.com` requires a wildcard `*` MX record under the base domain in DNS, because Cloudflare Email Routing does not inherit the apex configuration onto subdomains (issue #1035)
### Bug Fixes
- fix: |Admin| Hash address passwords in the frontend before admin reset requests, and make the backend accept and store only the hash instead of plaintext
- fix: |Address| Stop returning stored address password hashes from the admin address list and user bound-address list APIs to avoid exposing sensitive fields
- fix: |Address| Normalize whitespace and casing for configured domains, inbound recipient domains, and prefixes across `DOMAINS`, `DEFAULT_DOMAINS`, `USER_ROLES.domains`, random subdomains, forwarding rules, SMTP, and `SEND_MAIL` domain matching, preserve blank-domain catch-all forwarding rules, and clarify that empty `DEFAULT_DOMAINS` / role domains fall back to `DOMAINS`, to avoid create, receive, forward, or send failures caused by mixed-case configuration or inbound recipient domains (issue #926)
- fix: |AI Extract| Switch the default Workers AI model for AI email recognition to the JSON Mode-compatible, non-deprecated `@cf/meta/llama-3.1-8b-instruct-fast`, and document structured-output compatibility guidance for `@cf/zai-org/glm-4.7-flash` (issue #1029)
- fix: |CI| Upgrade GitHub Actions and e2e Docker images to Node.js 24 to satisfy Wrangler 4.90.0 runtime requirements
- fix: |Frontend| Prevent iOS Safari from auto-zooming the page when focusing mobile form controls with small font sizes
### Improvements
## v1.8.0
### Features
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-slim
FROM node:24-slim
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-slim
FROM node:24-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
+9 -1
View File
@@ -1,4 +1,5 @@
import { APIRequestContext } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { createHash } from 'crypto';
import WebSocket from 'ws';
export const WORKER_URL = process.env.WORKER_URL!;
@@ -10,6 +11,13 @@ export const FRONTEND_URL = process.env.FRONTEND_URL!;
export const MAILPIT_API = process.env.MAILPIT_API!;
export const TEST_DOMAIN = 'test.example.com';
/**
* SHA-256 hash matching the frontend hashPassword utility.
*/
export function hashPassword(password: string): string {
return createHash('sha256').update(password).digest('hex');
}
/**
* Create a new email address via the worker API.
* Appends a timestamp suffix to avoid UNIQUE constraint collisions
+8 -4
View File
@@ -5,9 +5,13 @@ compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
[vars]
PREFIX = "tmp"
DEFAULT_DOMAINS = ["test.example.com"]
DOMAINS = ["test.example.com"]
PREFIX = "TMP"
DEFAULT_DOMAINS = []
DOMAINS = ["TEST.EXAMPLE.COM"]
USER_ROLES = [
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
{ domains = [], role = "empty-role", prefix = "EMPTY" },
]
JWT_SECRET = "e2e-test-secret-key"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
@@ -20,7 +24,7 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
"""
[[kv_namespaces]]
@@ -9,10 +9,10 @@ send_email = [
]
[vars]
PREFIX = "tmp"
DEFAULT_DOMAINS = ["test.example.com"]
DOMAINS = ["test.example.com"]
SEND_MAIL_DOMAINS = ["test.example.com"]
PREFIX = "TMP"
DEFAULT_DOMAINS = ["TEST.EXAMPLE.COM"]
DOMAINS = ["TEST.EXAMPLE.COM"]
SEND_MAIL_DOMAINS = ["TEST.EXAMPLE.COM"]
JWT_SECRET = "e2e-test-secret-key"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
@@ -25,7 +25,7 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
"""
[[kv_namespaces]]
+26 -50
View File
@@ -6,8 +6,8 @@
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
"imapflow": "^1.4.2",
"nodemailer": "^9.0.1"
},
"devDependencies": {
"@playwright/test": "1.58.2",
@@ -69,13 +69,13 @@
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"version": "5.4.12",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz",
"integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libmime": "5.3.8",
"libqp": "2.1.1"
}
},
@@ -129,38 +129,26 @@
}
},
"node_modules/imapflow": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
"integrity": "sha512-73CGfb5+W0FkZ5CY4GfSdsoXyQ+17wdKkpMN2vwJHdLtOOFQWxv0ilG7KYY79XHBYg5njjqxXYB2FPw5Tl81zQ==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"@zone-eu/mailsplit": "5.4.12",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libqp": "2.1.1",
"nodemailer": "8.0.5",
"nodemailer": "9.0.1",
"pino": "10.3.1",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
"socks": "2.8.9"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -173,29 +161,17 @@
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
@@ -203,9 +179,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -346,12 +322,12 @@
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
+2 -2
View File
@@ -13,7 +13,7 @@
"ws": "^8.18.0"
},
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
"imapflow": "^1.4.2",
"nodemailer": "^9.0.1"
}
}
+113 -5
View File
@@ -1,15 +1,16 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
test.describe('Address Password Login', () => {
test('set password then login with it', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'pwd-login');
const passwordHash = hashPassword('test-password-123');
try {
// Set a password on the address
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'test-password-123' },
data: { new_password: passwordHash },
});
expect(changePwdRes.ok()).toBe(true);
const changePwdBody = await changePwdRes.json();
@@ -17,7 +18,7 @@ test.describe('Address Password Login', () => {
// Login with the correct password
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: 'test-password-123' },
data: { email: address, password: passwordHash },
});
expect(loginRes.ok()).toBe(true);
const loginBody = await loginRes.json();
@@ -36,12 +37,13 @@ test.describe('Address Password Login', () => {
test('login with wrong password returns 401', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'pwd-wrong');
const passwordHash = hashPassword('correct-password');
try {
// Set a password
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'correct-password' },
data: { new_password: passwordHash },
});
expect(changePwdRes.ok()).toBe(true);
const changePwdBody = await changePwdRes.json();
@@ -49,11 +51,117 @@ test.describe('Address Password Login', () => {
// Login with wrong password
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: 'wrong-password' },
data: { email: address, password: hashPassword('wrong-password') },
});
expect(loginRes.status()).toBe(401);
} finally {
await deleteAddress(request, jwt);
}
});
test('admin reset stores frontend-hashed address password', async ({ request }) => {
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-admin-reset');
const plainPassword = `admin-reset-${Date.now()}`;
const passwordHash = hashPassword(plainPassword);
try {
const resetRes = await request.post(`${WORKER_URL}/admin/address/${address_id}/reset_password`, {
data: { password: passwordHash },
});
expect(resetRes.ok()).toBe(true);
await expect(resetRes.json()).resolves.toMatchObject({ success: true });
const plaintextLoginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: plainPassword },
});
expect(plaintextLoginRes.status()).toBe(401);
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: passwordHash },
});
expect(loginRes.ok()).toBe(true);
const loginBody = await loginRes.json();
expect(loginBody.jwt).toBeTruthy();
expect(loginBody.address).toBe(address);
} finally {
await deleteAddress(request, jwt);
}
});
test('admin address list does not expose stored password hash', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'pwd-list-hidden');
const passwordHash = hashPassword('list-hidden-password');
try {
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: passwordHash },
});
expect(changePwdRes.ok()).toBe(true);
const listRes = await request.get(
`${WORKER_URL}/admin/address?limit=10&offset=0&query=${encodeURIComponent(address)}`
);
expect(listRes.ok()).toBe(true);
const listBody = await listRes.json();
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
expect(listedAddress).toBeTruthy();
expect(listedAddress).not.toHaveProperty('password');
} finally {
await deleteAddress(request, jwt);
}
});
test('user bind address list does not expose stored password hash', async ({ request }) => {
const userEmail = `pwd-bind-hidden-${Date.now()}@test.example.com`;
const userPasswordHash = hashPassword('bind-hidden-user-password');
const { jwt, address } = await createTestAddress(request, 'pwd-bind-hidden');
const addressPasswordHash = hashPassword('bind-hidden-address-password');
try {
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
enable: true,
enableMailVerify: false,
},
});
expect(enableRes.ok()).toBe(true);
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: { email: userEmail, password: userPasswordHash },
});
expect(registerRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email: userEmail, password: userPasswordHash },
});
expect(loginRes.ok()).toBe(true);
const { jwt: userJwt } = await loginRes.json();
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: addressPasswordHash },
});
expect(changePwdRes.ok()).toBe(true);
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${jwt}`,
'x-user-token': userJwt,
},
});
expect(bindRes.ok()).toBe(true);
const listRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
headers: { 'x-user-token': userJwt },
});
expect(listRes.ok()).toBe(true);
const listBody = await listRes.json();
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
expect(listedAddress).toBeTruthy();
expect(listedAddress).not.toHaveProperty('password');
} finally {
await deleteAddress(request, jwt);
}
});
});
+112
View File
@@ -16,4 +16,116 @@ test.describe('Admin New Address', () => {
expect(body.address_id).toBeGreaterThan(0);
expect(typeof body.address_id).toBe('number');
});
test('normalizes uppercase configured prefix and domain', async ({ request }) => {
const uniqueName = `admincase${Date.now()}`;
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
data: { name: uniqueName, domain: TEST_DOMAIN.toUpperCase(), enablePrefix: true },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`);
expect(body.jwt).toBeTruthy();
expect(body.address_id).toBeGreaterThan(0);
});
test('falls back to domains when default domains is empty', async ({ request }) => {
const uniqueName = `fallback${Date.now().toString(36)}`;
const res = await request.post(`${WORKER_URL}/api/new_address`, {
data: { name: uniqueName },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`);
expect(body.jwt).toBeTruthy();
expect(body.address_id).toBeGreaterThan(0);
});
test('normalizes user role domains and prefix', async ({ request }) => {
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
const email = `role-case-${suffix}@${TEST_DOMAIN}`;
const password = `role-pass-${suffix}`;
const name = `rolecase${suffix}`;
const createUserRes = await request.post(`${WORKER_URL}/admin/users`, {
data: { email, password },
});
expect(createUserRes.ok()).toBe(true);
const usersRes = await request.get(`${WORKER_URL}/admin/users`, {
params: { limit: '20', offset: '0', query: email },
});
expect(usersRes.ok()).toBe(true);
const usersBody = await usersRes.json();
const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email);
expect(user).toBeTruthy();
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
data: { user_id: user.id, role_text: 'case-role' },
});
expect(updateRoleRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
expect(loginRes.ok()).toBe(true);
const { jwt: userJwt } = await loginRes.json();
const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, {
headers: { 'x-user-token': userJwt },
data: { name },
});
expect(createAddressRes.ok()).toBe(true);
const addressBody = await createAddressRes.json();
expect(addressBody.address).toBe(`role${name}@${TEST_DOMAIN}`);
expect(addressBody.jwt).toBeTruthy();
expect(addressBody.address_id).toBeGreaterThan(0);
});
test('falls back to default domains when user role domains is empty', async ({ request }) => {
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
const email = `empty-role-${suffix}@${TEST_DOMAIN}`;
const password = `empty-role-pass-${suffix}`;
const name = `emptyrole${suffix}`;
const createUserRes = await request.post(`${WORKER_URL}/admin/users`, {
data: { email, password },
});
expect(createUserRes.ok()).toBe(true);
const usersRes = await request.get(`${WORKER_URL}/admin/users`, {
params: { limit: '20', offset: '0', query: email },
});
expect(usersRes.ok()).toBe(true);
const usersBody = await usersRes.json();
const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email);
expect(user).toBeTruthy();
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
data: { user_id: user.id, role_text: 'empty-role' },
});
expect(updateRoleRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
expect(loginRes.ok()).toBe(true);
const { jwt: userJwt } = await loginRes.json();
const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, {
headers: { 'x-user-token': userJwt },
data: { name },
});
expect(createAddressRes.ok()).toBe(true);
const addressBody = await createAddressRes.json();
expect(addressBody.address).toBe(`empty${name}@${TEST_DOMAIN}`);
expect(addressBody.jwt).toBeTruthy();
expect(addressBody.address_id).toBeGreaterThan(0);
});
});
@@ -0,0 +1,175 @@
import { test, expect } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
TEST_DOMAIN,
createTestAddress,
deleteAddress,
} from '../../fixtures/test-helpers';
const ADMIN_PASSWORD = 'e2e-admin-pass';
const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD };
const DEFAULT_ACCOUNT_SETTINGS = {
blockList: [],
sendBlockList: [],
verifiedAddressList: [],
fromBlockList: [],
noLimitSendAddressList: [],
emailRuleSettings: {},
addressCreationSettings: {},
};
async function resetAccountSettings(request: APIRequestContext) {
const res = await request.post(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
data: DEFAULT_ACCOUNT_SETTINGS,
});
expect(res.ok()).toBe(true);
}
test.describe('Email forward domain normalization', () => {
test.afterEach(async ({ request }) => {
await resetAccountSettings(request);
});
test('normalizes uppercase forwarding rule and recipient domains', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'forward-case');
const forwardAddress = 'forward-target@test.example.com';
try {
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
data: {
...DEFAULT_ACCOUNT_SETTINGS,
emailRuleSettings: {
emailForwardingList: [{
domains: [TEST_DOMAIN.toUpperCase()],
forward: forwardAddress,
}],
},
},
});
expect(saveRes.ok()).toBe(true);
const to = address.replace(`@${TEST_DOMAIN}`, `@${TEST_DOMAIN.toUpperCase()}`);
const subject = `forward-case-${Date.now()}`;
const raw = [
`From: sender@test.example.com`,
`To: ${to}`,
`Subject: ${subject}`,
`Message-ID: <${subject}@test>`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=utf-8`,
``,
`Forward domain normalization test`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to, raw },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.forwardedTo).toEqual([forwardAddress]);
const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(mailsRes.ok()).toBe(true);
const mailsBody = await mailsRes.json();
expect(mailsBody.results.some((mail: { address: string; raw: string }) => {
return mail.address === address && mail.raw.includes(subject);
})).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('does not forward when only the domain suffix string matches', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'forward-boundary');
const forwardAddress = 'forward-boundary-target@test.example.com';
try {
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
data: {
...DEFAULT_ACCOUNT_SETTINGS,
emailRuleSettings: {
emailForwardingList: [{
domains: [TEST_DOMAIN],
forward: forwardAddress,
}],
},
},
});
expect(saveRes.ok()).toBe(true);
const to = address.replace(`@${TEST_DOMAIN}`, `@evil${TEST_DOMAIN}`);
const subject = `forward-boundary-${Date.now()}`;
const raw = [
`From: sender@test.example.com`,
`To: ${to}`,
`Subject: ${subject}`,
`Message-ID: <${subject}@test>`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=utf-8`,
``,
`Forward domain boundary test`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to, raw },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.forwardedTo).toEqual([]);
} finally {
await deleteAddress(request, jwt);
}
});
test('keeps blank forwarding rule domain as catch-all', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'forward-catch-all');
const forwardAddress = 'forward-catch-all-target@test.example.com';
try {
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
data: {
...DEFAULT_ACCOUNT_SETTINGS,
emailRuleSettings: {
emailForwardingList: [{
domains: ['', 'not-the-domain.example.com'],
forward: forwardAddress,
}],
},
},
});
expect(saveRes.ok()).toBe(true);
const subject = `forward-catch-all-${Date.now()}`;
const raw = [
`From: sender@test.example.com`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: <${subject}@test>`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=utf-8`,
``,
`Forward catch-all domain test`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.forwardedTo).toEqual([forwardAddress]);
} finally {
await deleteAddress(request, jwt);
}
});
});
+1
View File
@@ -15,6 +15,7 @@ test.describe('Health & Settings', () => {
const settings = await res.json();
expect(settings.domains).toContain('test.example.com');
expect(settings.defaultDomains).toContain('test.example.com');
expect(settings.prefix).toBe('tmp');
expect(settings.enableSendMail).toBe(true);
expect(settings.enableUserCreateEmail).toBe(true);
expect(settings.enableUserDeleteEmail).toBe(true);
+3 -11
View File
@@ -1,13 +1,5 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
import * as crypto from 'crypto';
/**
* SHA-256 hash matching frontend hashPassword utility.
*/
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
}
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
@@ -110,14 +102,14 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
// Set a password
await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'addr-pass-123' },
data: { new_password: hashPassword('addr-pass-123') },
});
// Login with cf_token field present but empty
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: {
email: address,
password: 'addr-pass-123',
password: hashPassword('addr-pass-123'),
cf_token: ''
},
});
+18 -3
View File
@@ -1,4 +1,5 @@
import { test, expect, APIRequestContext } from '@playwright/test';
import { test, expect } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
WORKER_URL_SEND_MAIL_DOMAIN,
@@ -425,11 +426,11 @@ test.describe('Send Mail Limit', () => {
expect(res.status()).toBe(400);
});
test('/admin/send_mail_by_binding returns 200 when domain is allowed', async ({ request }) => {
test('/admin/send_mail_by_binding normalizes uppercase allowed domain', async ({ request }) => {
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
headers: ADMIN_HEADERS,
data: {
from: 'admin@test.example.com',
from: 'admin@TEST.EXAMPLE.COM',
to: ['recipient@test.example.com'],
subject: `send-mail-domain-ok-${Date.now()}`,
text: 'body',
@@ -439,6 +440,20 @@ test.describe('Send Mail Limit', () => {
expect(await res.json()).toEqual({ status: 'ok' });
});
test('/admin/send_mail_by_binding normalizes object-form from domain', async ({ request }) => {
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
headers: ADMIN_HEADERS,
data: {
from: { email: 'admin@TEST.EXAMPLE.COM', name: 'Admin' },
to: ['recipient@test.example.com'],
subject: `send-mail-domain-object-ok-${Date.now()}`,
text: 'body',
},
});
expect(res.ok()).toBe(true);
expect(await res.json()).toEqual({ status: 'ok' });
});
test('/admin/send_mail_by_binding returns 400 when domain is not allowed', async ({ request }) => {
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
headers: ADMIN_HEADERS,
+55
View File
@@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Telegram AI extraction rendering', () => {
test('realtime mail stores AI extraction metadata for Telegram rendering', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'tg-ai');
try {
const subject = `Telegram AI realtime ${Date.now()}`;
const raw = [
'From: sender@test.example.com',
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: <telegram-ai-${Date.now()}@test>`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=utf-8',
'',
'Telegram AI extraction realtime body',
].join('\r\n');
const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: {
from: 'sender@test.example.com',
to: address,
raw,
ai_extract_result: {
type: 'auth_code',
result: '123456',
result_text: '',
},
},
});
expect(receiveRes.ok()).toBe(true);
const receiveBody = await receiveRes.json();
expect(receiveBody.success).toBe(true);
const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(mailsRes.ok()).toBe(true);
const { results } = await mailsRes.json();
expect(results).toHaveLength(1);
const metadata = JSON.parse(results[0].metadata);
expect(metadata.ai_extract).toEqual({
type: 'auth_code',
result: '123456',
result_text: '',
});
expect(metadata.extracted_at).toBeTruthy();
} finally {
await deleteAddress(request, jwt);
}
});
});
@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import http from 'node:http';
import { WORKER_URL } from '../../fixtures/test-helpers';
async function resetUserSettings(request: APIRequestContext) {
const res = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
enable: true,
enableMailVerify: false,
enableMailAllowList: false,
mailAllowList: [],
},
});
expect(res.ok()).toBe(true);
}
async function resetOauth2Settings(request: APIRequestContext) {
const res = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, {
data: [],
});
expect(res.ok()).toBe(true);
}
async function startOauthServer(email: string): Promise<{ server: http.Server; baseUrl: string }> {
const server = http.createServer((req, res) => {
if (req.url === '/token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ access_token: 'token', token_type: 'Bearer' }));
return;
}
if (req.url === '/userinfo') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ email }));
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
await new Promise<void>((resolve) => server.listen(0, '0.0.0.0', resolve));
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('Failed to resolve OAuth test server port');
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
return { server, baseUrl: `http://${hostname}:${addr.port}` };
}
test.describe('User domain normalization', () => {
test.afterEach(async ({ request }) => {
await resetOauth2Settings(request);
await resetUserSettings(request);
});
test('normalizes uppercase verify mail sender domain in admin settings', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
enable: true,
enableMailVerify: true,
verifyMailSender: 'verify@TEST.EXAMPLE.COM',
},
});
expect(res.ok()).toBe(true);
});
test('normalizes uppercase user registration allow-list domains', async ({ request }) => {
const saveRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
enable: true,
enableMailVerify: false,
enableMailAllowList: true,
mailAllowList: ['TEST.EXAMPLE.COM'],
},
});
expect(saveRes.ok()).toBe(true);
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: {
email: `allow-list-${Date.now()}@TEST.EXAMPLE.COM`,
password: 'allow-list-password',
},
});
expect(registerRes.ok()).toBe(true);
});
test('normalizes uppercase OAuth2 allow-list domains', async ({ request }) => {
const email = `oauth-allow-${Date.now()}@TEST.EXAMPLE.COM`;
const { server, baseUrl } = await startOauthServer(email);
try {
const saveRes = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, {
data: [{
name: 'case-oauth',
clientID: 'case-client',
clientSecret: 'case-secret',
authorizationURL: `${baseUrl}/authorize`,
accessTokenURL: `${baseUrl}/token`,
accessTokenFormat: 'json',
userInfoURL: `${baseUrl}/userinfo`,
redirectURL: `${baseUrl}/callback`,
userEmailKey: 'email',
scope: 'openid email',
enableMailAllowList: true,
mailAllowList: ['TEST.EXAMPLE.COM'],
}],
});
expect(saveRes.ok()).toBe(true);
const callbackRes = await request.post(`${WORKER_URL}/user_api/oauth2/callback`, {
data: { clientID: 'case-client', code: 'case-code' },
});
expect(callbackRes.ok()).toBe(true);
const body = await callbackRes.json();
expect(body.jwt).toBeTruthy();
} finally {
server.close();
}
});
});
+16 -1
View File
@@ -72,6 +72,9 @@ test.describe('Webhook — triggered on incoming mail', () => {
from: '${from}',
to: '${to}',
subject: '${subject}',
aiExtractType: '${aiExtractType}',
aiExtractResult: '${aiExtractResult}',
aiExtractResultText: '${aiExtractResultText}',
}),
},
});
@@ -93,7 +96,16 @@ test.describe('Webhook — triggered on incoming mail', () => {
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
data: {
from,
to: address,
raw,
ai_extract_result: {
type: 'auth_code',
result: '654321',
result_text: 'Login verification code',
},
},
});
expect(res.ok()).toBe(true);
@@ -106,6 +118,9 @@ test.describe('Webhook — triggered on incoming mail', () => {
expect(payload.from).toContain('webhook-sender@test.example.com');
expect(payload.to).toBe(address);
expect(payload.subject).toBe(subject);
expect(payload.aiExtractType).toBe('auth_code');
expect(payload.aiExtractResult).toBe('654321');
expect(payload.aiExtractResultText).toBe('Login verification code');
} finally {
server.close();
}
+4 -2
View File
@@ -132,8 +132,10 @@ test.describe('IMAP Proxy', () => {
try {
const results = await client.search({ all: true });
expect(results.length).toBeGreaterThan(0);
const msg = await client.fetchOne(String(results[0]), { uid: true, flags: true }, { uid: true });
expect(msg.uid).toBe(results[0]);
const seqMsg = await client.fetchOne(String(results[0]), { uid: true, flags: true });
expect(seqMsg.uid).toBeGreaterThan(0);
const uidMsg = await client.fetchOne(String(seqMsg.uid), { uid: true, flags: true }, { uid: true });
expect(uidMsg.uid).toBe(seqMsg.uid);
} finally {
lock.release();
}
+15 -15
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.8.0",
"version": "1.10.0",
"private": true,
"type": "module",
"scripts": {
@@ -23,37 +23,37 @@
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@simplewebauthn/browser": "13.2.2",
"@unhead/vue": "^2.1.13",
"@vueuse/core": "^14.2.1",
"@simplewebauthn/browser": "^13.3.0",
"@unhead/vue": "^2.1.15",
"@vueuse/core": "^14.3.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.15.1",
"dompurify": "^3.4.0",
"axios": "^1.18.1",
"dompurify": "^3.4.11",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.4",
"vooks": "^0.2.12",
"vue": "^3.5.32",
"vue": "^3.5.38",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.3.2",
"vue-i18n": "^11.4.6",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue": "^6.0.7",
"jsdom": "^28.1.0",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.3.2",
"vite-plugin-pwa": "^1.2.0",
"vite": "^7.3.5",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-wasm": "^3.6.0",
"vitest": "^3.2.4",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0",
"wrangler": "^4.83.0"
"vitest": "^4.1.9",
"workbox-build": "^7.4.1",
"workbox-window": "^7.4.1",
"wrangler": "^4.104.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+1726 -1780
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -129,6 +129,16 @@ onMounted(async () => {
margin-left: 10px;
margin-right: 10px;
}
@media (hover: none) and (pointer: coarse) and (max-width: 1024px) {
:where(input, textarea, select, [contenteditable="true"]) {
font-size: 16px !important;
}
:where(.n-input, .n-input-number, .n-base-selection, .n-input-group-label) {
--n-font-size: 16px !important;
}
}
</style>
<style scoped>
+3
View File
@@ -104,7 +104,10 @@ const getOpenSettings = async (message, notification) => {
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false,
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
enableAddressPassword: res["enableAddressPassword"] || false,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
});
@@ -0,0 +1,322 @@
<script setup>
import { computed } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../store'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
address: {
type: String,
default: '',
},
jwt: {
type: String,
default: '',
},
addressPassword: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:show'])
const { openSettings, auth } = useGlobalState()
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
const message = useMessage()
const modalShow = computed({
get: () => props.show,
set: (value) => emit('update:show', value),
})
const configuredApiBaseUrl = import.meta.env.VITE_API_BASE || ''
const frontendBaseUrl = computed(() => window.location.origin)
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
const securityLabel = computed(() =>
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
)
const agentConfigJson = computed(() => JSON.stringify({
base: apiBaseUrl.value,
jwt: props.jwt,
site_password: auth.value || '',
}, null, 2))
const agentText = computed(() => [
`${t('currentAddress')}: ${props.address || '-'}`,
`${t('apiBase')}: ${apiBaseUrl.value}`,
`${t('agentSkill')}: ${agentSkillUrl}`,
`${t('agentConfig')}:`,
agentConfigJson.value,
].join('\n'))
const smtpImapText = computed(() => [
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
`${t('security')}: ${securityLabel.value}`,
`${t('username')}: ${props.address || '-'}`,
`${t('password')}: ${props.jwt}`,
].join('\n'))
const copyText = async (text) => {
if (!text) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
message.success(t('copySuccess'))
return
}
const textarea = document.createElement('textarea')
try {
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
if (document.execCommand('copy')) {
message.success(t('copySuccess'))
return
}
message.error(t('copyFailed'))
} finally {
textarea.parentNode?.removeChild(textarea)
}
} catch (error) {
console.error(error)
message.error(t('copyFailed'))
}
}
</script>
<template>
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
style="width: min(760px, calc(100vw - 32px));">
<n-alert type="info" :show-icon="false" :bordered="false">
{{ t('tip') }}
</n-alert>
<section class="credential-panel">
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
<div class="credential-section">
<div class="credential-field" v-if="address">
<span class="credential-label">{{ t('currentAddress') }}</span>
<div class="credential-copy-row">
<code class="credential-code">{{ address }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
<div class="credential-copy-row">
<code class="credential-code">{{ jwt }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div class="credential-field" v-if="addressPassword">
<span class="credential-label">{{ t('addressPassword') }}</span>
<code class="credential-code">{{ addressPassword }}</code>
</div>
</div>
</section>
<n-collapse accordion class="credential-collapse">
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
<div class="credential-field">
<span class="credential-label">{{ t('apiBase') }}</span>
<code class="credential-code">{{ apiBaseUrl }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('agentSkill') }}</span>
<code class="credential-code">
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('agentConfig') }}</span>
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
</div>
<div class="credential-actions">
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
{{ t('docs') }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
<div class="credential-grid">
<div class="credential-field">
<span class="credential-label">{{ t('smtpHost') }}</span>
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('smtpPort') }}</span>
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('imapHost') }}</span>
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('imapPort') }}</span>
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
</div>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('security') }}</span>
<code class="credential-code">{{ securityLabel }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('username') }}</span>
<code class="credential-code">{{ address }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('password') }}</span>
<code class="credential-code">{{ jwt }}</code>
</div>
<div class="credential-actions">
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
{{ t('docs') }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<div class="credential-field">
<code class="credential-code">{{ autoLoginUrl }}</code>
</div>
</div>
</n-collapse-item>
</n-collapse>
</n-modal>
</template>
<style scoped>
.credential-collapse {
margin-top: 14px;
}
.credential-panel {
display: grid;
gap: 12px;
margin-top: 14px;
}
.credential-title {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
}
.credential-section {
display: grid;
gap: 12px;
text-align: left;
}
.credential-tip {
margin: 0;
color: var(--n-text-color-2);
line-height: 1.6;
}
.credential-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.credential-field {
display: grid;
gap: 6px;
min-width: 0;
}
.credential-label {
color: var(--n-text-color-2);
font-size: 12px;
font-weight: 600;
}
.credential-code {
display: block;
min-width: 0;
overflow-wrap: anywhere;
border-radius: 6px;
padding: 6px 8px;
background: var(--n-color-embedded);
font-size: 12px;
line-height: 1.5;
}
.credential-copy-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
}
.credential-code-block {
margin: 0;
white-space: pre-wrap;
}
.credential-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
@media (max-width: 640px) {
.credential-grid {
grid-template-columns: 1fr;
}
.credential-copy-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ref } from "vue";
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useScopedI18n } from '@/i18n/app'
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
@@ -8,10 +8,39 @@ import { getDownloadEmlUrl } from '../utils/email-parser';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteContent } = useGlobalState();
const { t } = useScopedI18n('components.MailContentRenderer')
const blockedRemoteContentPlaceholder = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80" viewBox="0 0 320 80">
<rect width="320" height="80" rx="8" fill="#f3f4f6"/>
<text x="160" y="45" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#6b7280">Remote content blocked</text>
</svg>
`)}`;
const hasRemoteUrl = (value: string | null) => /(?:^|,)\s*(?:https?:)?\/\//i.test(value || '');
const blockRemoteImages = (html?: string | null) => {
if (!html || typeof DOMParser !== 'function') return html || '';
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(html);
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const image of doc.querySelectorAll('img')) {
const src = image.getAttribute('src');
const srcset = image.getAttribute('srcset');
if (!hasRemoteUrl(src) && !hasRemoteUrl(srcset)) continue;
if (src) image.setAttribute('data-blocked-src', src);
if (srcset) image.setAttribute('data-blocked-srcset', srcset);
image.removeAttribute('srcset');
image.setAttribute('src', blockedRemoteContentPlaceholder);
}
return hasDocumentShell ? `<!doctype html>\n${doc.documentElement.outerHTML}` : doc.body.innerHTML;
};
const props = defineProps({
mail: {
type: Object,
@@ -57,6 +86,15 @@ const showAttachments = ref(false);
const curAttachments = ref([]);
const attachmentLoding = ref(false);
const showFullscreen = ref(false);
const loadRemoteContentForCurrentMail = ref(false);
const shouldLoadRemoteContent = computed(() => autoLoadRemoteContent.value || loadRemoteContentForCurrentMail.value);
const mailHtmlContent = computed(() => shouldLoadRemoteContent.value
? (props.mail.message || '')
: blockRemoteImages(props.mail.message));
watch(() => props.mail.id, () => {
loadRemoteContentForCurrentMail.value = false;
});
const handleDelete = () => {
props.onDelete();
@@ -149,6 +187,12 @@ const handleSaveToS3 = async (filename, blob) => {
</template>
{{ t('fullscreen') }}
</n-button>
<n-button v-if="!showTextMail && !shouldLoadRemoteContent" size="small" tertiary type="info"
@click="loadRemoteContentForCurrentMail = true">
{{ t('loadRemoteContent') }}
</n-button>
</n-space>
<!-- AI 提取信息 -->
@@ -157,9 +201,9 @@ const handleSaveToS3 = async (filename, blob) => {
<!-- 邮件内容 -->
<div class="mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
<iframe v-else-if="useIframeShowMail" :srcdoc="mailHtmlContent" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mailHtmlContent" :isDark="isDark" class="mail-html" />
</div>
</div>
@@ -168,9 +212,9 @@ const handleSaveToS3 = async (filename, blob) => {
<n-drawer-content :title="mail.subject" closable>
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
<iframe v-else-if="useIframeShowMail" :srcdoc="mailHtmlContent" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mailHtmlContent" :isDark="isDark" class="mail-html" />
</div>
</n-drawer-content>
</n-drawer>
+32 -3
View File
@@ -178,6 +178,7 @@ export const deMessages = {
"views.admin.SenderAccess.disable": "Deaktivieren",
"views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert",
"views.common.Appearance.preferShowTextMail": "Text-Mail standardmäßig anzeigen",
"views.common.Appearance.autoLoadRemoteContent": "Remote-Inhalte automatisch laden",
"views.admin.AccountSettings.domain_list": "Domain-Liste (optional)",
"views.admin.AccountSettings.source_patterns_tip": "Die Domain-Liste filtert nach Empfängeradresse, die Quell-Regex nach Absenderadresse. Für die Weiterleitung müssen beide Bedingungen erfüllt sein (UND-Logik). Leer lassen, um den jeweiligen Filter zu überspringen.",
"views.admin.UserManagement.domains": "Domains",
@@ -237,6 +238,7 @@ export const deMessages = {
"views.admin.AccountSettings.forward_address_required": "Eine Weiterleitungsadresse ist erforderlich",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Vollbild",
"components.MailContentRenderer.loadRemoteContent": "Remote-Inhalte laden",
"views.common.Login.generateName": "Zufälligen Namen erzeugen",
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
@@ -577,8 +579,8 @@ export const deMessages = {
"views.Admin.webhookSettings": "Webhook-Einstellungen",
"views.admin.AiExtractSettings.disabledTip": "Wenn deaktiviert, verarbeitet die KI-Extraktion alle E-Mail-Adressen",
"views.admin.AiExtractSettings.enableAllowListTip": "Wenn aktiviert, verarbeitet die KI-Extraktion nur E-Mails an Adressen auf der Freigabeliste",
"views.admin.CreateAccount.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Subdomain-Adressen werden nur für den Empfang empfohlen.",
"views.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Subdomain-Adressen werden nur für den Empfang empfohlen.",
"views.admin.CreateAccount.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
"views.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
"views.admin.AiExtractSettings.allowListTip": "Der Platzhalter * passt auf beliebige Zeichen; z. B. passt *{'@'}example.com auf alle Adressen der Domain example.com",
"views.Admin.workerconfig": "Worker-Konfiguration",
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "Die Worker-Umgebungsvariable ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH ist derzeit false. Der gespeicherte Admin-Schalter kann geändert werden, wird aber erst wirksam, wenn die Umgebungsvariable aktiviert oder entfernt wird.",
@@ -586,5 +588,32 @@ export const deMessages = {
"views.admin.AccountSettings.tip": "Die folgenden Mehrfachauswahlwerte können manuell eingegeben und mit Enter hinzugefügt werden",
"components.MailBox.emptyInbox": "Dein Posteingang ist leer",
"views.index.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
"views.admin.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden"
"views.admin.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
"components.AddressCredentialModal.addressCredential": "Adresszugangsdaten",
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
"components.AddressCredentialModal.addressPassword": "Adresspasswort",
"components.AddressCredentialModal.agentAccess": "AI Agent",
"components.AddressCredentialModal.agentAccessTip": "Verwende dieses Postfach in einem AI Agent mit dem Address JWT und den parsed-mail APIs.",
"components.AddressCredentialModal.agentConfig": "Agent-Konfiguration",
"components.AddressCredentialModal.agentSkill": "Agent skill",
"components.AddressCredentialModal.apiBase": "API-Basisadresse",
"components.AddressCredentialModal.autoLoginLink": "Auto-Login-Link",
"components.AddressCredentialModal.copyFailed": "Kopieren fehlgeschlagen",
"components.AddressCredentialModal.copySection": "Kopieren",
"components.AddressCredentialModal.copySuccess": "Kopiert",
"components.AddressCredentialModal.currentAddress": "Aktuelle Adresse",
"components.AddressCredentialModal.docs": "Dokumentation",
"components.AddressCredentialModal.imapHost": "IMAP-Host",
"components.AddressCredentialModal.imapPort": "IMAP-Port",
"components.AddressCredentialModal.password": "Passwort",
"components.AddressCredentialModal.plainOrProxyTls": "Klartext oder Proxy-TLS",
"components.AddressCredentialModal.security": "Sicherheit",
"components.AddressCredentialModal.smtpHost": "SMTP-Host",
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
"components.AddressCredentialModal.smtpImapTip": "Verwende diese Werte in Mail-Clients, nachdem der Administrator den SMTP/IMAP-Proxy konfiguriert hat. Als Passwort kannst du den hier angezeigten Address JWT oder ein vorhandenes Adresspasswort verwenden.",
"components.AddressCredentialModal.smtpPort": "SMTP-Port",
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
"components.AddressCredentialModal.username": "Benutzername"
}
+32 -3
View File
@@ -178,6 +178,7 @@ export const esMessages = {
"views.admin.SenderAccess.disable": "Deshabilitar",
"views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada",
"views.common.Appearance.preferShowTextMail": "Mostrar correo en texto por defecto",
"views.common.Appearance.autoLoadRemoteContent": "Cargar contenido remoto automáticamente",
"views.admin.AccountSettings.domain_list": "Lista de dominios (opcional)",
"views.admin.AccountSettings.source_patterns_tip": "La lista de dominios filtra por destinatario y la regex de origen por remitente. Ambas condiciones deben cumplirse para reenviar (lógica AND). Deja alguna vacía para omitirla.",
"views.admin.UserManagement.domains": "Dominios",
@@ -237,6 +238,7 @@ export const esMessages = {
"views.admin.AccountSettings.forward_address_required": "La dirección de reenvío es obligatoria",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Pantalla completa",
"components.MailContentRenderer.loadRemoteContent": "Cargar contenido remoto",
"views.common.Login.generateName": "Generar nombre aleatorio",
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
@@ -577,8 +579,8 @@ export const esMessages = {
"views.Admin.webhookSettings": "Configuración de webhook",
"views.admin.AiExtractSettings.disabledTip": "Si está desactivado, la extracción IA procesará todas las direcciones",
"views.admin.AiExtractSettings.enableAllowListTip": "Si está activado, la extracción IA solo procesará correos enviados a direcciones permitidas",
"views.admin.CreateAccount.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Se recomienda usarlo solo para recibir.",
"views.common.Login.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Se recomienda usarlo solo para recibir.",
"views.admin.CreateAccount.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
"views.common.Login.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
"views.admin.AiExtractSettings.allowListTip": "El comodín * coincide con cualquier carácter; p. ej., *{'@'}example.com coincide con todas las direcciones del dominio example.com",
"views.Admin.workerconfig": "Configuración del Worker",
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "La variable ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH está en false. Puedes guardar el interruptor, pero no tendrá efecto hasta habilitar o quitar la variable.",
@@ -586,5 +588,32 @@ export const esMessages = {
"views.admin.AccountSettings.tip": "Puedes introducir manualmente los siguientes valores y pulsar Enter para añadirlos",
"components.MailBox.emptyInbox": "Tu bandeja de entrada está vacía",
"views.index.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo",
"views.admin.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo"
"views.admin.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo",
"components.AddressCredentialModal.addressCredential": "Credencial de dirección",
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
"components.AddressCredentialModal.addressPassword": "Contraseña de la dirección",
"components.AddressCredentialModal.agentAccess": "AI Agent",
"components.AddressCredentialModal.agentAccessTip": "Usa este buzón desde un AI Agent con el Address JWT y las APIs parsed-mail.",
"components.AddressCredentialModal.agentConfig": "Configuración del agente",
"components.AddressCredentialModal.agentSkill": "Agent skill",
"components.AddressCredentialModal.apiBase": "Base de la API",
"components.AddressCredentialModal.autoLoginLink": "Enlace de inicio automático",
"components.AddressCredentialModal.copyFailed": "Error al copiar",
"components.AddressCredentialModal.copySection": "Copiar",
"components.AddressCredentialModal.copySuccess": "Copiado",
"components.AddressCredentialModal.currentAddress": "Dirección actual",
"components.AddressCredentialModal.docs": "Documentación",
"components.AddressCredentialModal.imapHost": "Host IMAP",
"components.AddressCredentialModal.imapPort": "Puerto IMAP",
"components.AddressCredentialModal.password": "Contraseña",
"components.AddressCredentialModal.plainOrProxyTls": "Texto plano o TLS del proxy",
"components.AddressCredentialModal.security": "Seguridad",
"components.AddressCredentialModal.smtpHost": "Host SMTP",
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
"components.AddressCredentialModal.smtpImapTip": "Usa estos valores en clientes de correo después de que el administrador configure el proxy SMTP/IMAP. Como contraseña puedes usar el Address JWT mostrado aquí o la contraseña de la dirección si la tienes.",
"components.AddressCredentialModal.smtpPort": "Puerto SMTP",
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
"components.AddressCredentialModal.username": "Usuario"
}
+32 -3
View File
@@ -178,6 +178,7 @@ export const jaMessages = {
"views.admin.SenderAccess.disable": "無効化",
"views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化",
"views.common.Appearance.preferShowTextMail": "既定でテキストメールを表示",
"views.common.Appearance.autoLoadRemoteContent": "リモートコンテンツを自動で読み込む",
"views.admin.AccountSettings.domain_list": "ドメイン一覧(任意)",
"views.admin.AccountSettings.source_patterns_tip": "ドメイン一覧は受信先アドレスで、送信元正規表現は送信者アドレスでフィルタします。転送には両方の条件を満たす必要があります(AND)。どちらかを空欄にするとその条件は無視されます。",
"views.admin.UserManagement.domains": "ドメイン",
@@ -237,6 +238,7 @@ export const jaMessages = {
"views.admin.AccountSettings.forward_address_required": "転送先アドレスは必須です",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "全画面",
"components.MailContentRenderer.loadRemoteContent": "リモートコンテンツを読み込む",
"views.common.Login.generateName": "ランダム名を生成",
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
"views.common.Appearance.globalTabplacement": "全体タブ位置",
@@ -577,8 +579,8 @@ export const jaMessages = {
"views.Admin.webhookSettings": "Webhook設定",
"views.admin.AiExtractSettings.disabledTip": "無効時は AI 抽出がすべてのメールアドレスを処理します",
"views.admin.AiExtractSettings.enableAllowListTip": "有効時は AI 抽出は許可リストのアドレス宛メールのみ処理します",
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。サブドメインアドレスは受信専用として推奨されます。",
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。サブドメインアドレスは受信専用として推奨されます。",
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.admin.AiExtractSettings.allowListTip": "ワイルドカード * は任意の文字に一致します。例: *{'@'}example.com は example.com ドメイン配下のすべてのアドレスに一致します",
"views.Admin.workerconfig": "Worker設定",
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "Worker 環境変数 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH は現在 false です。管理画面のスイッチは保存できますが、env を有効化または削除するまで反映されません。",
@@ -586,5 +588,32 @@ export const jaMessages = {
"views.admin.AccountSettings.tip": "以下の複数選択項目は手動入力して Enter で追加できます",
"components.MailBox.emptyInbox": "受信箱は空です",
"views.index.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します",
"views.admin.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します"
"views.admin.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します",
"components.AddressCredentialModal.addressCredential": "アドレス認証情報",
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
"components.AddressCredentialModal.addressPassword": "アドレスパスワード",
"components.AddressCredentialModal.agentAccess": "AI Agent",
"components.AddressCredentialModal.agentAccessTip": "AI Agent から Address JWT と parsed-mail API を使ってこのメールボックスを利用できます。",
"components.AddressCredentialModal.agentConfig": "Agent 設定",
"components.AddressCredentialModal.agentSkill": "Agent skill",
"components.AddressCredentialModal.apiBase": "API ベース",
"components.AddressCredentialModal.autoLoginLink": "自動ログインリンク",
"components.AddressCredentialModal.copyFailed": "コピーに失敗しました",
"components.AddressCredentialModal.copySection": "コピー",
"components.AddressCredentialModal.copySuccess": "コピーしました",
"components.AddressCredentialModal.currentAddress": "現在のアドレス",
"components.AddressCredentialModal.docs": "ドキュメント",
"components.AddressCredentialModal.imapHost": "IMAP ホスト",
"components.AddressCredentialModal.imapPort": "IMAP ポート",
"components.AddressCredentialModal.password": "パスワード",
"components.AddressCredentialModal.plainOrProxyTls": "平文またはプロキシ側 TLS",
"components.AddressCredentialModal.security": "セキュリティ",
"components.AddressCredentialModal.smtpHost": "SMTP ホスト",
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
"components.AddressCredentialModal.smtpImapTip": "管理者が SMTP/IMAP プロキシを設定した後、メールクライアントでこれらの値を使用できます。パスワードにはここに表示される Address JWT、または手元にあるアドレスパスワードを使用できます。",
"components.AddressCredentialModal.smtpPort": "SMTP ポート",
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
"components.AddressCredentialModal.username": "ユーザー名"
}
+32 -3
View File
@@ -178,6 +178,7 @@ export const ptBRMessages = {
"views.admin.SenderAccess.disable": "Desativar",
"views.Admin.loginViaDisabledCheck": "Verificação de senha desativada",
"views.common.Appearance.preferShowTextMail": "Exibir e-mail em texto por padrão",
"views.common.Appearance.autoLoadRemoteContent": "Carregar conteúdo remoto automaticamente",
"views.admin.AccountSettings.domain_list": "Lista de domínios (opcional)",
"views.admin.AccountSettings.source_patterns_tip": "A lista de domínios filtra pelo destinatário e o regex de origem filtra pelo remetente. Ambas as condições precisam corresponder para encaminhar (lógica AND). Deixe qualquer uma vazia para ignorá-la.",
"views.admin.UserManagement.domains": "Domínios",
@@ -237,6 +238,7 @@ export const ptBRMessages = {
"views.admin.AccountSettings.forward_address_required": "O endereço de encaminhamento é obrigatório",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Tela cheia",
"components.MailContentRenderer.loadRemoteContent": "Carregar conteúdo remoto",
"views.common.Login.generateName": "Gerar nome aleatório",
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
"views.common.Appearance.globalTabplacement": "Posição global das abas",
@@ -577,8 +579,8 @@ export const ptBRMessages = {
"views.Admin.webhookSettings": "Configurações de webhook",
"views.admin.AiExtractSettings.disabledTip": "Quando desativado, a extração por IA processará todos os endereços",
"views.admin.AiExtractSettings.enableAllowListTip": "Quando ativado, a extração por IA só processará e-mails enviados aos endereços permitidos",
"views.admin.CreateAccount.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Endereços com subdomínio são recomendados apenas para recebimento.",
"views.common.Login.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Endereços com subdomínio são recomendados apenas para recebimento.",
"views.admin.CreateAccount.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
"views.common.Login.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
"views.admin.AiExtractSettings.allowListTip": "O curinga * corresponde a quaisquer caracteres; ex.: *{'@'}example.com corresponde a todos os endereços do domínio example.com",
"views.Admin.workerconfig": "Configuração do Worker",
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "A variável ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH está em false. O botão salvo pode ser alterado, mas só terá efeito quando a variável for ativada ou removida.",
@@ -586,5 +588,32 @@ export const ptBRMessages = {
"views.admin.AccountSettings.tip": "Você pode inserir manualmente os seguintes valores e pressionar Enter para adicioná-los",
"components.MailBox.emptyInbox": "Sua caixa de entrada está vazia",
"views.index.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail",
"views.admin.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail"
"views.admin.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail",
"components.AddressCredentialModal.addressCredential": "Credencial do endereço",
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
"components.AddressCredentialModal.addressPassword": "Senha do endereço",
"components.AddressCredentialModal.agentAccess": "AI Agent",
"components.AddressCredentialModal.agentAccessTip": "Use esta caixa de entrada em um AI Agent com o Address JWT e as APIs parsed-mail.",
"components.AddressCredentialModal.agentConfig": "Configuração do Agent",
"components.AddressCredentialModal.agentSkill": "Agent skill",
"components.AddressCredentialModal.apiBase": "Base da API",
"components.AddressCredentialModal.autoLoginLink": "Link de login automático",
"components.AddressCredentialModal.copyFailed": "Falha ao copiar",
"components.AddressCredentialModal.copySection": "Copiar",
"components.AddressCredentialModal.copySuccess": "Copiado",
"components.AddressCredentialModal.currentAddress": "Endereço atual",
"components.AddressCredentialModal.docs": "Documentação",
"components.AddressCredentialModal.imapHost": "Host IMAP",
"components.AddressCredentialModal.imapPort": "Porta IMAP",
"components.AddressCredentialModal.password": "Senha",
"components.AddressCredentialModal.plainOrProxyTls": "Texto puro ou TLS do proxy",
"components.AddressCredentialModal.security": "Segurança",
"components.AddressCredentialModal.smtpHost": "Host SMTP",
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
"components.AddressCredentialModal.smtpImapTip": "Use estes valores em clientes de e-mail depois que o administrador configurar o proxy SMTP/IMAP. Como senha, use o Address JWT mostrado aqui ou a senha do endereço quando você a tiver.",
"components.AddressCredentialModal.smtpPort": "Porta SMTP",
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
"components.AddressCredentialModal.username": "Nome de usuário"
}
+124 -6
View File
@@ -186,6 +186,10 @@ export const MESSAGE_REGISTRY = {
"en": "Fullscreen",
"zh": "全屏"
},
"loadRemoteContent": {
"en": "Load Remote Content",
"zh": "加载远程内容"
},
"reply": {
"en": "Reply",
"zh": "回复"
@@ -281,6 +285,116 @@ export const MESSAGE_REGISTRY = {
"zh": "用户地址"
}
},
"components.AddressCredentialModal": {
"addressCredential": {
"en": "Address Credential",
"zh": "地址凭证"
},
"addressCredentialLabel": {
"en": "Address JWT",
"zh": "Address JWT"
},
"addressPassword": {
"en": "Address Password",
"zh": "地址密码"
},
"agentAccess": {
"en": "AI Agent",
"zh": "AI Agent"
},
"agentAccessTip": {
"en": "Use this mailbox from an AI agent with the Address JWT and parsed-mail APIs.",
"zh": "AI Agent 可使用 Address JWT 和 parsed-mail API 读取这个邮箱。"
},
"agentConfig": {
"en": "Agent config",
"zh": "Agent 配置"
},
"agentSkill": {
"en": "Agent skill",
"zh": "Agent skill"
},
"apiBase": {
"en": "API Base",
"zh": "API 地址"
},
"autoLoginLink": {
"en": "Auto-login link",
"zh": "自动登录链接"
},
"copyFailed": {
"en": "Copy failed",
"zh": "复制失败"
},
"copySection": {
"en": "Copy",
"zh": "复制"
},
"copySuccess": {
"en": "Copied",
"zh": "已复制"
},
"currentAddress": {
"en": "Current address",
"zh": "当前邮箱"
},
"docs": {
"en": "Docs",
"zh": "文档"
},
"imapHost": {
"en": "IMAP host",
"zh": "IMAP 主机"
},
"imapPort": {
"en": "IMAP port",
"zh": "IMAP 端口"
},
"password": {
"en": "Password",
"zh": "密码"
},
"plainOrProxyTls": {
"en": "Plain or proxy TLS",
"zh": "明文或代理层 TLS"
},
"security": {
"en": "Security",
"zh": "安全"
},
"smtpHost": {
"en": "SMTP host",
"zh": "SMTP 主机"
},
"smtpImapAccess": {
"en": "SMTP / IMAP",
"zh": "SMTP / IMAP"
},
"smtpImapTip": {
"en": "Use these values in mail clients after the administrator configures the SMTP/IMAP proxy. The password can be the Address JWT shown here, or the address password when you have it.",
"zh": "管理员配置 SMTP/IMAP 代理后,可在邮件客户端中使用这些信息。密码可使用这里展示的 Address JWT,也可使用你持有的地址密码。"
},
"smtpPort": {
"en": "SMTP port",
"zh": "SMTP 端口"
},
"starttls": {
"en": "STARTTLS",
"zh": "STARTTLS"
},
"tip": {
"en": "Use these credentials only with clients and agents you trust.",
"zh": "请只在可信的客户端和 Agent 中使用这些凭证。"
},
"title": {
"en": "Address Credentials & Connection Methods",
"zh": "地址凭证与连接方式"
},
"username": {
"en": "Username",
"zh": "用户名"
}
},
"views.user.UserMailBox": {
"addressQueryTip": {
"en": "Leave blank to query all addresses",
@@ -817,8 +931,8 @@ export const MESSAGE_REGISTRY = {
"zh": "密码不匹配"
},
"showAddressCredential": {
"en": "Show Address Credential",
"zh": "查看邮箱地址凭证"
"en": "Credentials & Connection Methods",
"zh": "地址凭证与连接方式"
},
"success": {
"en": "Success",
@@ -1891,8 +2005,8 @@ export const MESSAGE_REGISTRY = {
"zh": "打开即可自动登录邮箱的链接"
},
"randomSubdomainTip": {
"en": "When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.",
"zh": "启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。"
"en": "When enabled, the created address will use a random subdomain. Recommended for receiving only. Requires a wildcard MX DNS record on the base domain — see the random subdomain docs.",
"zh": "启用后,创建出来的地址会自动挂在随机子域名下,建议仅用于收件。需要在基础域名 DNS 中配置通配 MX 记录,详见随机子域名文档。"
},
"successTip": {
"en": "Success Created",
@@ -2004,6 +2118,10 @@ export const MESSAGE_REGISTRY = {
"en": "Display text Mail by default",
"zh": "默认以文本显示邮件"
},
"autoLoadRemoteContent": {
"en": "Automatically load remote content",
"zh": "自动加载远程内容"
},
"right": {
"en": "right",
"zh": "右侧"
@@ -2413,8 +2531,8 @@ export const MESSAGE_REGISTRY = {
"zh": "请\"登录\"或点击 \"注册新邮箱\" 按钮来获取一个新的邮箱地址"
},
"randomSubdomainTip": {
"en": "When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.",
"zh": "启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。"
"en": "When enabled, the created address will use a random subdomain. Recommended for receiving only. Requires a wildcard MX DNS record on the base domain — see the random subdomain docs.",
"zh": "启用后,创建出来的地址会自动挂在随机子域名下,建议仅用于收件。需要在基础域名 DNS 中配置通配 MX 记录,详见随机子域名文档。"
}
},
"views.admin.Webhook": {
+14 -2
View File
@@ -61,8 +61,20 @@ router.beforeEach((to, from, next) => {
preferredLocale.value = getPreferredLocale('', getBrowserLocales())
}
if (to.query.jwt) {
jwt.value = to.query.jwt
if (Object.prototype.hasOwnProperty.call(to.query, 'jwt')) {
const jwtQuery = Array.isArray(to.query.jwt) ? to.query.jwt[0] : to.query.jwt
if (typeof jwtQuery === 'string') {
jwt.value = jwtQuery
}
const query = { ...to.query }
delete query.jwt
next({
path: to.path,
query,
hash: to.hash,
replace: true,
})
return
}
if (routeLocale) {
+16
View File
@@ -38,8 +38,22 @@ export const useGlobalState = createGlobalState(
isS3Enabled: false,
enableSendMail: false,
showGithub: true,
showGithubForUser: true,
disableAdminPasswordCheck: false,
enableAddressPassword: false,
enableAgentEmailInfo: false,
smtpImapProxyConfig: {
smtp: {
host: '',
port: 8025,
starttls: false,
},
imap: {
host: '',
port: 11143,
starttls: false,
},
},
statusUrl: '',
enableGlobalTurnstileCheck: false,
})
@@ -76,6 +90,7 @@ export const useGlobalState = createGlobalState(
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false);
const autoLoadRemoteContent = useStorage('autoLoadRemoteContent', false);
const userJwt = useStorage('userJwt', '');
const preferredLocale = useStorage('preferredLocale', '');
const userTab = useSessionStorage('userTab', 'address_management');
@@ -148,6 +163,7 @@ export const useGlobalState = createGlobalState(
mailboxSplitSize,
useIframeShowMail,
preferShowTextMail,
autoLoadRemoteContent,
userJwt,
preferredLocale,
userTab,
+1 -1
View File
@@ -110,7 +110,7 @@ onMounted(async () => {
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" @keyup.enter="authFunc" />
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
<template #action>
<n-button @click="authFunc" type="primary" :loading="loading">
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup>
import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../store'
import DOMPurify from 'dompurify'
const { openSettings } = useGlobalState()
@@ -17,7 +18,7 @@ const { t } = useScopedI18n('views.Footer')
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }}
</n-text>
<n-text depth="3">
<div v-html="openSettings.copyright"></div>
<div v-html="DOMPurify.sanitize(openSettings.copyright)"></div>
</n-text>
</n-space>
</div>
+12 -3
View File
@@ -103,6 +103,11 @@ const changeLocale = async (lang) => {
}
const version = import.meta.env.PACKAGE_VERSION ? `v${import.meta.env.PACKAGE_VERSION}` : "";
const showGithubForCurrentUser = computed(() => {
if (!openSettings.value.showGithub) return false;
if (openSettings.value.showGithubForUser) return true;
return showAdminPage.value;
});
const menuOptions = computed(() => [
{
@@ -270,7 +275,7 @@ onMounted(async () => {
</n-button>
</n-dropdown>
<n-button
v-if="!isMobile && openSettings.showGithub"
v-if="!isMobile && showGithubForCurrentUser"
text
size="small"
class="header-version-button"
@@ -298,7 +303,7 @@ onMounted(async () => {
</button>
</n-dropdown>
<a
v-if="openSettings.showGithub"
v-if="showGithubForCurrentUser"
class="mobile-menu-utility-button"
target="_blank"
rel="noopener noreferrer"
@@ -314,7 +319,7 @@ onMounted(async () => {
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="password" show-password-on="click" />
<n-input v-model:value="auth" type="password" show-password-on="click" @keyup.enter="authFunc" />
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
<template #action>
<n-button :loading="loading" @click="authFunc" type="primary">
@@ -430,6 +435,10 @@ onMounted(async () => {
}
@media (max-width: 640px) {
:deep(.n-page-header) {
padding: 10px;
}
:deep(.n-page-header__title) {
min-width: 0;
}
+18 -18
View File
@@ -5,8 +5,10 @@ import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { hashPassword } from '../../utils'
import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const {
loading, adminTab, openSettings,
@@ -18,6 +20,7 @@ const { t } = useScopedI18n('views.admin.Account')
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curEmailAddress = ref("")
const curDeleteAddressId = ref(0);
const curClearInboxAddressId = ref(0);
const curClearSentItemsAddressId = ref(0);
@@ -46,14 +49,16 @@ const showDeleteAccount = ref(false)
const showClearInbox = ref(false)
const showClearSentItems = ref(false)
const showCredential = async (id) => {
const showCredential = async (row) => {
try {
curEmailCredential.value = await api.adminShowAddressCredential(id)
curEmailAddress.value = row.name
curEmailCredential.value = await api.adminShowAddressCredential(row.id)
showEmailCredential.value = true
} catch (error) {
message.error(error.message || "error");
showEmailCredential.value = false
curEmailCredential.value = ""
curEmailAddress.value = ""
}
}
@@ -98,11 +103,16 @@ const clearSentItems = async () => {
}
const resetPassword = async () => {
const normalizedPassword = newPassword.value.trim()
if (!normalizedPassword) {
message.error(t("newPassword"));
return;
}
try {
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
method: 'POST',
body: JSON.stringify({
password: newPassword.value
password: await hashPassword(normalizedPassword)
})
});
message.success(t("passwordResetSuccess"));
@@ -365,7 +375,7 @@ const columns = computed(() => [
label: () => h(NButton,
{
text: true,
onClick: () => showCredential(row.id)
onClick: () => showCredential(row)
},
{ default: () => t('showCredential') }
),
@@ -467,19 +477,8 @@ onMounted(async () => {
<template>
<div style="margin-top: 10px;">
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("addressCredential") }}</div>
</template>
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card :bordered="false" embedded>
<b>{{ curEmailCredential }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
:jwt="curEmailCredential" />
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
@@ -507,7 +506,8 @@ onMounted(async () => {
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click"
@keyup.enter="resetPassword" />
</n-form-item>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">
+3 -25
View File
@@ -4,6 +4,7 @@ import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const {
loading, openSettings,
@@ -59,10 +60,6 @@ const newEmail = async () => {
}
}
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${result.value}`
}
onMounted(async () => {
if (openSettings.prefix) {
enablePrefix.value = true
@@ -73,27 +70,8 @@ onMounted(async () => {
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ result }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ createdAddress }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<AddressCredentialModal v-model:show="showReultModal" :address="createdAddress" :jwt="result"
:address-password="addressPassword" />
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-switch v-model:value="enablePrefix" :round="false" />
+4 -2
View File
@@ -304,7 +304,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
<n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="createUser" />
</n-form-item-row>
</n-form>
<template #action>
@@ -315,7 +316,8 @@ onMounted(async () => {
</n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="newResetPassword" type="password" show-password-on="click" />
<n-input v-model:value="newResetPassword" type="password" show-password-on="click"
@keyup.enter="resetPassword" />
</n-form-item-row>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary">
+4 -1
View File
@@ -12,7 +12,7 @@ const props = defineProps({
const {
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteContent
} = useGlobalState()
const isMobile = useIsMobile()
@@ -43,6 +43,9 @@ const { t } = useScopedI18n('views.common.Appearance')
<n-form-item-row :label="t('useIframeShowMail')">
<n-switch v-model:value="useIframeShowMail" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('autoLoadRemoteContent')">
<n-switch v-model:value="autoLoadRemoteContent" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('useUTCDate')">
<n-switch v-model:value="useUTCDate" :round="false" />
</n-form-item-row>
+2 -1
View File
@@ -260,7 +260,8 @@ onMounted(async () => {
<n-input v-model:value="loginAddress" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="loginPassword" type="password" show-password-on="click" />
<n-input v-model:value="loginPassword" type="password" show-password-on="click"
@keyup.enter="login" />
</n-form-item-row>
</div>
+10 -6
View File
@@ -92,7 +92,7 @@ const changePassword = async () => {
<template>
<div class="center" v-if="settings.address">
<n-card :bordered="false" embedded>
<n-card :bordered="false" embedded class="account-card">
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
@@ -110,11 +110,13 @@ const changePassword = async () => {
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
<n-divider v-if="openSettings.enableUserDeleteEmail" />
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
block strong>
{{ t('deleteAccount') }}
</n-button>
</n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
@@ -151,10 +153,12 @@ const changePassword = async () => {
<n-modal v-model:show="showChangePassword" preset="dialog" :title="t('changePassword')">
<n-form :model="{ newPassword, confirmPassword }">
<n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click"
@keyup.enter="changePassword" />
</n-form-item>
<n-form-item :label="t('confirmPassword')">
<n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click" />
<n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click"
@keyup.enter="changePassword" />
</n-form-item>
</n-form>
<template #action>
@@ -172,13 +176,13 @@ const changePassword = async () => {
justify-content: center;
}
.n-card {
.account-card {
max-width: 800px;
text-align: left;
}
.n-button {
margin-top: 10px;
margin-top: 14px;
}
</style>
+3 -25
View File
@@ -12,6 +12,7 @@ import LocalAddress from './LocalAddress.vue'
import AddressManagement from '../user/AddressManagement.vue'
import { getRouterPathWithLang } from '../../utils'
import AddressSelect from '../../components/AddressSelect.vue'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const router = useRouter()
@@ -24,10 +25,6 @@ const { locale, t } = useScopedI18n('views.index.AddressBar')
const showAddressManage = ref(false)
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${jwt.value}`
}
const onUserLogin = async () => {
await router.push(getRouterPathWithLang("/user", locale.value))
}
@@ -78,27 +75,8 @@ onMounted(async () => {
</n-button>
</n-card>
</div>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ jwt }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ settings.address }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<AddressCredentialModal v-model:show="showAddressCredential" :address="settings.address" :jwt="jwt"
:address-password="addressPassword" />
<n-modal v-model:show="showAddressManage" preset="card" :title="t('addressManage')"
style="width: 720px;">
<TelegramAddress v-if="isTelegram" />
+6 -3
View File
@@ -187,7 +187,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
<n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailLogin" />
</n-form-item-row>
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" />
<n-button @click="emailLogin" type="primary" block secondary strong>
@@ -218,7 +219,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
<n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailSignup" />
</n-form-item-row>
<Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
@@ -244,7 +246,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
<n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailSignup" />
</n-form-item-row>
<Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" />
<n-form-item-row :label="t('verifyCode')" required>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "temp-email-pages",
"version": "1.8.0",
"version": "1.10.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -11,7 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^4.83.0"
"wrangler": "^4.104.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+2 -2
View File
@@ -1,6 +1,6 @@
aiosmtpd==1.4.6
pydantic-settings==2.13.1
Twisted==25.5.0
pydantic-settings==2.14.2
Twisted==26.4.0
httpx==0.28.1
pyOpenSSL==26.0.0
service-identity==24.2.0
+1
View File
@@ -123,6 +123,7 @@ ENABLE_AUTO_REPLY = false
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # Disable Show GitHub link
# DISABLE_SHOW_GITHUB_FOR_USER = true # Hide GitHub link for normal users only
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# the role which can send emails without limit, multiple roles can be separated by ,
@@ -48,6 +48,12 @@ No additional env var is required.
Register at `https://resend.com/domains` and add DNS records according to the instructions.
> [!WARNING] DNS record proxy status on Cloudflare
> Resend domain verification CNAME records **must be set to DNS-only** (gray cloud) in
> the Cloudflare DNS dashboard. Proxied (orange cloud) records will prevent Resend from
> completing verification, and a single failed attempt can take several hours before
> Retry becomes available. See [#515](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515).
Create an `api key` on the `API KEYS` page.
Then execute the following command to add `RESEND_TOKEN` to secrets:
@@ -22,7 +22,9 @@ Extraction results are automatically saved to the `metadata` field in the databa
| Variable Name | Type | Description | Example |
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable AI email recognition feature | `true` |
| `AI_EXTRACT_MODEL` | Text | AI model name, choose from [models supporting JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models) | `@cf/meta/llama-3.1-8b-instruct` |
| `AI_EXTRACT_MODEL` | Text | AI model name, choose from [models supporting JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models) | `@cf/meta/llama-3.1-8b-instruct-fast` |
We recommend `@cf/meta/llama-3.1-8b-instruct-fast` as the default model because it supports the JSON Mode used by this feature, and Cloudflare says `-fast` variants will remain active. The cheaper `@cf/meta/llama-3.1-8b-instruct-fp8-fast` is not currently listed as a JSON Mode supported model, so it is not recommended for this feature. Cloudflare's newer recommended model `@cf/zai-org/glm-4.7-flash` is suitable for multilingual scenarios, but confirm structured JSON output support in your account/region before using it for this feature. The previous default model `@cf/meta/llama-3.1-8b-instruct` will be deprecated by Cloudflare on 2026-05-30 and is no longer recommended.
## Content Length Limit
@@ -41,6 +43,18 @@ Or add in Cloudflare Dashboard Worker settings:
- **Variable name**: `AI`
- **Type**: Workers AI
## Fallback Without a Workers AI Binding
If `ENABLE_AI_EMAIL_EXTRACT` is enabled but **no Workers AI binding is configured** (e.g. a self-hosted deployment without Workers AI), the system automatically falls back to a built-in **regex verification-code extractor**:
- Extracts **verification codes** (`auth_code`) only; links are not extracted (link extraction requires AI)
- Zero dependency, zero cost, runs locally inside the Worker
- Supports common verification-code formats in English, Chinese, Japanese and Korean
- Rejects years (e.g. `2026`) and `YYYYMMDD` dates to reduce false positives
- Results are written to `metadata` and reuse the same Telegram / webhook placeholders (`aiExtractType` is `auth_code` in this case)
When a Workers AI binding is configured, AI extraction is still preferred (recognizing both codes and links) and this fallback does not apply.
## Address Allowlist (Optional)
To control costs and resource usage, you can configure an address allowlist in the Admin console's **AI Extract Settings** page:
@@ -52,9 +52,30 @@ If you want to create an address under a specific subdomain such as `team.abc.co
>
> There is currently no backend switch that globally forces random subdomains; API calls that do
> not pass `enableRandomSubdomain: true` will not randomize automatically.
> [!IMPORTANT] A wildcard MX DNS record is required for random subdomains
> The worker only generates addresses like `name@<random>.abc.com` — **whether mail actually
> arrives depends entirely on DNS / Email Routing being configured for `*.abc.com`**, and
> Cloudflare Email Routing does **not** propagate the base-domain configuration onto subdomains.
> See Cloudflare's [Email Routing — Subdomains](https://developers.cloudflare.com/email-routing/setup/subdomains/)
> documentation.
>
> It does not automatically create Cloudflare-side subdomain mail routes or DNS records for you,
> so make sure the base-domain/subdomain routing is already available first.
> There are two ways to make `*.abc.com` deliverable:
>
> 1. **DNS-only wildcard MX (simplest workaround)** — In your DNS, copy **every existing MX
> record on `abc.com`** to host `*`, preserving each record's **priority and target value**.
> This makes `*.abc.com` resolve to the same MX targets as the apex, so mail flows into the
> same Cloudflare Email Routing zone and is picked up by the apex Catch-all rule that points
> at the Worker. No extra Cloudflare-side action is needed.
> 2. **Cloudflare dashboard "Add subdomain"** — Add the specific subdomain in the Email Routing
> dashboard and configure its DNS + Catch-all routing rule separately. This only covers the
> specific subdomain you add, so it is not suitable for arbitrary random subdomains.
>
> For random subdomain mailboxes, option **(1)** is the recommended path. Without it, the
> random subdomain feature will appear to work in the UI but inbound mail will never reach the
> Worker.
>
> Reference issue: [#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035)
## Let APIs Specify Subdomains Directly
@@ -111,5 +111,10 @@ To get the url, you need to configure the worker's `FRONTEND_URL` to your fronte
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
"aiExtractType": "${aiExtractType}",
"aiExtractResult": "${aiExtractResult}",
"aiExtractResultText": "${aiExtractResultText}",
}
```
When AI email extraction is enabled, webhook templates can use the `aiExtractType`, `aiExtractResult`, and `aiExtractResultText` placeholders. They are empty strings when no extraction result is available.
+27 -2
View File
@@ -43,12 +43,25 @@
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance. When greater than `0`, it is auto-initialized when users open the settings page or send mail for the first time. Defaults to `0` if unset | `1` |
| `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` |
| `ENABLE_AGENT_EMAIL_INFO` | Text/JSON | Whether to show AI Agent access info in the frontend "Address Credentials & Connection Methods" dialog (Address JWT, parsed-mail APIs, skill link) | `true` |
| `SMTP_IMAP_PROXY_CONFIG` | JSON | Show SMTP/IMAP proxy connection info in the frontend "Address Credentials & Connection Methods" dialog; display-only, does not start the proxy service, which must be deployed separately | See example below |
| `SEND_MAIL_DOMAINS` | JSON | Restrict which sender domains can use the `SEND_MAIL` binding; when unset or empty, all domains are allowed | `["example.com", "mail.example.com"]` |
> [!NOTE]
> When `DEFAULT_DOMAINS` is unset or configured as an empty array, it falls back to `DOMAINS`.
>
> `RANDOM_SUBDOMAIN_DOMAINS` only controls automatic random subdomain generation during mailbox
> creation. It does not create Cloudflare-side subdomain routing for you.
>
> To actually receive mail on addresses like `name@<random>.abc.com`, **you must add a wildcard
> `*` MX record under the base domain in DNS** by copying the apex's existing MX records to
> host `*` (preserving each record's priority and target). Cloudflare Email Routing does not
> inherit the apex configuration onto subdomains — see the
> [Cloudflare Email Routing — Subdomains](https://developers.cloudflare.com/email-routing/setup/subdomains/)
> docs,
> [#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035) and
> [Configure Subdomain Email](/en/guide/feature/subdomain).
>
> Subdomain addresses are usually best used for receiving only; for sending, prefer the main
> domain.
>
@@ -66,6 +79,17 @@
>
> `SEND_MAIL_DOMAINS` only affects the `SEND_MAIL` binding fallback path and
> `/admin/send_mail_by_binding`. It does not affect Resend, SMTP, or `verifiedAddressList`.
>
> `SMTP_IMAP_PROXY_CONFIG` example:
>
> ```json
> {
> "smtp": { "host": "smtp.example.com", "port": 8025, "starttls": true },
> "imap": { "host": "imap.example.com", "port": 11143, "starttls": true }
> }
> ```
>
> SMTP and IMAP can use different hostnames, which is useful for reverse proxies or separate port mappings.
## Email Reception Related Variables
@@ -115,7 +139,7 @@
> [!NOTE] USER_ROLES User Role Configuration
>
> - If `domains` is empty, `DEFAULT_DOMAINS` will be used
> - If `domains` is empty, `DEFAULT_DOMAINS` will be used; if `DEFAULT_DOMAINS` is also empty, it falls back to `DOMAINS`
> - If prefix is null, the default prefix will be used, if prefix is an empty string, no prefix will be used
>
> When deploying through UI, configure `USER_ROLES` in this format: `[{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"vip","prefix":"vip"},{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"admin","prefix":""}]`
@@ -132,7 +156,8 @@
| `ALWAYS_SHOW_ANNOUNCEMENT` | Text/JSON | Whether to always show announcement (even if unchanged), default `false` | `true` |
| `COPYRIGHT` | Text | Custom frontend footer text, supports html | `Dream Hunter` |
| `ADMIN_CONTACT` | Text | Admin contact information, can be any string, hidden if not configured | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | Text/JSON | Whether to show GitHub link | `true` |
| `DISABLE_SHOW_GITHUB` | Text/JSON | Globally hide the GitHub link | `true` |
| `DISABLE_SHOW_GITHUB_FOR_USER` | Text/JSON | Hide the GitHub link for normal users while keeping it visible to admin users | `true` |
| `STATUS_URL` | Text | Status monitoring page URL, shows Status menu button when configured | `https://status.example.com` |
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
@@ -1,4 +1,4 @@
# Gihub Actions 部署准备
# GitHub Actions 部署准备
## GitHub 账户
@@ -48,6 +48,12 @@ send_email = [
注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
> [!WARNING] Cloudflare 上 DNS 记录的代理状态
> Resend 域名验证的 CNAME 记录**必须设置为 仅 DNS**(灰云),在
> Cloudflare DNS 控制面板中代理(橙云)记录会阻止 Resend
> 完成验证,且一次失败的尝试可能需要数小时才能重试。
> 参见 [#515](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515)。
`API KEYS` 页面创建 `api key`
然后执行下面的命令,将 `RESEND_TOKEN` 添加到 secrets 中
@@ -22,7 +22,9 @@ AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内
| 变量名 | 类型 | 说明 | 示例 |
| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
| `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用 AI 邮件识别功能 | `true` |
| `AI_EXTRACT_MODEL` | 文本 | AI 模型名称,从[支持 JSON 模式的模型](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models)中选择 | `@cf/meta/llama-3.1-8b-instruct` |
| `AI_EXTRACT_MODEL` | 文本 | AI 模型名称,从[支持 JSON 模式的模型](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models)中选择 | `@cf/meta/llama-3.1-8b-instruct-fast` |
推荐使用 `@cf/meta/llama-3.1-8b-instruct-fast` 作为默认模型,它支持当前实现依赖的 JSON Mode,且 Cloudflare 说明 `-fast` 变体会保持可用。价格更低的 `@cf/meta/llama-3.1-8b-instruct-fp8-fast` 目前不在 JSON Mode 支持列表中,不建议用于本功能。Cloudflare 推荐的新模型 `@cf/zai-org/glm-4.7-flash` 适合多语言场景,但用于本功能前请先确认它在你的账号/区域支持结构化 JSON 输出。旧默认模型 `@cf/meta/llama-3.1-8b-instruct` 将于 2026-05-30 被 Cloudflare 弃用,不建议继续使用。
## 内容长度限制
@@ -41,6 +43,18 @@ binding = "AI"
- **Variable name**: `AI`
- **Type**: Workers AI
## 无 Workers AI 绑定时的正则兜底
如果启用了 `ENABLE_AI_EMAIL_EXTRACT` 但**没有配置 Workers AI 绑定**(例如自部署时未开通 Workers AI),系统会自动回退到内置的**正则验证码提取**:
- 仅提取**验证码**`auth_code`),不提取链接(链接提取依赖 AI
- 零依赖、零成本,在 Worker 内本地完成
- 支持中文、英文、日文、韩文常见验证码格式
- 自动排除年份(如 `2026`)与 `YYYYMMDD` 日期,降低误判
- 提取结果同样写入 `metadata`,并复用 Telegram 推送与 Webhook 占位符(此时 `aiExtractType``auth_code`
当配置了 Workers AI 绑定时,仍优先使用 AI 提取(可识别验证码与各类链接),不受此回退影响。
## 地址白名单(可选)
为了控制成本和资源使用,可以在 Admin 控制台的 **AI 提取设置** 页面配置地址白名单:
@@ -45,8 +45,18 @@ RANDOM_SUBDOMAIN_LENGTH = 8
> 这个功能只是在“创建地址”时自动补一个随机二级域名。
>
> 当前没有“全局强制随机二级域名”的后端开关;未传 `enableRandomSubdomain: true` 的 API 调用不会自动随机。
> [!IMPORTANT] 随机子域名需要 DNS 通配 MX 记录
> 程序只负责生成 `name@<random>.abc.com` 这样的地址,**能不能收到邮件完全取决于 DNS / Email Routing 是否覆盖到 `*.abc.com`**Cloudflare Email Routing 的子域名配置**不会**从基础域名继承。可参考 Cloudflare 的 [Email Routing — Subdomains](https://developers.cloudflare.com/email-routing/setup/subdomains/) 文档。
>
> 它不会自动帮你创建 Cloudflare 侧的子域名收件路由或 DNS 配置,请先确保基础域名/子域名路由本身已经可用。
> `*.abc.com` 可投递有两种做法:
>
> 1. **DNS 通配 MX 记录(最简方案)** — 在 `abc.com` 的 DNS 中,把基础域名上**现有的每一条 MX 记录**都复制一份到 `*` 主机名上,**保留每条记录的 priority 与 target**。这样 `*.abc.com` 会解析到与 apex 相同的 MX 目标,邮件流向同一个 Cloudflare Email Routing zone,并由 apex 的 Catch-all 规则交给 Worker**Cloudflare 侧不需要额外配置**。
> 2. **Cloudflare 控制台 “Add subdomain”** — 在 Email Routing 后台显式添加该子域名,并单独配置其 DNS 与 Catch-all 路由规则。这只覆盖你添加的那个具体子域名,不适合随机生成的任意子域名。
>
> 随机子域名场景下推荐使用 **方案 (1)**。如果不做这一步,随机子域名功能在前端看起来正常,但实际邮件不会进入 Worker。
>
> 参考 issue[#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035)
## 允许 API 直接指定子域名
@@ -111,5 +111,10 @@
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
"aiExtractType": "${aiExtractType}",
"aiExtractResult": "${aiExtractResult}",
"aiExtractResultText": "${aiExtractResultText}",
}
```
启用 AI 邮件内容提取后,Webhook 模板可使用 `aiExtractType``aiExtractResult``aiExtractResultText` 占位符。未提取到结果时这些字段为空字符串。
+25 -2
View File
@@ -43,12 +43,23 @@
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/` | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额;当值大于 `0` 时,用户打开前端设置页或首次发送邮件时会自动初始化该额度。如果不设置,将为 `0` | `1` |
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
| `ENABLE_AGENT_EMAIL_INFO` | 文本/JSON | 是否在前端“地址凭证与连接方式”弹窗中展示 AI Agent 接入信息(Address JWT、parsed-mail API、skill 链接) | `true` |
| `SMTP_IMAP_PROXY_CONFIG` | JSON | 在前端“地址凭证与连接方式”弹窗中展示 SMTP/IMAP 代理连接信息;仅用于展示给用户,不会启动代理服务,代理服务仍需单独部署 | 见下方示例 |
| `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
> [!NOTE]
> `DEFAULT_DOMAINS` 未配置或配置为空数组时,会回退使用 `DOMAINS`
>
> `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare
> 侧的子域名路由。
>
> 要让 `name@<随机>.abc.com` 这种随机子域名地址真的能收到邮件,**必须在基础域名的 DNS 中为
> `*` 子域添加通配 MX 记录**:把基础域名上现有的每一条 MX 记录都复制到 `*` 主机名上,
> 并保留 priority 与 targetEmail Routing 子域不继承父域配置,详见 Cloudflare 的
> [Email Routing — Subdomains](https://developers.cloudflare.com/email-routing/setup/subdomains/)
> 文档、[#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035) 与
> [配置子域名邮箱](/zh/guide/feature/subdomain)。
>
> 子域名地址通常更适合收件;如果要发件,仍建议优先使用主域名。
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与随机子域名功能不同:它允许 API 调用方**直接指定**
@@ -62,6 +73,17 @@
>
> `SEND_MAIL_DOMAINS` 只影响 `SEND_MAIL` binding 的兜底发信路径和 `/admin/send_mail_by_binding`
> 它不影响 Resend、SMTP、`verifiedAddressList` 等其他发信通道。
>
> `SMTP_IMAP_PROXY_CONFIG` 示例:
>
> ```json
> {
> "smtp": { "host": "smtp.example.com", "port": 8025, "starttls": true },
> "imap": { "host": "imap.example.com", "port": 11143, "starttls": true }
> }
> ```
>
> SMTP 与 IMAP 可以使用不同主机名,便于反向代理或不同端口映射。
## 接受邮件相关变量
@@ -111,7 +133,7 @@
> [!NOTE] USER_ROLES 用户角色配置说明
>
> - 如果 `domains` 为空将使用 `DEFAULT_DOMAINS`
> - 如果 `domains` 为空将使用 `DEFAULT_DOMAINS`;如果 `DEFAULT_DOMAINS` 也为空,则继续回退到 `DOMAINS`
> - 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀
>
> 通过用户界面部署时 `USER_ROLES` 请配置为此格式 `[{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"vip","prefix":"vip"},{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"admin","prefix":""}]`
@@ -128,7 +150,8 @@
| `ALWAYS_SHOW_ANNOUNCEMENT` | 文本/JSON | 是否总是显示公告(即使无更改), 默认 `false` | `true` |
| `COPYRIGHT` | 文本 | 自定义前端界面页脚文本,支持 html | `Dream Hunter` |
| `ADMIN_CONTACT` | 文本 | admin 联系方式,可配置任意字符串, 不配置则不显示 | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | 文本/JSON | 是否显示 GitHub 链接 | `true` |
| `DISABLE_SHOW_GITHUB` | 文本/JSON | 是否全局隐藏 GitHub 链接 | `true` |
| `DISABLE_SHOW_GITHUB_FOR_USER` | 文本/JSON | 是否仅对普通用户隐藏 GitHub 链接,admin 仍显示 | `true` |
| `STATUS_URL` | 文本 | 状态监控页面 URL,配置后显示 Status 菜单按钮 | `https://status.example.com` |
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "temp-mail-docs",
"private": true,
"version": "1.8.0",
"version": "1.10.0",
"type": "module",
"devDependencies": {
"@types/node": "^25.6.0",
"@types/node": "^26.0.0",
"vitepress": "^1.6.4",
"wrangler": "^4.83.0"
"wrangler": "^4.104.0"
},
"scripts": {
"dev": "vitepress dev docs",
+529 -529
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.8.0",
"version": "1.10.0",
"private": true,
"type": "module",
"scripts": {
@@ -11,23 +11,23 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260420.1",
"@cloudflare/workers-types": "^4.20260624.1",
"@eslint/js": "9.39.1",
"@types/node": "^25.6.0",
"@types/node": "^25.9.4",
"eslint": "9.39.1",
"globals": "^16.5.0",
"typescript-eslint": "^8.58.2",
"wrangler": "^4.83.0"
"typescript-eslint": "^8.62.0",
"wrangler": "^4.104.0"
},
"dependencies": {
"@aws-sdk/client-s3": "3.888.0",
"@aws-sdk/s3-request-presigner": "3.888.0",
"@simplewebauthn/server": "13.2.3",
"hono": "^4.12.14",
"@simplewebauthn/server": "^13.3.2",
"hono": "^4.12.27",
"jsonpath-plus": "^10.4.0",
"mimetext": "^3.0.28",
"postal-mime": "^2.7.4",
"resend": "^6.12.0",
"resend": "^6.14.0",
"telegraf": "4.16.3",
"worker-mailer": "^1.2.1"
},
+811 -964
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -2,7 +2,7 @@ import { Context } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n'
import { getBooleanValue, hashPassword } from '../utils'
import { getBooleanValue } from '../utils'
import { newAddress, handleListQuery } from '../common'
const listAddresses = async (c: Context<HonoCustomType>) => {
@@ -32,7 +32,7 @@ const listAddresses = async (c: Context<HonoCustomType>) => {
+ ` FROM address a`
+ ` where ${whereClause}`,
`SELECT count(*) as count FROM address where ${whereClause}`,
[param], limit, offset, orderBy
[param], limit, offset, orderBy, ['password']
);
}
return await handleListQuery(c,
@@ -41,7 +41,7 @@ const listAddresses = async (c: Context<HonoCustomType>) => {
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`,
`SELECT count(*) as count FROM address`,
[], limit, offset, orderBy
[], limit, offset, orderBy, ['password']
);
};
@@ -137,16 +137,16 @@ const resetPassword = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { password } = await c.req.json();
// NOTE: Keep the admin API field as password, but the value is a frontend SHA-256 hash.
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();
).bind(password, id).run();
if (!success) {
return c.text(msgs.FailedUpdatePasswordMsg, 500);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { Context } from 'hono';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles } from '../utils';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles, getMailDomain, includesDomain } from '../utils';
import { UserSettings, GeoData, UserInfo, RoleAddressConfig } from "../models";
import { handleListQuery } from '../common'
import UserBindAddressModule from '../user_api/bind_address';
@@ -24,9 +24,9 @@ export default {
return c.text(msgs.VerifyMailSenderNotSetMsg, 400)
}
if (settings.enableMailVerify && settings.verifyMailSender) {
const mailDomain = settings.verifyMailSender.split("@")[1];
const mailDomain = getMailDomain(settings.verifyMailSender);
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
if (!includesDomain(domains, mailDomain)) {
return c.text(`${msgs.VerifyMailDomainInvalidMsg} ${JSON.stringify(domains, null, 2)}`, 400)
}
}
+24 -5
View File
@@ -29,7 +29,7 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
return c.text("Not available", 404);
}
const { from, to, raw } = await c.req.json();
const { from, to, raw, ai_extract_result } = await c.req.json();
if (!from || !to || !raw) {
return c.text("from, to and raw are required", 400);
}
@@ -44,19 +44,38 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
if (!headers.has('Message-ID')) headers.set('Message-ID', `<e2e-${Date.now()}@test>`);
const rawBytes = new TextEncoder().encode(raw);
const state = { rejected: undefined as string | undefined, replyCalled: false };
const state = { rejected: undefined as string | undefined, replyCalled: false, forwardedTo: [] as string[] };
const mockMessage: ForwardableEmailMessage = {
from, to, headers,
rawSize: rawBytes.byteLength,
raw: new ReadableStream({ start(ctrl) { ctrl.enqueue(rawBytes); ctrl.close(); } }),
setReject(reason: string) { state.rejected = reason; },
forward: async () => ({ messageId: '' }),
forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; },
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
};
const { email: emailHandler } = await import('../email');
await emailHandler(mockMessage, c.env, { waitUntil: () => {}, passThroughOnException: () => {} });
const aiExtractEnvOverrides: Partial<Bindings> = {
ENABLE_AI_EMAIL_EXTRACT: true,
AI: {
run: async () => ({ response: ai_extract_result })
} as unknown as Ai,
};
const env = ai_extract_result
? { ...c.env, ...aiExtractEnvOverrides }
: c.env;
const executionContext: ExecutionContext = {
waitUntil: () => {},
passThroughOnException: () => {},
props: {}
};
await emailHandler(mockMessage, env, executionContext);
return c.json({ success: !state.rejected, replyCalled: state.replyCalled, ...(state.rejected ? { rejected: state.rejected } : {}) });
return c.json({
success: !state.rejected,
replyCalled: state.replyCalled,
forwardedTo: state.forwardedTo,
...(state.rejected ? { rejected: state.rejected } : {})
});
};
export default { seedMail, receiveMail };
@@ -37,7 +37,11 @@ async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response
subject: parsedEmail?.subject || "test subject",
raw: raw || "test raw email",
parsedText: parsedEmail?.text || "test parsed text",
parsedHtml: parsedEmail?.html || "test parsed html"
parsedHtml: parsedEmail?.html || "test parsed html",
aiExtract: null,
aiExtractType: "",
aiExtractResult: "",
aiExtractResultText: ""
});
if (!res.success) {
return c.text(res.message || "send webhook error", 400);
+2 -3
View File
@@ -3,6 +3,7 @@ import { isSendMailBindingEnabled } from "../common";
import i18n from "../i18n";
import { sendMail } from "../mails_api/send_mail_api";
import { ensureSendMailLimit, increaseSendMailLimitCount } from "../mails_api/send_mail_limit_utils";
import { getMailDomain } from "../utils";
const getAdminSendMailErrorMessage = (
msgs: ReturnType<typeof i18n.getMessagesbyContext>,
@@ -68,9 +69,7 @@ export const sendMailByBindingAdmin = async (c: Context<HonoCustomType>) => {
return c.text(msgs.InvalidInputMsg, 400)
}
const fromMail = typeof from === "string" ? from : from?.email;
const mailDomain = typeof fromMail === "string" && fromMail.includes("@")
? fromMail.split("@")[1]?.trim().toLowerCase()
: null;
const mailDomain = getMailDomain(fromMail);
if (!mailDomain) {
return c.text(msgs.InvalidInputMsg, 400)
}
+2 -1
View File
@@ -45,7 +45,8 @@ export default {
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
"S3_ENABLED": isS3Enabled(c),
"VERSION": CONSTANTS.VERSION,
"DISABLE_SHOW_GITHUB": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"DISABLE_SHOW_GITHUB": utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"DISABLE_SHOW_GITHUB_FOR_USER": utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB_FOR_USER),
"DISABLE_ADMIN_PASSWORD_CHECK": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"ENABLE_CHECK_JUNK_MAIL": utils.getBooleanValue(c.env.ENABLE_CHECK_JUNK_MAIL),
"JUNK_MAIL_CHECK_LIST": utils.getStringArray(c.env.JUNK_MAIL_CHECK_LIST),
+20 -1
View File
@@ -15,12 +15,17 @@ api.get('/open_api/settings', async (c) => {
const auth = c.req.raw.headers.get("x-custom-auth");
needAuth = !auth || !passwords.includes(auth);
}
const smtpImapProxyConfig = utils.getJsonObjectValue<SmtpImapProxyConfig>(
c.env.SMTP_IMAP_PROXY_CONFIG
) || {};
const smtpProxyConfig = smtpImapProxyConfig.smtp || {};
const imapProxyConfig = smtpImapProxyConfig.imap || {};
return c.json({
"title": c.env.TITLE,
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
"alwaysShowAnnouncement": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT),
"prefix": utils.getStringValue(c.env.PREFIX),
"prefix": utils.trimLower(c.env.PREFIX),
"addressRegex": utils.getStringValue(c.env.ADDRESS_REGEX),
"minAddressLen": utils.getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"maxAddressLen": utils.getIntValue(c.env.MAX_ADDRESS_LEN, 30),
@@ -43,8 +48,22 @@ api.get('/open_api/settings', async (c) => {
"enableSendMail": isAnySendMailEnabled(c),
"version": CONSTANTS.VERSION,
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"showGithubForUser": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB_FOR_USER),
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD),
"enableAgentEmailInfo": utils.getBooleanValue(c.env.ENABLE_AGENT_EMAIL_INFO),
"smtpImapProxyConfig": {
"smtp": {
"host": utils.getStringValue(smtpProxyConfig.host),
"port": utils.getIntValue(smtpProxyConfig.port, 8025),
"starttls": utils.getBooleanValue(smtpProxyConfig.starttls),
},
"imap": {
"host": utils.getStringValue(imapProxyConfig.host),
"port": utils.getIntValue(imapProxyConfig.port, 11143),
"starttls": utils.getBooleanValue(imapProxyConfig.starttls),
},
},
"statusUrl": utils.getStringValue(c.env.STATUS_URL),
"enableGlobalTurnstileCheck": utils.isGlobalTurnstileEnabled(c)
});
+42 -15
View File
@@ -2,10 +2,10 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { WorkerMailerOptions } from 'worker-mailer';
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains } from './utils';
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, normalizeDomains, trimLower } from './utils';
import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models';
import i18n from './i18n';
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
@@ -15,7 +15,7 @@ const MAX_DOMAIN_LENGTH = 253;
const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const normalizeDomainValue = (domain: string): string => {
return domain.trim().toLowerCase();
return trimLower(domain);
}
const isValidDomainLabel = (label: string): boolean => {
@@ -41,7 +41,7 @@ export const isSendMailEnabled = (
// Check SMTP config for domain
const smtpConfigMap = getJsonObjectValue<Record<string, WorkerMailerOptions>>(c.env.SMTP_CONFIG);
if (smtpConfigMap && smtpConfigMap[mailDomain]) return true;
if (getDomainMapValue(smtpConfigMap, mailDomain)) return true;
// Check SEND_MAIL binding
if (isSendMailBindingEnabled(c, mailDomain)) return true;
@@ -56,8 +56,7 @@ export const isSendMailBindingEnabled = (
if (!c.env.SEND_MAIL) {
return false;
}
const sendMailDomains = getStringArray(c.env.SEND_MAIL_DOMAINS)
.map((domain) => normalizeDomainValue(domain));
const sendMailDomains = normalizeDomains(getStringArray(c.env.SEND_MAIL_DOMAINS));
if (sendMailDomains.length === 0) {
return true;
}
@@ -369,9 +368,9 @@ export const newAddress = async (
}
// create address with prefix
if (typeof addressPrefix === "string") {
name = addressPrefix.trim() + name;
name = trimLower(addressPrefix) + name;
} else if (enablePrefix) {
name = getStringValue(c.env.PREFIX).trim() + name;
name = trimLower(c.env.PREFIX) + name;
}
// check domain
const allowDomains = checkAllowDomains ? await getAllowDomains(c) : getDomains(c);
@@ -611,7 +610,8 @@ export const handleListQuery = async (
limit: string | number | undefined | null,
offset: string | number | undefined | null,
/** Must be pre-validated (e.g. whitelist), NOT raw user input. Interpolated directly into SQL. */
orderBy?: string
orderBy?: string,
hiddenFields: string[] = []
): Promise<Response> => {
const msgs = i18n.getMessagesbyContext(c);
if (typeof limit === "string") {
@@ -634,7 +634,23 @@ export const handleListQuery = async (
const count = offset == 0 ? await c.env.DB.prepare(
countQuery
).bind(...params).first("count") : 0;
return c.json({ results, count });
if (hiddenFields.length === 0) {
return c.json({ results, count });
}
const filteredResults = results.map((row) => hideObjectFields(row, hiddenFields));
return c.json({ results: filteredResults, count });
}
export const hideObjectFields = <T extends Record<string, unknown>>(
row: T,
fields: string[]
): T => {
const filteredRow = { ...row };
for (const field of fields) {
delete filteredRow[field];
}
return filteredRow;
}
/**
@@ -743,13 +759,13 @@ export const commonGetUserRole = async (
export const getAddressPrefix = async (c: Context<HonoCustomType>): Promise<string | undefined> => {
const user = c.get("userPayload");
if (!user) {
return getStringValue(c.env.PREFIX).trim().toLowerCase();
return trimLower(c.env.PREFIX);
}
const user_role = await commonGetUserRole(c, user.user_id);
if (typeof user_role?.prefix === "string") {
return user_role.prefix.trim().toLowerCase();
return trimLower(user_role.prefix);
}
return getStringValue(c.env.PREFIX).trim().toLowerCase();
return trimLower(c.env.PREFIX);
}
export const getAllowDomains = async (c: Context<HonoCustomType>): Promise<string[]> => {
@@ -758,7 +774,10 @@ export const getAllowDomains = async (c: Context<HonoCustomType>): Promise<strin
return getDefaultDomains(c);
}
const user_role = await commonGetUserRole(c, user.user_id);
return user_role?.domains || getDefaultDomains(c);;
if (user_role?.domains && user_role.domains.length > 0) {
return normalizeDomains(user_role.domains);
}
return getDefaultDomains(c);
}
export async function sendWebhook(
@@ -791,7 +810,8 @@ export async function triggerWebhook(
c: Context<HonoCustomType>,
address: string,
parsedEmailContext: ParsedEmailContext,
message_id: string | null
message_id: string | null,
aiExtract?: ExtractResult | null
): Promise<void> {
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
return
@@ -824,6 +844,9 @@ export async function triggerWebhook(
).bind(address, message_id).first<string>("id");
const parsedEmail = await commonParseMail(parsedEmailContext);
const usableAiExtract = aiExtract?.type !== "none" && aiExtract?.result
? aiExtract
: null;
const webhookMail = {
id: mailId || "",
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
@@ -833,6 +856,10 @@ export async function triggerWebhook(
raw: parsedEmailContext.rawEmail || "",
parsedText: parsedEmail?.text || "",
parsedHtml: parsedEmail?.html || "",
aiExtract: usableAiExtract,
aiExtractType: usableAiExtract?.type || "",
aiExtractResult: usableAiExtract?.result || "",
aiExtractResultText: usableAiExtract?.result_text || "",
}
for (const settings of webhookList) {
const res = await sendWebhook(settings, webhookMail);
+1 -1
View File
@@ -1,5 +1,5 @@
export const CONSTANTS = {
VERSION: 'v' + '1.8.0',
VERSION: 'v' + '1.10.0',
// DB Version
DB_VERSION_KEY: 'db_version',
+122 -38
View File
@@ -7,10 +7,12 @@
*/
import { commonParseMail } from "../common";
import { extractCode } from "./extract_code";
import { getBooleanValue, getJsonSetting } from "../utils";
import { CONSTANTS } from "../constants";
import { Context } from "hono";
import type { AiExtractSettings } from "../admin_api/ai_extract_settings";
import type { ExtractResult } from "../models";
// AI Prompt for email analysis
const PROMPT = `
@@ -73,7 +75,8 @@ If the extracted content is in markdown link format [text](url):
2. **Single Selection**: Choose ONLY ONE type based on the highest priority match
3. **Real Data Only**: Never invent, guess, or fabricate content
4. **Complete URLs**: Links must be full, valid URLs as they appear in the email
5. **Clean Extraction**: Return only the raw extracted content, no extra text
5. **No Domain Modification**: Never modify, rewrite, or substitute URL domains. If the exact URL domain is uncertain, return none
6. **Clean Extraction**: Return only the raw extracted content, no extra text
# Output Format (JSON only)
{
@@ -97,7 +100,7 @@ async function extractWithCloudflareAI(
env: Bindings
): Promise<ExtractResult> {
// Get the AI model name from environment variable or use default
const modelName = env.AI_EXTRACT_MODEL || '@cf/meta/llama-3.1-8b-instruct';
const modelName = env.AI_EXTRACT_MODEL || '@cf/meta/llama-3.1-8b-instruct-fast';
const result = await env.AI.run(modelName as keyof AiModels, {
messages: [
@@ -136,35 +139,118 @@ async function extractWithCloudflareAI(
throw new Error('Unexpected response format from Cloudflare AI');
}
/**
* Persist an extraction result to the raw_mails metadata column.
* Shared by the Workers AI path and the regex fallback path.
*
* @param env - Cloudflare Workers environment bindings
* @param message_id - The email message ID
* @param result - The extraction result to persist
*/
async function saveExtractMetadata(
env: Bindings,
message_id: string | null,
result: ExtractResult
): Promise<void> {
try {
const metadata = JSON.stringify({
ai_extract: result,
extracted_at: new Date().toISOString()
});
// Update the raw_mails record with metadata
await env.DB.prepare(
`UPDATE raw_mails SET metadata = ? WHERE message_id = ?`
).bind(metadata, message_id).run();
} catch (e) {
console.error('AI extraction metadata save error:', e);
}
}
function decodeHtmlEntities(text: string): string {
const entities: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
};
const decodeCodePoint = (value: number, fallback: string) => {
if (!Number.isFinite(value) || value < 0 || value > 0x10ffff) {
return fallback;
}
return String.fromCodePoint(value);
};
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (match, entity) => {
const normalized = entity.toLowerCase();
if (normalized.startsWith('#x')) {
const value = Number.parseInt(normalized.slice(2), 16);
return decodeCodePoint(value, match);
}
if (normalized.startsWith('#')) {
const value = Number.parseInt(normalized.slice(1), 10);
return decodeCodePoint(value, match);
}
return entities[normalized] ?? match;
});
}
function htmlToTextForAi(html: string): string {
return decodeHtmlEntities(
html
.replace(/<\s*(script|style|head|svg)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<a\b[^>]*\bhref=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, ' $3 $2 ')
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\/\s*(p|div|tr|td|th|li|table|section|article|header|footer|h[1-6])\s*>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
)
.replace(/[ \t\r\f\v]+/g, ' ')
.replace(/\n\s+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function getEmailContentForExtract(parsedEmail: Awaited<ReturnType<typeof commonParseMail>>): string {
if (parsedEmail?.text) {
return parsedEmail.text;
}
if (!parsedEmail?.html) {
return "";
}
return htmlToTextForAi(parsedEmail.html) || parsedEmail.html;
}
/**
* Main extraction function
* Checks if AI extraction is enabled, processes the email content, and saves to database
* Checks if extraction is enabled, processes the email content, and saves to database.
* Uses Cloudflare Workers AI when the `AI` binding is available; otherwise falls back
* to a built-in regex extractor that surfaces verification codes only.
*
* @param parsedEmailContext - The parsed email context
* @param env - Cloudflare Workers environment bindings
* @param message_id - The email message ID
* @param address - The recipient email address
* @returns Promise<void>
* @returns Promise<ExtractResult | null>
*/
export async function extractEmailInfo(
parsedEmailContext: ParsedEmailContext,
env: Bindings,
message_id: string | null,
address: string
): Promise<void> {
): Promise<ExtractResult | null> {
try {
// Check if AI extraction is enabled via environment variable
// Check if extraction is enabled via environment variable
if (!getBooleanValue(env.ENABLE_AI_EMAIL_EXTRACT)) {
return;
return null;
}
// Ensure AI binding is available
if (!env.AI) {
console.error('AI binding not available');
return;
}
// Check allowlist if enabled
// Check allowlist if enabled (applies to both AI and the regex fallback)
const aiSettings = await getJsonSetting<AiExtractSettings>(
{ env: env } as Context<HonoCustomType>,
CONSTANTS.AI_EXTRACT_SETTINGS_KEY
@@ -186,17 +272,31 @@ export async function extractEmailInfo(
});
if (!isAllowed) {
console.log(`AI extraction skipped for ${address}: not in allowlist`);
return;
console.log(`Email extraction skipped for ${address}: not in allowlist`);
return null;
}
}
// Parse email to get content
// Parse email to get content (shared by the AI path and the regex fallback)
const parsedEmail = await commonParseMail(parsedEmailContext);
const emailContent = parsedEmail?.text || parsedEmail?.html || "";
const emailContent = getEmailContentForExtract(parsedEmail);
if (!emailContent) {
return;
return null;
}
// Fallback: when no Workers AI binding is available, use a built-in regex
// extractor so self-hosted deployments without Workers AI still surface
// verification codes. Telegram / webhook reuse the same ExtractResult.
if (!env.AI) {
const code = extractCode(emailContent);
if (!code) {
return null;
}
const result: ExtractResult = { type: 'auth_code', result: code, result_text: '' };
await saveExtractMetadata(env, message_id, result);
console.log(`Regex code extraction completed for ${message_id}`);
return result;
}
// Truncate content if too long (max 4000 characters to avoid token limits)
@@ -208,28 +308,12 @@ export async function extractEmailInfo(
// If extraction found something useful, save it to database
if (result.type !== 'none' && result.result) {
const metadata = JSON.stringify({
ai_extract: result,
extracted_at: new Date().toISOString()
});
// Update the raw_mails record with metadata
await env.DB.prepare(
`UPDATE raw_mails SET metadata = ? WHERE message_id = ?`
).bind(metadata, message_id).run();
await saveExtractMetadata(env, message_id, result);
console.log(`AI extraction completed for ${message_id}: ${result.type}`);
}
return result;
} catch (e) {
console.error('AI email extraction error:', e);
return null;
}
}
/**
* Type definition for extraction result
*/
export type ExtractResult = {
type: 'auth_code' | 'auth_link' | 'service_link' | 'subscription_link' | 'other_link' | 'none';
result: string;
result_text: string;
};
+8 -4
View File
@@ -1,5 +1,5 @@
import { createMimeMessage } from "mimetext";
import { getBooleanValue } from "../utils";
import { getBooleanValue, normalizeAddressDomain } from "../utils";
/**
* Check if the sender matches the source_prefix filter.
@@ -21,14 +21,18 @@ function matchSender(from: string, sourcePrefix: string | undefined): boolean {
return from.startsWith(sourcePrefix);
}
export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings): Promise<void> => {
export const auto_reply = async (
message: ForwardableEmailMessage,
env: Bindings,
toAddress: string = normalizeAddressDomain(message.to)
): Promise<void> => {
const message_id = message.headers.get("Message-ID");
// auto reply email
if (getBooleanValue(env.ENABLE_AUTO_REPLY) && message_id) {
try {
const results = await env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
).bind(message.to).first<Record<string, string>>();
).bind(toAddress).first<Record<string, string>>();
if (results && matchSender(message.from, results.source_prefix)) {
if (!results.subject || !results.message) {
console.log("auto-reply using defaults:", !results.subject ? "subject" : "", !results.message ? "message" : "");
@@ -50,7 +54,7 @@ export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings
} else {
const { EmailMessage } = await import('cloudflare:email');
const replyMessage = new EmailMessage(
message.to,
toAddress,
message.from,
msg.asRaw()
);
+80
View File
@@ -0,0 +1,80 @@
/**
* Regex-based verification code extraction.
*
* Extracts verification codes (4-12 digit / alphanumeric) from email text.
* Covers common formats across English, Chinese, Japanese and Korean.
*
* This is a zero-dependency fallback used when Workers AI is not bound, so
* self-hosted deployments without Workers AI still surface verification codes.
*
* Design principles (learned from QA):
* 1. Prefer codes that appear after an explicit code keyword.
* 2. Reject plausible dates/years (e.g. "2026", "20260411") too common as
* date markers in subject lines, almost never a real verification code.
* 3. Allow multi-character delimiters (a keyword followed by a colon or "is").
* 4. Fall back to standalone digits ONLY if no keyword-guided match found
* AND the digits don't look like a year or YYYYMMDD date.
*/
export function extractCode(text: string): string | null {
// DELIM: at least one explicit delimiter must follow the code keyword before
// the code itself. Allowed delimiters: an ASCII or full-width colon, the word
// "is", or a common CJK delimiter particle (see the DELIM regex below).
// Multiple delimiters in sequence are OK. Whitespace around them is allowed.
//
// Why mandatory: without a delimiter, "verification code Your email" would
// capture "Your" as a false alphanumeric code. Bare "verification code 123456"
// without any delimiter still falls through to the standalone-digit fallback.
const DELIM = '\\s*(?:[:]|\\bis\\b|是|为|です)[\\s:]*';
// Keyword groups — labels that precede a verification code
const CN_JA_KO_KW = '验证码|认证码|确认码|認証コード|인증\\s*코드|코드';
const EN_KW = 'verification\\s*code|confirm(?:ation)?\\s*code|security\\s*code|passcode|OTP|pin\\s*code';
const ALL_KW = `${CN_JA_KO_KW}|${EN_KW}`;
const keywordPatterns = [
// 1. "code is 123456" / "code: 123456" — numeric preferred (bare "code" keyword)
new RegExp(`\\bcode${DELIM}(\\d{4,12})\\b`, 'i'),
// 2. Full keyword + mandatory delimiter + digits
new RegExp(`(?:${ALL_KW})${DELIM}(\\d{4,12})\\b`, 'i'),
// 3. Bare "code" + delimiter + alphanumeric
new RegExp(`\\bcode${DELIM}([A-Za-z0-9]{4,12})\\b`, 'i'),
// 4. Full keyword + delimiter + alphanumeric
new RegExp(`(?:${ALL_KW})${DELIM}([A-Za-z0-9]{4,12})\\b`, 'i'),
];
for (const pattern of keywordPatterns) {
const match = text.match(pattern);
if (match?.[1] && !looksLikeDate(match[1])) return match[1];
}
// Fallback: standalone digit sequence, but reject year/date patterns
const standaloneMatch = text.match(/(?:^|\s)(\d{4,12})(?:\s|$|\.|,)/m);
if (standaloneMatch?.[1] && !looksLikeDate(standaloneMatch[1])) {
return standaloneMatch[1];
}
return null;
}
/**
* Heuristic: does this digit sequence look like a date/year we should reject?
* - 4 digits matching 19xx or 20xx year (e.g. 2026)
* - 8 digits matching YYYYMMDD with plausible month/day date (e.g. 20260411)
*/
function looksLikeDate(digits: string): boolean {
// 4-digit year: 1900-2099
if (digits.length === 4) {
const n = parseInt(digits, 10);
if (n >= 1900 && n <= 2099) return true;
}
// 8-digit YYYYMMDD
if (digits.length === 8) {
const year = parseInt(digits.slice(0, 4), 10);
const month = parseInt(digits.slice(4, 6), 10);
const day = parseInt(digits.slice(6, 8), 10);
if (year >= 1900 && year <= 2099 && month >= 1 && month <= 12 && day >= 1 && day <= 31) {
return true;
}
}
return false;
}
+12 -3
View File
@@ -1,6 +1,6 @@
import { Context } from "hono";
import { getEnvStringList, getJsonObjectValue, getJsonSetting } from "../utils";
import { getEnvStringList, getJsonObjectValue, getJsonSetting, getMailDomain, isDomainOrSubdomain, normalizeDomain } from "../utils";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
@@ -91,6 +91,7 @@ async function forwardByRules(
...(emailRuleSettings?.emailForwardingList || []),
];
const messageDomain = getMailDomain(message.to);
for (const rule of allRules) {
// 检查来源地址是否匹配正则
if (!matchSourcePatterns(message.from, rule.sourcePatterns, rule.sourceMatchMode)) {
@@ -100,8 +101,16 @@ async function forwardByRules(
// 检查目标地址是否匹配域名,并转发
// 保持原始逻辑:每个匹配的 domain 都会触发一次转发
if (rule.domains && rule.domains.length > 0) {
for (const domain of rule.domains) {
if (message.to.endsWith(domain) && rule.forward) {
const normalizedDomains = rule.domains.map(normalizeDomain);
if (normalizedDomains.some(domain => domain.length === 0)) {
if (rule.forward) {
await message.forward(rule.forward);
}
continue;
}
for (const normalizedDomain of normalizedDomains) {
if (isDomainOrSubdomain(messageDomain, normalizedDomain) && rule.forward) {
await message.forward(rule.forward);
}
}
+21 -20
View File
@@ -1,6 +1,6 @@
import { Context } from "hono";
import { getBooleanValue, getJsonSetting } from "../utils";
import { getBooleanValue, getJsonSetting, normalizeAddressDomain } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { auto_reply } from "./auto_reply";
import { isBlocked } from "./black_list";
@@ -15,9 +15,10 @@ import { compressText } from "../gzip";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
const toAddress = normalizeAddressDomain(message.to);
if (await isBlocked(message.from, env)) {
message.setReject("Reject from address");
console.log(`Reject message from ${message.from} to ${message.to}`);
console.log(`Reject message from ${message.from} to ${toAddress}`);
return;
}
const rawEmail = await new Response(message.raw).text();
@@ -27,10 +28,10 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
// check if junk mail
try {
const is_junk = await check_if_junk_mail(env, message.to, parsedEmailContext, message.headers.get("Message-ID"));
const is_junk = await check_if_junk_mail(env, toAddress, parsedEmailContext, message.headers.get("Message-ID"));
if (is_junk) {
message.setReject("Junk mail");
console.log(`Junk mail from ${message.from} to ${message.to}`);
console.log(`Junk mail from ${message.from} to ${toAddress}`);
return;
}
} catch (error) {
@@ -45,10 +46,10 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
if (emailRuleSettings?.blockReceiveUnknowAddressEmail) {
const db_address_id = await env.DB.prepare(
`SELECT id FROM address where name = ? `
).bind(message.to).first("id");
).bind(toAddress).first("id");
if (!db_address_id) {
message.setReject("Unknown address");
console.log(`Unknown address mail from ${message.from} to ${message.to}`);
console.log(`Unknown address mail from ${message.from} to ${toAddress}`);
return;
}
}
@@ -58,7 +59,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
// remove attachment if configured or size > 2MB
try {
await remove_attachment_if_need(env, parsedEmailContext, message.from, message.to, message.rawSize);
await remove_attachment_if_need(env, parsedEmailContext, message.from, toAddress, message.rawSize);
} catch (error) {
console.error("remove attachment error", error);
}
@@ -79,7 +80,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
({ success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, compressed, message_id
message.from, toAddress, compressed, message_id
).run());
} catch (dbError) {
// Fallback to plaintext only if raw_blob column is missing (migration not applied)
@@ -89,7 +90,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
({ success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, parsedEmailContext.rawEmail, message_id
message.from, toAddress, parsedEmailContext.rawEmail, message_id
).run());
} else {
throw dbError;
@@ -99,19 +100,19 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
({ success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, parsedEmailContext.rawEmail, message_id
message.from, toAddress, parsedEmailContext.rawEmail, message_id
).run());
}
} else {
({ success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, parsedEmailContext.rawEmail, message_id
message.from, toAddress, parsedEmailContext.rawEmail, message_id
).run());
}
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.error(`Failed save message from ${message.from} to ${message.to}`);
message.setReject(`Failed save message to ${toAddress}`);
console.error(`Failed save message from ${message.from} to ${toAddress}`);
}
}
catch (error) {
@@ -121,11 +122,14 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
// forward email
await forwardEmail(message, env);
// AI email content extraction
const aiExtractResult = await extractEmailInfo(parsedEmailContext, env, message_id, toAddress);
// send email to telegram
try {
await sendMailToTelegram(
{ env: env } as Context<HonoCustomType>,
message.to, parsedEmailContext, message_id);
toAddress, parsedEmailContext, message_id, aiExtractResult);
} catch (error) {
console.error("send mail to telegram error", error);
}
@@ -134,7 +138,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
try {
await triggerWebhook(
{ env: env } as Context<HonoCustomType>,
message.to, parsedEmailContext, message_id
toAddress, parsedEmailContext, message_id, aiExtractResult
);
} catch (error) {
console.error("send webhook error", error);
@@ -146,7 +150,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
const parsedText = parsedEmail?.text ?? ""
const rpcEmail: RPCEmailMessage = {
from: message.from,
to: message.to,
to: toAddress,
rawEmail: rawEmail,
headers: message.headers
}
@@ -156,10 +160,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
}
// auto reply email
await auto_reply(message, env);
// AI email content extraction
await extractEmailInfo(parsedEmailContext, env, message_id, message.to);
await auto_reply(message, env, toAddress);
}
export { email }
+6
View File
@@ -174,6 +174,12 @@ const messages: LocaleMessages = {
TgNoPermissionViewMailMsg: "No permission to view this mail",
TgBotTokenRequiredMsg: "TELEGRAM_BOT_TOKEN is required",
TgLangFeatureDisabledMsg: "Language setting feature is disabled. System default language is used.",
TgAiExtractResultMsg: "AI extracted",
TgAiExtractAuthCodeMsg: "Verification code",
TgAiExtractAuthLinkMsg: "Verification link",
TgAiExtractServiceLinkMsg: "Service link",
TgAiExtractSubscriptionLinkMsg: "Subscription link",
TgAiExtractOtherLinkMsg: "Other link",
}
export default messages;
+6
View File
@@ -172,4 +172,10 @@ export type LocaleMessages = {
TgNoPermissionViewMailMsg: string
TgBotTokenRequiredMsg: string
TgLangFeatureDisabledMsg: string
TgAiExtractResultMsg: string
TgAiExtractAuthCodeMsg: string
TgAiExtractAuthLinkMsg: string
TgAiExtractServiceLinkMsg: string
TgAiExtractSubscriptionLinkMsg: string
TgAiExtractOtherLinkMsg: string
}
+6
View File
@@ -174,6 +174,12 @@ const messages: LocaleMessages = {
TgNoPermissionViewMailMsg: "无权查看此邮件",
TgBotTokenRequiredMsg: "需要设置 TELEGRAM_BOT_TOKEN",
TgLangFeatureDisabledMsg: "语言设置功能已禁用,使用系统默认语言",
TgAiExtractResultMsg: "AI 提取",
TgAiExtractAuthCodeMsg: "验证码",
TgAiExtractAuthLinkMsg: "验证链接",
TgAiExtractServiceLinkMsg: "服务链接",
TgAiExtractSubscriptionLinkMsg: "订阅链接",
TgAiExtractOtherLinkMsg: "其他链接",
}
export default messages;
+2 -2
View File
@@ -23,7 +23,7 @@ export default {
return c.text(msgs.InvalidAddressTokenMsg, 400);
}
// 更新密码
// NOTE: new_password is the frontend SHA-256 hash, stored directly in address.password.
const { success } = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
).bind(new_password, address_id).run();
@@ -67,7 +67,7 @@ export default {
return c.text(msgs.AddressNotFoundMsg, 404);
}
// 验证密码
// NOTE: password is the frontend SHA-256 hash, compared directly with address.password.
if (address.password !== password) {
return c.text(msgs.InvalidEmailOrPasswordMsg, 401);
}
+5 -5
View File
@@ -6,7 +6,7 @@ import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer';
import i18n from '../i18n';
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getBooleanValue, getJsonObjectValue } from '../utils';
import { getJsonSetting, getDomains, getBooleanValue, getJsonObjectValue, getDomainMapValue, getMailDomain, includesDomain } from '../utils';
import { GeoData } from '../models'
import { handleListQuery, isSendMailBindingEnabled, updateAddressUpdatedAt } from '../common'
import { getSendBalanceState, requestSendMailAccess } from './send_balance';
@@ -81,7 +81,7 @@ const sendMailByResend = async (
subject: string, content: string, is_html: boolean
}
): Promise<void> => {
const mailDomain = address.split("@")[1];
const mailDomain = getMailDomain(address);
const token = c.env[
`RESEND_TOKEN_${mailDomain.replace(/\./g, "_").toUpperCase()}`
] || c.env.RESEND_TOKEN;
@@ -143,9 +143,9 @@ export const sendMail = async (
throw new Error(msgs.AddressNotFoundMsg)
}
// check domain
const mailDomain = address.split("@")[1];
const mailDomain = getMailDomain(address);
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
if (!includesDomain(domains, mailDomain)) {
throw new Error(msgs.InvalidDomainMsg)
}
const sendBalanceState = await getSendBalanceState(c, address, {
@@ -182,7 +182,7 @@ export const sendMail = async (
];
// send by smtp
const smtpConfigMap = getJsonObjectValue<Record<string, WorkerMailerOptions>>(c.env.SMTP_CONFIG);
const smtpConfig = smtpConfigMap ? smtpConfigMap[mailDomain] : null;
const smtpConfig = getDomainMapValue(smtpConfigMap, mailDomain);
// send by verified address list
let sendByVerifiedAddressList = false;
if (c.env.SEND_MAIL) {
+5 -1
View File
@@ -53,7 +53,11 @@ async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response
subject: parsedEmail?.subject || "test subject",
raw: raw || "test raw email",
parsedText: parsedEmail?.text || "test parsed text",
parsedHtml: parsedEmail?.html || "test parsed html"
parsedHtml: parsedEmail?.html || "test parsed html",
aiExtract: null,
aiExtractType: "",
aiExtractResult: "",
aiExtractResultText: ""
});
if (!res.success) {
return c.text(res.message || "send webhook error", 400);
+13
View File
@@ -32,6 +32,10 @@ export type WebhookMail = {
raw: string;
parsedText: string;
parsedHtml: string;
aiExtract: ExtractResult | null;
aiExtractType: string;
aiExtractResult: string;
aiExtractResultText: string;
}
export type CustomSqlCleanup = {
@@ -156,6 +160,9 @@ export class WebhookSettings {
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
"aiExtractType": "${aiExtractType}",
"aiExtractResult": "${aiExtractResult}",
"aiExtractResultText": "${aiExtractResultText}",
}, null, 2)
}
@@ -208,3 +215,9 @@ export type RawMailRow = {
metadata?: string;
created_at?: string;
}
export type ExtractResult = {
type: 'auth_code' | 'auth_link' | 'service_link' | 'subscription_link' | 'other_link' | 'none';
result: string;
result_text: string;
}
+66 -8
View File
@@ -4,16 +4,16 @@ import { Telegraf, Context as TgContext, Markup } from "telegraf";
import { callbackQuery } from "telegraf/filters";
import { CONSTANTS } from "../constants";
import { getBooleanValue, getDomains, getJsonObjectValue, getStringValue } from '../utils';
import { getBooleanValue, getDomains, getJsonObjectValue, trimLower } from '../utils';
import { TelegramSettings } from "./settings";
import { sendTelegramAttachments } from "./tg_file_upload";
import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common";
import { commonParseMail } from "../common";
import { resolveRawEmail } from "../gzip";
import { RawMailRow } from "../models";
import { UserFromGetMe } from "telegraf/types";
import i18n from "../i18n";
import { LocaleMessages } from "../i18n/type";
import type { ExtractResult, RawMailRow } from "../models";
// Helper to get messages by userId
@@ -75,6 +75,56 @@ const COMMANDS = [
},
]
const formatAiExtractForTelegram = (
msgs: LocaleMessages,
aiExtract?: ExtractResult | string | null
): string => {
if (!aiExtract) {
return "";
}
try {
if (typeof aiExtract === "string") {
const metadata = JSON.parse(aiExtract);
aiExtract = metadata?.ai_extract;
}
} catch (error) {
console.warn("Failed to parse AI extraction metadata", error);
return "";
}
if (!aiExtract || typeof aiExtract !== "object") {
return "";
}
const labels: Record<Exclude<ExtractResult["type"], "none">, string> = {
auth_code: msgs.TgAiExtractAuthCodeMsg,
auth_link: msgs.TgAiExtractAuthLinkMsg,
service_link: msgs.TgAiExtractServiceLinkMsg,
subscription_link: msgs.TgAiExtractSubscriptionLinkMsg,
other_link: msgs.TgAiExtractOtherLinkMsg,
};
const label = labels[aiExtract.type as keyof typeof labels];
const result = typeof aiExtract.result === "string"
? aiExtract.result.replace(/\s+/g, " ").trim().slice(0, 600)
: "";
if (!result) {
return "";
}
if (!label) {
return "";
}
const resultText = typeof aiExtract.result_text === "string"
? aiExtract.result_text.replace(/\s+/g, " ").trim().slice(0, 120)
: "";
const displayText = aiExtract.type !== "auth_code" && resultText && resultText !== result
? ` (${resultText})`
: "";
return `${msgs.TgAiExtractResultMsg}\n${label}: ${result}${displayText}\n\n`;
}
export const getTelegramCommands = (c: Context<HonoCustomType>) => {
return getBooleanValue(c.env.TG_ALLOW_USER_LANG)
? COMMANDS
@@ -117,7 +167,7 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
bot.command("start", async (ctx: TgContext) => {
const msgs = await getTgMessages(c, ctx);
const prefix = getStringValue(c.env.PREFIX)
const prefix = trimLower(c.env.PREFIX)
const domains = getDomains(c);
const commands = getTelegramCommands(c);
return await ctx.reply(
@@ -312,7 +362,9 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
const raw = mailRow ? await resolveRawEmail(mailRow) : undefined;
const mailId = mailRow?.id;
const created_at = mailRow?.created_at;
const { mail } = raw ? await parseMail(msgs, { rawEmail: raw }, queryAddress, created_at) : { mail: msgs.TgNoMoreMailsMsg };
const { mail } = raw
? await parseMail(msgs, { rawEmail: raw }, queryAddress, created_at, mailRow?.metadata)
: { mail: msgs.TgNoMoreMailsMsg };
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
const miniAppButtons = []
if (settings?.miniAppUrl && settings?.miniAppUrl?.length > 0 && mailId) {
@@ -381,7 +433,9 @@ export async function initTelegramBotCommands(c: Context<HonoCustomType>, bot: T
const parseMail = async (
msgs: LocaleMessages,
parsedEmailContext: ParsedEmailContext,
address: string, created_at: string | undefined | null
address: string,
created_at: string | undefined | null,
aiExtract?: ExtractResult | string | null
) => {
if (!parsedEmailContext.rawEmail) {
return {};
@@ -394,7 +448,8 @@ const parseMail = async (
}
return {
isHtml: false,
mail: `From: ${parsedEmail?.sender || msgs.TgNoSenderMsg}\n`
mail: formatAiExtractForTelegram(msgs, aiExtract)
+ `From: ${parsedEmail?.sender || msgs.TgNoSenderMsg}\n`
+ `To: ${address}\n`
+ (created_at ? `Date: ${created_at}\n` : "")
+ `Subject: ${parsedEmail?.subject}\n`
@@ -412,7 +467,8 @@ const parseMail = async (
export async function sendMailToTelegram(
c: Context<HonoCustomType>, address: string,
parsedEmailContext: ParsedEmailContext,
message_id: string | null
message_id: string | null,
aiExtract?: ExtractResult | null
) {
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
return;
@@ -429,7 +485,9 @@ export async function sendMailToTelegram(
const bot = newTelegramBot(c, c.env.TELEGRAM_BOT_TOKEN);
const buildAndSend = async (targetUserId: string, msgs: LocaleMessages) => {
const { mail } = await parseMail(msgs, parsedEmailContext, address, new Date().toUTCString());
const { mail } = await parseMail(
msgs, parsedEmailContext, address, new Date().toUTCString(), aiExtract
);
if (!mail) return;
const attachments = parsedEmailContext.parsedEmail?.attachments || [];
const buttons = [];
+16
View File
@@ -4,6 +4,19 @@ type UserRole = {
prefix: string | undefined | null
}
type SmtpImapProxyConfig = {
smtp?: {
host?: string
port?: number | string
starttls?: boolean | string
}
imap?: {
host?: string
port?: number | string
starttls?: boolean | string
}
}
type Bindings = {
// bindings
DB: D1Database
@@ -45,6 +58,8 @@ type Bindings = {
DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
ENABLE_AGENT_EMAIL_INFO: string | boolean | undefined
SMTP_IMAP_PROXY_CONFIG: string | SmtpImapProxyConfig | undefined
ENABLE_INDEX_ABOUT: string | boolean | undefined
DEFAULT_SEND_BALANCE: number | string | undefined
NO_LIMIT_SEND_ROLE: string | undefined | null
@@ -52,6 +67,7 @@ type Bindings = {
COPYRIGHT: string | undefined
STATUS_URL: string | undefined
DISABLE_SHOW_GITHUB: string | boolean | undefined
DISABLE_SHOW_GITHUB_FOR_USER: string | boolean | undefined
FORWARD_ADDRESS_LIST: string | string[] | undefined
ENABLE_CHECK_JUNK_MAIL: string | boolean | undefined
+2 -2
View File
@@ -4,7 +4,7 @@ import { Jwt } from 'hono/utils/jwt'
import { isAddressCountLimitReached } from "../utils"
import { unbindTelegramByAddress } from '../telegram_api/common';
import i18n from '../i18n';
import { updateAddressUpdatedAt, commonGetUserRole } from '../common';
import { updateAddressUpdatedAt, commonGetUserRole, hideObjectFields } from '../common';
const UserBindAddressModule = {
bind: async (c: Context<HonoCustomType>) => {
@@ -140,7 +140,7 @@ const UserBindAddressModule = {
created_at: string;
updated_at: string;
}>();
return results || [];
return (results || []).map((row) => hideObjectFields(row, ['password']));
},
getBindedAddressJwt: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
+3 -3
View File
@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n';
import { getJsonSetting, getStringValue, getUserRoles } from '../utils';
import { getJsonSetting, getMailDomain, getStringValue, getUserRoles, includesDomain } from '../utils';
import { UserOauth2Settings } from '../models';
import { CONSTANTS } from '../constants';
@@ -107,8 +107,8 @@ export default {
return c.text(msgs.Oauth2FailedGetUserEmailMsg, 400);
}
// check email in mail allow list
const mailDomain = email.split("@")[1];
if (setting.enableMailAllowList && !setting.mailAllowList?.includes(mailDomain)) {
const mailDomain = getMailDomain(email);
if (setting.enableMailAllowList && !includesDomain(setting.mailAllowList, mailDomain)) {
return c.text(`${msgs.UserMailDomainMustInMsg} ${JSON.stringify(setting.mailAllowList, null, 2)}`, 400)
}
// insert or update user
+5 -5
View File
@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n';
import utils, { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils"
import utils, { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue, getMailDomain, includesDomain } from "../utils"
import { CONSTANTS } from "../constants";
import { GeoData, UserInfo, UserSettings } from "../models";
import { sendMail } from "../mails_api/send_mail_api";
@@ -20,10 +20,10 @@ export default {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value)
// check mail domain allow list
const mailDomain = email.split("@")[1];
const mailDomain = getMailDomain(email);
if (settings.enableMailAllowList
&& settings.mailAllowList
&& !settings.mailAllowList.includes(mailDomain)
&& !includesDomain(settings.mailAllowList, mailDomain)
) {
return c.text(`${msgs.UserMailDomainMustInMsg} ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
}
@@ -95,10 +95,10 @@ export default {
return c.text(msgs.InvalidVerifyCodeMsg, 400)
}
// check mail domain allow list
const mailDomain = email.split("@")[1];
const mailDomain = getMailDomain(email);
if (settings.enableMailAllowList
&& settings.mailAllowList
&& !settings.mailAllowList.includes(mailDomain)
&& !includesDomain(settings.mailAllowList, mailDomain)
) {
return c.text(`${msgs.UserMailDomainMustInMsg} ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
}
+109 -7
View File
@@ -137,12 +137,97 @@ export const getStringArray = (
return value;
}
export const trimLower = (
value: string | undefined | null
): string => {
return getStringValue(value).trim().toLowerCase();
}
export const normalizeDomain = (
value: string | undefined | null
): string => {
return trimLower(value);
}
export const normalizeDomains = (domains: string[]): string[] => {
return domains
.map((domain) => normalizeDomain(domain))
.filter((domain) => domain.length > 0);
}
export const getMailDomain = (
value: string | undefined | null
): string => {
const address = getStringValue(value).trim();
const atIndex = address.lastIndexOf("@");
if (atIndex < 0) {
return "";
}
return normalizeDomain(address.slice(atIndex + 1));
}
export const normalizeAddressDomain = (
value: string | undefined | null
): string => {
const address = getStringValue(value).trim();
const atIndex = address.lastIndexOf("@");
if (atIndex < 0) {
return address;
}
const localPart = address.slice(0, atIndex).trim();
const domain = normalizeDomain(address.slice(atIndex + 1));
if (!localPart || !domain) {
return address;
}
return `${localPart}@${domain}`;
}
export const includesDomain = (
domains: string[] | undefined | null,
domain: string | undefined | null
): boolean => {
const normalizedDomain = normalizeDomain(domain);
if (!normalizedDomain || !domains || domains.length === 0) {
return false;
}
return normalizeDomains(domains).includes(normalizedDomain);
}
export const isDomainOrSubdomain = (
domain: string | undefined | null,
allowDomain: string | undefined | null
): boolean => {
const normalizedDomain = normalizeDomain(domain);
const normalizedAllowDomain = normalizeDomain(allowDomain);
if (!normalizedDomain || !normalizedAllowDomain) {
return false;
}
return normalizedDomain === normalizedAllowDomain
|| normalizedDomain.endsWith(`.${normalizedAllowDomain}`);
}
export const getDomainMapValue = <T>(
valueMap: Record<string, T> | undefined | null,
domain: string | undefined | null
): T | null => {
const normalizedDomain = normalizeDomain(domain);
if (!normalizedDomain || !valueMap) {
return null;
}
for (const [key, value] of Object.entries(valueMap)) {
if (normalizeDomain(key) === normalizedDomain) {
return value;
}
}
return null;
}
export const getDefaultDomains = (c: Context<HonoCustomType>): string[] => {
if (c.env.DEFAULT_DOMAINS == undefined || c.env.DEFAULT_DOMAINS == null) {
return getDomains(c);
}
const domains = getStringArray(c.env.DEFAULT_DOMAINS);
return domains || getDomains(c);
const domains = normalizeDomains(getStringArray(c.env.DEFAULT_DOMAINS));
return domains.length > 0 ? domains : getDomains(c);
}
export const getDomains = (c: Context<HonoCustomType>): string[] => {
@@ -152,36 +237,46 @@ export const getDomains = (c: Context<HonoCustomType>): string[] => {
// check if DOMAINS is an array, if not use json.parse
if (!Array.isArray(c.env.DOMAINS)) {
try {
return JSON.parse(c.env.DOMAINS);
return normalizeDomains(JSON.parse(c.env.DOMAINS));
} catch (e) {
console.error("Failed to parse DOMAINS", e);
return [];
}
}
return c.env.DOMAINS;
return normalizeDomains(c.env.DOMAINS);
}
export const getRandomSubdomainDomains = (c: Context<HonoCustomType>): string[] => {
if (!c.env.RANDOM_SUBDOMAIN_DOMAINS) {
return [];
}
return getStringArray(c.env.RANDOM_SUBDOMAIN_DOMAINS);
return normalizeDomains(getStringArray(c.env.RANDOM_SUBDOMAIN_DOMAINS));
}
export const getUserRoles = (c: Context<HonoCustomType>): UserRole[] => {
if (!c.env.USER_ROLES) {
return [];
}
const normalizeRoles = (roles: UserRole[]): UserRole[] => {
return roles.map((role) => ({
...role,
domains: Array.isArray(role.domains)
? normalizeDomains(role.domains)
: typeof role.domains === "string"
? normalizeDomains([role.domains])
: role.domains,
}));
};
// check if USER_ROLES is an array, if not use json.parse
if (!Array.isArray(c.env.USER_ROLES)) {
try {
return JSON.parse(c.env.USER_ROLES);
return normalizeRoles(JSON.parse(c.env.USER_ROLES));
} catch (e) {
console.error("Failed to parse USER_ROLES", e);
return [];
}
}
return c.env.USER_ROLES;
return normalizeRoles(c.env.USER_ROLES);
}
export const getAnotherWorkerList = (c: Context<HonoCustomType>): AnotherWorker[] => {
@@ -414,6 +509,13 @@ export default {
getBooleanValue,
getIntValue,
getStringArray,
trimLower,
normalizeDomain,
normalizeDomains,
getMailDomain,
normalizeAddressDomain,
includesDomain,
getDomainMapValue,
getDefaultDomains,
getDomains,
getRandomSubdomainDomains,
+2 -2
View File
@@ -14,7 +14,7 @@ import { api as telegramApi } from './telegram_api'
import i18n from './i18n';
import { email } from './email';
import { scheduled } from './scheduled';
import { getPasswords, getBooleanValue, getStringArray, checkIsAdmin } from './utils';
import { getPasswords, getBooleanValue, getDomains, checkIsAdmin } from './utils';
import { checkAccessControl } from './ip_blacklist';
const API_PATHS = [
@@ -270,7 +270,7 @@ const health_check = async (c: Context<HonoCustomType>) => {
if (!c.env.JWT_SECRET) {
return c.text(msgs.JWTSecretNotSetMsg, 400);
}
if (getStringArray(c.env.DOMAINS).length === 0) {
if (getDomains(c).length === 0) {
return c.text(msgs.DomainsNotSetMsg, 400);
}
return c.text("OK");
+14 -2
View File
@@ -81,9 +81,19 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# Enable address password feature, if set true, will generate password for new address and support password login and change
# ENABLE_ADDRESS_PASSWORD = false
# Show AI Agent mailbox connection info in the address credential modal
# ENABLE_AGENT_EMAIL_INFO = true
# Show SMTP/IMAP client connection info in the address credential modal
# SMTP_IMAP_PROXY_CONFIG = """
# {
# "smtp": { "host": "smtp.example.com", "port": 8025, "starttls": true },
# "imap": { "host": "imap.example.com", "port": 11143, "starttls": true }
# }
# """
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true
# DISABLE_SHOW_GITHUB_FOR_USER = true
# Status monitoring page URL
# STATUS_URL = "https://status.example.com"
# default send balance, auto initialized when users open settings or send mail; if not set, it will be 0
@@ -128,8 +138,10 @@ ENABLE_AUTO_REPLY = false
# ENABLE_MAIL_GZIP = true
# AI email extraction, automatically extract verification codes, auth links, etc.
# ENABLE_AI_EMAIL_EXTRACT = true
# AI model name, choose from https://developers.cloudflare.com/workers-ai/models/#text-generation
# AI_EXTRACT_MODEL = "@cf/meta/llama-3.1-8b-instruct"
# AI model name, choose from https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models
# Recommended JSON Mode model: "@cf/meta/llama-3.1-8b-instruct-fast"
# Note: "@cf/meta/llama-3.1-8b-instruct" will be deprecated by Cloudflare on 2026-05-30
# AI_EXTRACT_MODEL = "@cf/meta/llama-3.1-8b-instruct-fast"
# Calling other woker to process email
# ENABLE_ANOTHER_WORKER = false
# ANOTHER_WORKER_LIST = """