Compare commits

..
Author SHA1 Message Date
dreamhunter2333 f40b99d877 fix: validate mailbox credentials and secure Telegram unbinding 2026-09-07 18:32:45 +08:00
Dream Hunter 0a0a56e653 fix: clarify cleanup failures and test address activity controls (#1139)
* fix: use general cleanup failure messages

* fix: limit cleanup message change to false results

* test: cover address activity controls in existing e2e workers

* test: keep the default e2e worker entry unchanged

* test: extend existing address activity e2e coverage

* test: prepare historical timestamps through shared D1 files

* test: assert exact cleanup validation errors

* test: exclude D1 metadata when locating the test database

* test: exercise address activity through real API flows

* test: prepare activity fixtures with admin APIs
2026-09-07 00:32:52 +08:00
Dream Hunter 4ddd502a60 chore: upgrade dependencies (#1137) 2026-09-06 23:24:26 +08:00
Dream Hunter 406d1956b8 feat: add address activity disable switch (#1138)
* feat: add address activity disable switch

* style: separate address activity disable guards

* docs: clarify address activity switch behavior
2026-09-06 23:08:41 +08:00
Dream Hunter 66ab5a05df feat: add redemption code system (#1133)
feat: add redemption code support
2026-09-05 22:19:17 +08:00
Dream Hunter 8e1bfb8af2 fix: load AdSense script without event attributes (#1136) 2026-09-05 00:32:34 +08:00
Dream Hunter fc363cc9c5 feat: support runtime frontend configuration (#1135) 2026-09-05 00:15:38 +08:00
Dream Hunter 806ec1aeab fix: prevent Admin password dialog flash (#1134)
* fix: wait for admin access settings before rendering

* test: use frontend route for admin loading check

* test: always release delayed admin settings request

* test: track Admin password dialog during loading
2026-09-04 12:10:27 +08:00
Dream Hunter 3c505db9d0 feat: add admin API IP whitelist (#1131)
* feat: add admin API IP whitelist

* fix: harden admin IP whitelist checks
2026-09-02 01:21:24 +08:00
Dream Hunter 5fd181d9c0 refactor(frontend): unify mailbox terminology
Closes #1129
2026-08-31 22:36:08 +08:00
Dream Hunter 70206c61ef feat: add single-mail read status (#1125) 2026-08-31 13:11:07 +08:00
Dream Hunter f92b059aac feat: add Admin random address name generation (#1127)
feat: add admin random address name
2026-08-27 23:06:54 +08:00
Dream Hunter 5dbb6107dd feat: add user send mail and sent box (#1122)
* feat: add user send mail client

* fix: align user mail navigation

* fix: shorten address credential action

* test: cover user mail ownership boundaries

* fix: address user mail review feedback

* fix: disambiguate user mail e2e heading

* fix: minimize shared sent box changes

* refactor: isolate user send mail page

* refactor: reuse bound address lookup

* fix: clarify user sent box naming

* refactor: decouple user send API from roles

* fix: align user send mail behavior

* fix: align user send role and rate limits

* test: isolate user send rate limits

* test: initialize rate limit worker database

* refactor: simplify user send rate limit

* refactor: inline user send rate limit path

* refactor: simplify user send limiter key

* refactor: keep existing rate limit behavior

* style: simplify user send rate limit condition

* style: group user send rate limit condition

* fix: bind user role token to account

* fix: keep user sender selection available
2026-08-25 14:14:42 +08:00
Dream Hunter dccca92928 fix: align send mail fields and editor caret (#1121)
fix(frontend): align send mail fields and editor caret
2026-08-22 19:31:54 +08:00
Dream Hunter 005d74bfde feat: improve send mail composer (#1120)
feat(frontend): improve send mail composer
2026-08-22 19:08:09 +08:00
Dream Hunter aeb2ac687d fix: 修复 Admin 二级标签页状态丢失 (#1118)
fix: reset admin nested tabs on section switch
2026-08-20 02:20:40 +08:00
Dream Hunter 108b8ef4ac feat: show D1 storage capacity in admin (#1117)
* feat: show D1 storage usage in admin

* test: add D1 storage E2E coverage

* test: use exact D1 storage labels
2026-08-19 16:16:54 +08:00
Dream Hunter 2762741226 feat: upgrade version to v1.12.0 (#1116)
- Update version number to 1.12.0 in all package.json files
- Add v1.12.0 placeholder in CHANGELOG.md
- Move (main) marker from v1.11.0 to v1.12.0
2026-08-19 13:56:17 +08:00
Dream Hunter 3ffa16234f chore: upgrade dependencies (#1115) 2026-08-19 13:45:25 +08:00
Dream Hunter 3bcc0c19ce fix: clarify account and address terminology (#1113)
* fix: clarify account and address terminology

* fix: narrow account and address terminology changes

* fix: simplify mailbox settings label

* fix: align mailbox translations and send docs
2026-08-19 13:31:55 +08:00
Dream Hunter a3c62de42f feat: allow custom subdomains in create UI (#1109)
* feat: allow custom subdomains in create UI

* test: stabilize custom subdomain browser flow

* test: fix normalized address expectation

* fix: gate custom subdomain input by setting

* fix: scope custom subdomains to random domains

* test: recreate random subdomain manually

* refactor: minimize custom subdomain changes

* refactor: reduce custom subdomain changes

* test: fix custom subdomain locator

* refactor: clarify manual subdomain validation

* test: target visible custom subdomain input

* refactor: simplify manual subdomain validation

* refactor: use subdomain mode selector

* style: stack subdomain modes vertically

* test: click visible subdomain mode labels
2026-08-19 12:10:09 +08:00
148 changed files with 11660 additions and 2730 deletions
+40 -1
View File
@@ -6,14 +6,53 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.11.0(main)
## v1.12.0(main)
### Features
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
- feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126
- feat: |Admin API| 新增 `ADMIN_API_IP_WHITELIST`,可按来源 IP 限制所有管理接口访问
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
### Bug Fixes
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload``data-onerror` 属性
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
- fix: |用户发信| 用户地址发信接口支持角色无限额度
### Improvements
- fix: |Frontend| 统一邮箱、邮箱地址、用户账号与管理后台的核心界面术语,并补齐附加语言缺失的邮件状态与远程图片文案(issue #1129
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
### Testing
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
## v1.11.0
### Features
- feat: |Frontend| 在随机子域名允许范围内新增普通、随机和自定义子域名模式选择(issue #1108
### Bug Fixes
- fix: |Frontend| 将邮箱主页的账户设置、删除账户等表述更正为邮箱地址操作,并明确发信权限与额度按当前邮箱地址独立管理
### Improvements
- docs: |发送邮件| 补充用户账号、邮箱地址和发信权限的概念区别及按当前地址申请权限的操作说明
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103
- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属,避免为大用户加载全部绑定地址(issue #1103
+40 -1
View File
@@ -6,14 +6,53 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.11.0(main)
## v1.12.0(main)
### Features
- feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes
- feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html`
- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation
- feat: |Mail| Add optional read/unread status with click-to-read and manual status switching
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
- feat: |Admin| Add one-click random email-name generation to the address creation page (issue #1126)
- feat: |Admin API| Add `ADMIN_API_IP_WHITELIST` to restrict all admin endpoints by source IP
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
### Bug Fixes
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail
- fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script
- fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
- fix: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder
- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs
### Improvements
- fix: |Frontend| Unify core mailbox, email-address, user-account, and Admin Console terminology, and fill missing additional-locale strings for mail status and remote images (issue #1129)
- feat: |Send Mail| Improve the information hierarchy and responsive layout of the user and Admin composers, with a content-format toolbar, draft status, bottom send-action area, and isolated HTML preview
### Testing
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
- fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
## v1.11.0
### Features
- feat: |Frontend| Add Normal, Random, and Custom subdomain mode selection within the random-subdomain scope (issue #1108)
### Bug Fixes
- fix: |Frontend| Relabel mailbox settings and deletion actions as email-address operations, and clarify that send permission and balance are managed independently for the current email address
### Improvements
- docs: |Send Mail| Document the differences between user accounts, email addresses, and send permission, including how to request permission for the currently selected address
- fix: |Worker| Throttle address-activity touches to one write per day so user settings and mailbox access do not repeatedly update recently active addresses, reducing D1 writes (issue #1103)
- feat: |User| Add server-side pagination for bound addresses, with totals queried only on the first page; validate user-mail list ownership with a JOIN and delete ownership with `EXISTS` instead of loading every bound address for large users (issue #1103)
+1
View File
@@ -0,0 +1 @@
ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS redeem_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
redeem_type TEXT NOT NULL,
value TEXT NOT NULL,
result TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
expires_at DATETIME NOT NULL,
redeemed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
+17
View File
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT,
raw_blob BLOB,
metadata TEXT,
is_unread INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -119,3 +120,19 @@ CREATE TABLE IF NOT EXISTS user_passkeys (
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
CREATE TABLE IF NOT EXISTS redeem_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
redeem_type TEXT NOT NULL,
value TEXT NOT NULL,
result TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
expires_at DATETIME NOT NULL,
redeemed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
+1 -1
View File
@@ -1,5 +1,5 @@
# Keep this version in sync with @playwright/test in package.json
FROM mcr.microsoft.com/playwright:v1.62.1-noble
FROM mcr.microsoft.com/playwright:v1.63.0-noble
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
+1
View File
@@ -33,6 +33,7 @@ config.server = {\
"/admin": { target: workerTarget, changeOrigin: true },\
"/user_api": { target: workerTarget, changeOrigin: true },\
"/open_api": { target: workerTarget, changeOrigin: true },\
"/redeem_api": { target: workerTarget, changeOrigin: true },\
"/external": { target: workerTarget, changeOrigin: true },\
"/health_check": { target: workerTarget, changeOrigin: true },\
},\
+1 -1
View File
@@ -16,4 +16,4 @@ COPY ${WRANGLER_TOML} wrangler.toml
EXPOSE 8787
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0", "--test-scheduled"]
+38 -1
View File
@@ -42,7 +42,7 @@ services:
dockerfile: e2e/Dockerfile.worker
ports:
- "8790:8790"
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0", "--test-scheduled"]
volumes:
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
depends_on:
@@ -90,6 +90,24 @@ services:
start_period: 10s
retries: 20
worker-site-password:
build:
context: ..
dockerfile: e2e/Dockerfile.worker
args:
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.site-password
ports:
- "8792:8792"
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8792", "--ip", "0.0.0.0"]
depends_on:
- mailpit
healthcheck:
test: ["CMD", "curl", "-sf", "-H", "x-custom-auth: e2e-site-pass", "http://localhost:8792/health_check"]
interval: 3s
timeout: 5s
start_period: 10s
retries: 20
frontend:
build:
context: ..
@@ -100,6 +118,19 @@ services:
worker:
condition: service_healthy
frontend-env-off:
build:
context: ..
dockerfile: e2e/Dockerfile.frontend
ports:
- "5174:5174"
command: ["pnpm", "exec", "vite", "--port", "5174", "--host", "0.0.0.0"]
environment:
VITE_WORKER_URL: http://worker-env-off:8790
depends_on:
worker-env-off:
condition: service_healthy
smtp-proxy:
build:
context: ../smtp_proxy_server
@@ -147,7 +178,9 @@ services:
WORKER_URL_ENV_OFF: http://worker-env-off:8790
WORKER_GZIP_URL: http://worker-gzip:8788
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
WORKER_URL_SITE_PASSWORD: http://worker-site-password:8792
FRONTEND_URL: https://frontend:5173
FRONTEND_URL_ENV_OFF: https://frontend-env-off:5174
MAILPIT_API: http://mailpit:8025/api
SMTP_PROXY_HOST: smtp-proxy
SMTP_PROXY_SMTP_PORT: "8025"
@@ -167,8 +200,12 @@ services:
condition: service_healthy
worker-send-mail-domain:
condition: service_healthy
worker-site-password:
condition: service_healthy
frontend:
condition: service_started
frontend-env-off:
condition: service_started
smtp-proxy:
condition: service_started
smtp-proxy-tls:
+19 -10
View File
@@ -7,7 +7,9 @@ export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || '';
export const WORKER_URL_SITE_PASSWORD = process.env.WORKER_URL_SITE_PASSWORD || '';
export const FRONTEND_URL = process.env.FRONTEND_URL!;
export const FRONTEND_URL_ENV_OFF = process.env.FRONTEND_URL_ENV_OFF || '';
export const MAILPIT_API = process.env.MAILPIT_API!;
export const TEST_DOMAIN = 'test.example.com';
@@ -27,10 +29,11 @@ export function hashPassword(password: string): string {
export async function createTestAddress(
ctx: APIRequestContext,
name: string,
domain: string = TEST_DOMAIN
domain: string = TEST_DOMAIN,
workerUrl: string = WORKER_URL,
): Promise<{ jwt: string; address: string; address_id: number }> {
const uniqueName = `${name}${Date.now()}`;
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
const res = await ctx.post(`${workerUrl}/api/new_address`, {
data: { name: uniqueName, domain },
});
if (!res.ok()) {
@@ -164,14 +167,20 @@ export function onMailpitMessage(
ws.on('open', () => readyResolve());
ws.on('message', (data: WebSocket.Data) => {
try {
const event = JSON.parse(data.toString());
if (event.Type === 'new' && predicate(event.Data)) {
clearTimeout(timer);
ws.close();
if (!settled) { settled = true; resolve(event.Data); }
}
} catch { /* ignore parse errors */ }
// Mailpit may batch multiple newline-separated events in one message.
for (const line of data.toString().split('\n')) {
if (settled) return;
try {
const event = JSON.parse(line);
if (event.Type === 'new' && predicate(event.Data)) {
clearTimeout(timer);
settled = true;
ws.close();
resolve(event.Data);
return;
}
} catch { /* ignore malformed events without dropping the remaining ones */ }
}
});
ws.on('close', () => {
+9 -1
View File
@@ -7,19 +7,27 @@ keep_vars = true
[vars]
PREFIX = "TMP"
DEFAULT_DOMAINS = []
DOMAINS = ["TEST.EXAMPLE.COM"]
DOMAINS = ["TEST.EXAMPLE.COM", "MANUAL.EXAMPLE.COM"]
RANDOM_SUBDOMAIN_DOMAINS = ["TEST.EXAMPLE.COM"]
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = true
USER_ROLES = [
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
{ domains = [], role = "empty-role", prefix = "EMPTY" },
]
JWT_SECRET = "e2e-test-secret-key"
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
ENABLE_MAIL_READ_STATUS = true
ENABLE_REDEEM_CODE = true
REDEEM_CODE_URL = "https://example.com/redeem-codes"
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
NO_LIMIT_SEND_ROLE = "case-role"
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_USER_ROLE = "admin"
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
+2
View File
@@ -13,6 +13,8 @@ JWT_SECRET = "e2e-test-secret-key-env-off"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = false
DISABLE_ADDRESS_UPDATED_AT = true
ENABLE_REDEEM_CODE = false
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true
@@ -0,0 +1,32 @@
name = "cloudflare_temp_email_site_password"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
[vars]
PREFIX = "tmp"
DEFAULT_DOMAINS = ["test.example.com"]
DOMAINS = ["test.example.com"]
USER_ROLES = [
{ domains = ["test.example.com"], role = "case-role", prefix = "" },
]
ADMIN_USER_ROLE = "admin"
JWT_SECRET = "e2e-site-password-secret"
PASSWORDS = '["e2e-site-pass"]'
BLACK_LIST = ""
ENABLE_REDEEM_CODE = true
ENABLE_ADDRESS_PASSWORD = true
ADDRESS_CHECK_REGEX = "^(?!.*blocked).*$"
DISABLE_ADMIN_PASSWORD_CHECK = false
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
E2E_TEST_MODE = true
[[kv_namespaces]]
binding = "KV"
id = "e2e-test-kv-site-password-00000000-0000-0000-0000-000000000000"
[[d1_databases]]
binding = "DB"
database_name = "e2e-temp-email-site-password"
database_id = "e2e-test-db-site-password-00000000-0000-0000-0000-000000000000"
+40 -69
View File
@@ -6,14 +6,13 @@
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.6.5",
"nodemailer": "^9.0.4"
"imapflow": "^1.7.8",
"nodemailer": "^10.0.0"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@playwright/test": "1.63.0",
"@types/ws": "^8.18.1",
"ws": "^8.21.2"
"ws": "^8.21.3"
}
},
"node_modules/@pinojs/redact": {
@@ -23,13 +22,13 @@
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
@@ -48,16 +47,6 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -69,13 +58,13 @@
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.14",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
"version": "5.4.16",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz",
"integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.4.1",
"libmime": "5.4.3",
"libqp": "2.1.1"
}
},
@@ -89,27 +78,12 @@
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz",
"integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"node": ">=18.0.0"
}
},
"node_modules/iconv-lite": {
@@ -129,16 +103,16 @@
}
},
"node_modules/imapflow": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.6.5.tgz",
"integrity": "sha512-5XgLcfl6blDju2SP7TgTv4WIlTt7zxXYcfjznJFmfFrogHR2XzHGlS9s2wdqxv9ZJ5CPaRnig7KaK7qNoJPmlg==",
"version": "1.7.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz",
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.14",
"encoding-japanese": "2.2.0",
"@zone-eu/mailsplit": "5.4.16",
"encoding-japanese": "2.3.0",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libmime": "5.4.1",
"libmime": "5.4.3",
"libqp": "2.1.1",
"pino": "10.3.1",
"socks": "2.8.9"
@@ -160,12 +134,12 @@
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz",
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz",
"integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"encoding-japanese": "2.3.0",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
@@ -178,12 +152,12 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz",
"integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz",
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
"node": ">=20.0.0"
}
},
"node_modules/on-exit-leak-free": {
@@ -233,28 +207,25 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -378,9 +349,9 @@
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
+5 -5
View File
@@ -3,17 +3,17 @@
"private": true,
"type": "module",
"scripts": {
"test:unit": "node --experimental-strip-types --test tests/unit/*.test.mjs",
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
"test:down": "docker compose down -v"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@playwright/test": "1.63.0",
"@types/ws": "^8.18.1",
"ws": "^8.21.2"
"ws": "^8.21.3"
},
"dependencies": {
"imapflow": "^1.6.5",
"nodemailer": "^9.0.4"
"imapflow": "^1.7.8",
"nodemailer": "^10.0.0"
}
}
+38
View File
@@ -59,6 +59,21 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
done
fi
if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then
echo "==> Waiting for site-password worker at $WORKER_URL_SITE_PASSWORD ..."
for i in $(seq 1 60); do
if curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" "$WORKER_URL_SITE_PASSWORD/health_check" > /dev/null 2>&1; then
echo " Site-password worker ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Site-password worker not ready after 60s"
exit 1
fi
sleep 1
done
fi
echo "==> Waiting for frontend at $FRONTEND_URL ..."
for i in $(seq 1 60); do
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
@@ -72,6 +87,21 @@ for i in $(seq 1 60); do
sleep 1
done
if [ -n "${FRONTEND_URL_ENV_OFF:-}" ]; then
echo "==> Waiting for env-off frontend at $FRONTEND_URL_ENV_OFF ..."
for i in $(seq 1 60); do
if curl --connect-timeout 5 --max-time 10 -skf "$FRONTEND_URL_ENV_OFF" > /dev/null 2>&1; then
echo " Env-off frontend ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Env-off frontend not ready after 60s"
exit 1
fi
sleep 1
done
fi
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
for i in $(seq 1 30); do
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
@@ -110,5 +140,13 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
echo " Gzip worker database initialized"
fi
if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then
echo "==> Initializing site-password worker database"
curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_initialize" > /dev/null
curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_migration" > /dev/null
echo " Site-password worker database initialized"
fi
echo "==> Running Playwright tests"
npm run test:unit
exec npx playwright test "$@"
+325 -1
View File
@@ -1,6 +1,8 @@
import { test, expect } from '@playwright/test';
import { test, expect, type APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
WORKER_URL_ENV_OFF,
onMailpitMessage,
createTestAddress,
deleteAddress,
hashPassword,
@@ -77,3 +79,325 @@ test.describe('Address activity throttling', () => {
}
});
});
const OLD = '2020-01-01 00:00:00';
const RECENT = new Date(Date.now() - 3_600_000).toISOString().replace('T', ' ').slice(0, 19);
type Mailbox = { address: string; address_id: number; jwt: string; password: string };
type User = { id: number; email: string; jwt: string };
for (const { base, disabled } of [
{ base: WORKER_URL, disabled: false },
{ base: WORKER_URL_ENV_OFF, disabled: true },
]) {
test.describe(`Address activity disabled: ${disabled}`, () => {
let mailboxes: Mailbox[];
let users: User[];
let orphanAddresses: string[];
let originalUserSettings: Record<string, unknown>;
async function call(request: APIRequestContext, path: string,
options: Parameters<APIRequestContext['fetch']>[1] = {}, status = 200) {
const response = await request.fetch(`${base}${path}`, options);
expect(response.status(), await response.text()).toBe(status);
return response;
}
async function list(request: APIRequestContext, path: string, query: Record<string, string>) {
return (await call(request, path, { params: { limit: '100', offset: '0', ...query } })).json();
}
async function addressRow(request: APIRequestContext, mailbox: Mailbox) {
return (await list(request, '/admin/address', { query: mailbox.address })).results
.find((row: { name: string }) => row.name === mailbox.address);
}
async function newMailbox(request: APIRequestContext) {
const response = await call(request, '/admin/new_address', {
method: 'POST', data: { name: `activity${Date.now()}${mailboxes.length}`, domain: 'test.example.com' },
});
const mailbox: Mailbox = await response.json();
mailboxes.push(mailbox);
return mailbox;
}
async function newUser(request: APIRequestContext) {
const email = `activity${Date.now()}${users.length}@test.example.com`;
const password = hashPassword('test-password-123');
await call(request, '/admin/users', { method: 'POST', data: { email, password } });
const response = await call(request, '/user_api/login', { method: 'POST', data: { email, password } });
const { jwt } = await response.json();
const { user_id: id } = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
const user = { id, email, jwt };
users.push(user);
return user;
}
const addressAuth = (mailbox: Mailbox) => ({ Authorization: `Bearer ${mailbox.jwt}` });
const userAuth = (user: User) => ({ 'x-user-token': user.jwt });
async function bind(request: APIRequestContext, mailbox: Mailbox, user: User) {
await call(request, '/admin/users/bind_address', {
method: 'POST', data: { user_id: user.id, address_id: mailbox.address_id },
});
}
async function seed(request: APIRequestContext, mailbox: Mailbox, updatedAt: string | null = OLD, createdAt?: string) {
await call(request, '/admin/test/seed_mail', {
method: 'POST', data: {
address: mailbox.address, raw: `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: activity\r\n\r\nBody`,
address_updated_at: updatedAt, address_created_at: createdAt, created_at: OLD,
},
});
expect((await addressRow(request, mailbox)).updated_at).toBe(updatedAt);
}
async function expectActivity(request: APIRequestContext, mailbox: Mailbox, previous: string | null) {
if (disabled || previous === RECENT) {
await waitForNextTimestamp();
expect((await addressRow(request, mailbox)).updated_at).toBe(previous);
return;
}
await expect.poll(async () => (await addressRow(request, mailbox)).updated_at).not.toBe(previous);
expect((await addressRow(request, mailbox)).updated_at).toBeTruthy();
}
async function send(request: APIRequestContext, mailbox: Mailbox, user?: User) {
const subject = `Activity ${disabled} ${Date.now()}`;
const listener = onMailpitMessage(mail => mail.Subject === subject);
await listener.ready;
const [, delivered] = await Promise.all([
call(request, user ? `/user_api/address/${mailbox.address_id}/send_mail` : '/api/send_mail', {
method: 'POST', headers: user ? userAuth(user) : addressAuth(mailbox),
data: { to_mail: 'recipient@test.example.com', subject, content: 'Activity test', is_html: false },
}),
listener.message,
]);
expect(delivered.From.Address).toBe(mailbox.address);
}
async function relatedCounts(request: APIRequestContext, mailbox: Mailbox, user: User) {
const address = await addressRow(request, mailbox);
const inbox = await list(request, '/admin/mails', { address: mailbox.address });
const sent = await list(request, '/admin/sendbox', { address: mailbox.address });
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
let replyMailbox = mailbox;
if (!address) {
await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) }, 401);
// A new credential lets us check that no old auto-reply data survived cleanup.
const [name, domain] = mailbox.address.split('@');
replyMailbox = await (await call(request, '/admin/new_address', {
method: 'POST', data: { name, domain, enablePrefix: false },
})).json();
}
const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(replyMailbox) })).json();
if (!address) await call(request, `/admin/delete_address/${replyMailbox.address_id}`, { method: 'DELETE' });
const bound = await (await call(request, '/user_api/bind_address', { headers: userAuth(user) })).json();
return [Number(!!address), inbox.count, sent.count, sender.count, Number(!!reply.subject),
bound.results.filter((row: { name: string }) => row.name === mailbox.address).length];
}
async function prepareRelated(request: APIRequestContext) {
const mailbox = await newMailbox(request);
const user = await newUser(request);
await bind(request, mailbox, user);
await call(request, '/api/auto_reply', {
method: 'POST', headers: addressAuth(mailbox),
data: { auto_reply: { subject: 'Reply', message: 'Body', enabled: false } },
});
await send(request, mailbox);
await seed(request, mailbox);
expect(await relatedCounts(request, mailbox, user)).toEqual([1, 1, 1, 1, 1, 1]);
return { mailbox, user };
}
test.beforeAll(async ({ request }) => {
expect(base).toBeTruthy();
originalUserSettings = await (await call(request, '/admin/user_settings')).json();
await call(request, '/admin/user_settings', {
method: 'POST', data: { ...originalUserSettings, enable: true, enableMailVerify: false },
});
});
test.afterAll(async ({ request }) => {
await call(request, '/admin/user_settings', { method: 'POST', data: originalUserSettings });
});
test.beforeEach(() => {
expect(base).toBeTruthy();
mailboxes = [];
users = [];
orphanAddresses = [];
});
test.afterEach(async ({ request }) => {
for (const mailbox of mailboxes) {
const row = await addressRow(request, mailbox);
if (row) await call(request, `/admin/delete_address/${row.id}`, { method: 'DELETE' });
}
for (const user of users) await call(request, `/admin/users/${user.id}`, { method: 'DELETE' });
for (const address of orphanAddresses) {
const mails = await list(request, '/admin/mails', { address });
for (const mail of mails.results) await call(request, `/admin/mails/${mail.id}`, { method: 'DELETE' });
}
});
test('repeated user settings reads respect activity tracking and ownership', async ({ request }) => {
const user = await newUser(request);
const otherUser = await newUser(request);
for (const [index, previous] of [OLD, null, RECENT, OLD, OLD].entries()) {
const mailbox = await newMailbox(request);
if (index < 3) await bind(request, mailbox, user);
if (index === 4) await bind(request, mailbox, otherUser);
await seed(request, mailbox, previous);
}
await call(request, '/user_api/settings', { headers: userAuth(user) });
await expectActivity(request, mailboxes[0], OLD);
const after = await Promise.all(mailboxes.map(mailbox => addressRow(request, mailbox)));
expect(after.map(row => row.updated_at).slice(2)).toEqual([RECENT, OLD, OLD]);
if (disabled) expect(after[1].updated_at).toBeNull();
else expect(after[1].updated_at).toBeTruthy();
await call(request, '/user_api/settings', { headers: userAuth(user) });
await waitForNextTimestamp();
expect(await Promise.all(mailboxes.map(mailbox => addressRow(request, mailbox)))).toEqual(after);
});
for (const path of ['/api/settings', '/api/mails?limit=20&offset=0', '/api/parsed_mails?limit=20&offset=0']) {
test(`${path} respects the switch for stale, null and recent activity`, async ({ request }) => {
const mailbox = await newMailbox(request);
const other = await newMailbox(request);
await seed(request, other);
for (const previous of [OLD, null, RECENT]) {
await seed(request, mailbox, previous);
await call(request, path, { headers: addressAuth(mailbox) });
await expectActivity(request, mailbox, previous);
expect((await addressRow(request, other)).updated_at).toBe(OLD);
}
});
}
test('later inbox pages do not refresh activity', async ({ request }) => {
const mailbox = await newMailbox(request);
await seed(request, mailbox);
for (const path of ['/api/mails?limit=20&offset=20', '/api/parsed_mails?limit=20&offset=20']) {
await call(request, path, { headers: addressAuth(mailbox) });
}
await waitForNextTimestamp();
expect((await addressRow(request, mailbox)).updated_at).toBe(OLD);
});
test('incoming mail is stored and both send APIs respect activity tracking', async ({ request }) => {
const mailbox = await newMailbox(request);
const user = await newUser(request);
await bind(request, mailbox, user);
await seed(request, mailbox);
const raw = `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: incoming\r\n\r\nBody`;
const received = await call(request, '/admin/test/receive_mail', {
method: 'POST', data: { from: 'sender@test.example.com', to: mailbox.address, raw },
});
expect((await received.json()).success).toBe(true);
expect((await list(request, '/admin/mails', { address: mailbox.address })).count).toBe(2);
expect((await addressRow(request, mailbox)).updated_at).toBe(OLD);
for (const sender of [undefined, user]) {
await seed(request, mailbox);
await send(request, mailbox, sender);
await expectActivity(request, mailbox, OLD);
}
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
expect(sender.results[0].balance).toBe(8);
expect((await list(request, '/admin/sendbox', { address: mailbox.address })).count).toBe(2);
});
test('password generation, password changes and admin resets keep working', async ({ request }) => {
const mailbox = await newMailbox(request);
expect(mailbox.password).toBeTruthy();
expect((await addressRow(request, mailbox)).updated_at).toBeTruthy();
await call(request, '/api/address_login', {
method: 'POST', data: { email: mailbox.address, password: hashPassword(mailbox.password) },
});
for (const path of ['/api/address_change_password', `/admin/address/${mailbox.address_id}/reset_password`]) {
await seed(request, mailbox);
const password = hashPassword(`password-${path}`);
await call(request, path, {
method: 'POST', headers: addressAuth(mailbox), data: { password, new_password: password },
});
expect((await addressRow(request, mailbox)).updated_at).not.toBe(OLD);
await call(request, '/api/address_login', { method: 'POST', data: { email: mailbox.address, password } });
}
});
test('address transfer still initializes its timestamp and changes ownership', async ({ request }) => {
const mailbox = await newMailbox(request);
const user = await newUser(request);
const target = await newUser(request);
await bind(request, mailbox, user);
await seed(request, mailbox);
await call(request, '/user_api/transfer_address', {
method: 'POST', headers: userAuth(user), data: { address_id: mailbox.address_id, target_user_email: target.email },
});
const transferred = await addressRow(request, mailbox);
expect(transferred.id).not.toBe(mailbox.address_id);
expect(transferred.updated_at).toBeTruthy();
expect(transferred.updated_at).not.toBe(OLD);
const bindings = await (await call(request, '/user_api/bind_address', { headers: userAuth(target) })).json();
expect(bindings.results.map((row: { name: string }) => row.name)).toContain(mailbox.address);
});
test('manual inactivity cleanup protects the address and all related data when disabled', async ({ request }) => {
const { mailbox, user } = await prepareRelated(request);
for (const lang of ['en', 'zh']) {
const response = await call(request, '/admin/cleanup', {
method: 'POST', headers: { 'x-lang': lang }, data: { cleanType: 'inactiveAddress', cleanDays: 1 },
}, disabled ? 500 : 200);
if (disabled) expect(await response.text()).toBe(lang === 'zh'
? '清理失败,请检查清理配置;禁用地址活跃时间更新时,无法按不活跃时间清理。'
: 'Cleanup failed. Check your cleanup settings; inactive-address cleanup is unavailable when address activity updates are disabled.');
else expect(await response.json()).toEqual({ success: true });
expect(await relatedCounts(request, mailbox, user)).toEqual(disabled ? [1, 1, 1, 1, 1, 1] : [0, 0, 0, 0, 0, 0]);
}
});
test('scheduled inactivity cleanup respects the switch and continues later cleanup', async ({ request }) => {
const { mailbox, user } = await prepareRelated(request);
const created = await newMailbox(request);
await seed(request, created, RECENT, OLD);
const original = await (await call(request, '/admin/auto_cleanup')).json();
try {
await call(request, '/admin/auto_cleanup', { method: 'POST', data: {
enableInactiveAddressAutoCleanup: true, cleanInactiveAddressDays: 1,
enableAddressAutoCleanup: true, cleanAddressDays: 1,
} });
await call(request, '/__scheduled');
expect(await relatedCounts(request, mailbox, user)).toEqual(disabled ? [1, 1, 1, 1, 1, 1] : [0, 0, 0, 0, 0, 0]);
expect(await addressRow(request, created)).toBeUndefined();
} finally {
await call(request, '/admin/auto_cleanup', { method: 'POST', data: original || {} });
}
});
for (const cleanType of ['addressCreated', 'unboundAddress', 'emptyAddress', 'mails', 'mails_unknow', 'sendbox']) {
test(`${cleanType} cleanup remains available`, async ({ request }) => {
const mailbox = await newMailbox(request);
await seed(request, mailbox, RECENT, OLD);
let queryAddress = mailbox.address;
if (cleanType === 'emptyAddress') await call(request, `/admin/clear_inbox/${mailbox.address_id}`, { method: 'DELETE' });
if (cleanType === 'mails_unknow') {
queryAddress = `unknown${Date.now()}@test.example.com`;
orphanAddresses.push(queryAddress);
await call(request, '/admin/test/seed_mail', {
method: 'POST', data: { address: queryAddress, raw: 'old unknown mail', created_at: OLD },
});
}
if (cleanType === 'sendbox') {
await send(request, mailbox);
await waitForNextTimestamp();
}
await call(request, '/admin/cleanup', {
method: 'POST', data: { cleanType, cleanDays: cleanType === 'sendbox' ? 0 : 1 },
});
if (['addressCreated', 'unboundAddress', 'emptyAddress'].includes(cleanType)) {
expect(await addressRow(request, mailbox)).toBeUndefined();
} else {
const path = cleanType === 'sendbox' ? '/admin/sendbox' : '/admin/mails';
expect((await list(request, path, { address: queryAddress })).count).toBe(0);
expect(await addressRow(request, mailbox)).toBeTruthy();
}
});
}
test('invalid cleanup requests keep their original error details', async ({ request }) => {
for (const { data, message } of [
{ data: { cleanType: 'invalid', cleanDays: 1 }, message: 'Operation failed: Invalid cleanType' },
{ data: { cleanType: 'mails', cleanDays: -1 }, message: 'Operation failed: Invalid cleanType or cleanDays' },
]) {
const response = await call(request, '/admin/cleanup', { method: 'POST', data }, 500);
expect(await response.text()).toBe(message);
}
});
});
}
+122
View File
@@ -0,0 +1,122 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, seedTestMail } from '../../fixtures/test-helpers';
function signToken(payload: Record<string, unknown>) {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', 'e2e-test-secret-key')
.update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
}
async function expectRejected(request: APIRequestContext, jwt: string) {
for (const [method, path] of [
['GET', '/api/settings'],
['GET', '/api/mails?limit=20&offset=0'],
['GET', '/api/mail/1'],
['GET', '/api/parsed_mails?limit=20&offset=0'],
['GET', '/api/parsed_mail/1'],
['GET', '/api/sendbox?limit=20&offset=0'],
['GET', '/api/auto_reply'],
['POST', '/api/webhook/settings'],
['POST', '/api/attachment/get_url'],
['POST', '/api/address_change_password'],
['POST', '/api/request_send_mail_access'],
['POST', '/api/send_mail'],
['PATCH', '/api/mails/1/read'],
['DELETE', '/api/mails/1'],
['DELETE', '/api/sendbox/1'],
['DELETE', '/api/clear_inbox'],
['DELETE', '/api/clear_sent_items'],
['DELETE', '/api/delete_address'],
]) {
const response = await request.fetch(`${WORKER_URL}${path}`, {
method,
headers: { Authorization: `Bearer ${jwt}` },
...(method === 'POST' || method === 'PATCH' ? { data: {} } : {}),
});
expect(response.status(), `${method} ${path}`).toBe(401);
}
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: jwt },
});
expect(login.status()).toBe(401);
const send = await request.post(`${WORKER_URL}/external/api/send_mail`, {
data: { token: jwt },
});
expect(send.status()).toBe(401);
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${jwt}`,
'x-user-token': signToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 60 }),
},
});
expect(bind.status()).toBe(401);
}
test('deleted credentials cannot access or delete a recreated mailbox', async ({ request }) => {
const name = `credential${Date.now()}`;
const create = async () => {
const response = await request.post(`${WORKER_URL}/api/new_address`, {
data: { name, domain: TEST_DOMAIN },
});
expect(response.ok()).toBe(true);
return await response.json();
};
const original = await create();
await deleteAddress(request, original.jwt);
await expectRejected(request, original.jwt);
const recreated = await create();
try {
expect(recreated.address).toBe(original.address);
expect(recreated.address_id).not.toBe(original.address_id);
await seedTestMail(request, recreated.address, { subject: 'New owner mail' });
await expectRejected(request, original.jwt);
const mails = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
headers: { Authorization: `Bearer ${recreated.jwt}` },
});
expect(mails.ok()).toBe(true);
expect((await mails.json()).count).toBe(1);
} finally {
await deleteAddress(request, recreated.jwt);
}
});
test('valid numeric/string IDs work; missing, invalid and mismatched IDs are rejected', async ({ request }) => {
const mailbox = await createTestAddress(request, 'credential-id');
try {
for (const address_id of [mailbox.address_id, String(mailbox.address_id)]) {
const token = signToken({ address: mailbox.address, address_id });
const settings = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(settings.ok()).toBe(true);
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: token },
});
expect(login.ok()).toBe(true);
}
for (const payload of [
{ address: mailbox.address },
...[0, -1, 1.5, true, null, '', '1e3', {}, Number.MAX_SAFE_INTEGER + 1]
.map(address_id => ({ address: mailbox.address, address_id })),
{ address: `other@${TEST_DOMAIN}`, address_id: mailbox.address_id },
{ address_id: mailbox.address_id },
]) {
const jwt = signToken(payload);
const response = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(response.status(), JSON.stringify(payload)).toBe(401);
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: jwt },
});
expect(login.status(), JSON.stringify(payload)).toBe(401);
}
} finally {
await deleteAddress(request, mailbox.jwt);
}
});
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';
import { WORKER_URL } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test.describe('Admin Config and D1 Storage', () => {
test('reports database size in the database status response', async ({ request }) => {
const response = await request.get(`${WORKER_URL}/admin/db_version`, {
headers: ADMIN_HEADERS,
});
expect(response.ok()).toBe(true);
const body = await response.json();
expect(body.database_size).toEqual(expect.any(Number));
expect(body.database_size).toBeGreaterThan(0);
});
test('saves and retrieves a namespaced config value', async ({ request }) => {
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'd1_storage_plan', value: 'free' },
});
expect(saveResponse.ok()).toBe(true);
expect(await saveResponse.json()).toEqual({
success: true,
key: 'd1_storage_plan',
value: 'free',
});
const getResponse = await request.get(`${WORKER_URL}/admin/config/d1_storage_plan`, {
headers: ADMIN_HEADERS,
});
expect(getResponse.ok()).toBe(true);
expect(await getResponse.json()).toEqual({
key: 'd1_storage_plan',
value: 'free',
});
});
test('keeps config values isolated from internal settings', async ({ request }) => {
const versionBeforeResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
headers: ADMIN_HEADERS,
});
const versionBefore = (await versionBeforeResponse.json()).current_db_version;
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'db_version', value: 'shadow-version' },
});
expect(saveResponse.ok()).toBe(true);
const versionAfterResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
headers: ADMIN_HEADERS,
});
expect((await versionAfterResponse.json()).current_db_version).toBe(versionBefore);
});
test('rejects invalid keys and non-string values', async ({ request }) => {
const invalidKeyResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'invalid key', value: 'value' },
});
expect(invalidKeyResponse.status()).toBe(400);
const invalidValueResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'valid_key', value: 1 },
});
expect(invalidValueResponse.status()).toBe(400);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { expect, test } from '@playwright/test';
import {
WORKER_URL,
WORKER_URL_ENV_OFF,
createTestAddress,
deleteAddress,
seedTestMail,
} from '../../fixtures/test-helpers';
const headers = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
test.describe('Mail read status', () => {
test('keeps historical mail read and switches one new mail state', async ({ request }) => {
const mailbox = await createTestAddress(request, 'mail-read');
try {
const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: {
address: mailbox.address,
source: 'sender@test.example.com',
raw: 'From: sender@test.example.com\r\nSubject: Historical\r\n\r\nHistorical',
},
});
expect(historical.ok()).toBe(true);
await seedTestMail(request, mailbox.address, { subject: 'New unread mail' });
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(mailbox.jwt),
});
const mails = (await list.json()).results;
expect(mails.find((mail: any) => mail.raw.includes('Historical')).is_unread).toBeNull();
const unreadMail = mails.find((mail: any) => mail.raw.includes('New unread mail'));
expect(unreadMail.is_unread).toBe(1);
const markRead = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
headers: headers(mailbox.jwt),
data: { isUnread: false },
});
expect((await markRead.json()).success).toBe(true);
const updated = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(mailbox.jwt),
});
const updatedMail = (await updated.json()).results.find((mail: any) => mail.id === unreadMail.id);
expect(updatedMail.is_unread).toBe(0);
const markUnread = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
headers: headers(mailbox.jwt),
data: { isUnread: true },
});
expect((await markUnread.json()).success).toBe(true);
const restored = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(mailbox.jwt),
});
const restoredMail = (await restored.json()).results.find((mail: any) => mail.id === unreadMail.id);
expect(restoredMail.is_unread).toBe(1);
const invalid = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
headers: headers(mailbox.jwt),
data: { isUnread: 'yes' },
});
expect(invalid.status()).toBe(400);
} finally {
await deleteAddress(request, mailbox.jwt);
}
});
test('scopes the update and keeps disabled instances unchanged', async ({ request }) => {
const first = await createTestAddress(request, 'mail-read-first');
const second = await createTestAddress(request, 'mail-read-second');
try {
await seedTestMail(request, second.address, { subject: 'Second mail' });
const secondList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(second.jwt),
});
const secondMail = (await secondList.json()).results[0];
await request.patch(`${WORKER_URL}/api/mails/${secondMail.id}/read`, {
headers: headers(first.jwt),
data: { isUnread: false },
});
const unchanged = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(second.jwt),
});
expect((await unchanged.json()).results[0].is_unread).toBe(1);
const disabledMailbox = await createTestAddress(
request,
'mail-read-disabled',
'test.example.com',
WORKER_URL_ENV_OFF,
);
const oldList = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
headers: headers(disabledMailbox.jwt),
});
expect(oldList.ok()).toBe(true);
const disabledUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/1/read`, {
headers: headers(disabledMailbox.jwt),
data: { isUnread: false },
});
expect(disabledUpdate.status()).toBe(403);
} finally {
await deleteAddress(request, first.jwt);
await deleteAddress(request, second.jwt);
}
});
});
+305
View File
@@ -0,0 +1,305 @@
import { expect, test } from '@playwright/test';
import { createHmac } from 'node:crypto';
import {
WORKER_URL_ENV_OFF,
WORKER_URL_SITE_PASSWORD,
} from '../../fixtures/test-helpers';
const SITE_HEADERS = { 'x-custom-auth': 'e2e-site-pass' };
const ADMIN_HEADERS = { ...SITE_HEADERS, 'x-admin-auth': 'e2e-admin-pass' };
const futureExpiration = () => new Date(Date.now() + 3_600_000).toISOString();
const signTestToken = (payload: Record<string, unknown>, secret = 'e2e-site-password-secret') => {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
};
const tokenPayload = (role: string) => ({
user_id: 1,
user_role: role,
exp: Math.floor(Date.now() / 1000) + 3600,
});
test.describe('Redemption feature access boundaries', () => {
test('the disabled switch hides every user and Admin endpoint', async ({ request }) => {
const settingsResponse = await request.get(`${WORKER_URL_ENV_OFF}/open_api/settings`);
expect(settingsResponse.ok()).toBe(true);
expect((await settingsResponse.json()).enableRedeemCode).toBe(false);
const requests = [
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/query`, { data: { code: 'anything' } }),
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/result`, { data: { code: 'anything' } }),
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/redeem`, {
data: { code: 'anything', user_email: 'user@test.example.com' },
}),
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes?redeem_type=role`),
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/export?redeem_type=role&limit=1`),
request.post(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/batch`, {
data: {
count: 1,
redeem_type: 'role',
value: 'case-role',
enabled: true,
expires_at: futureExpiration(),
},
}),
request.put(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`, {
data: {
redeem_type: 'role',
value: 'case-role',
enabled: true,
expires_at: futureExpiration(),
},
}),
request.delete(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`),
];
const responses = await Promise.all(requests);
for (const response of responses) {
expect(response.status()).toBe(404);
}
});
test('site password takes priority over otherwise public redemption APIs', async ({ request }) => {
const settingsResponse = await request.get(`${WORKER_URL_SITE_PASSWORD}/open_api/settings`);
expect(settingsResponse.ok()).toBe(true);
expect(await settingsResponse.json()).toMatchObject({
needAuth: true,
enableRedeemCode: true,
});
const blockedAdminResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
{
headers: { 'x-admin-auth': 'e2e-admin-pass' },
data: {
count: 1,
redeem_type: 'role',
value: 'case-role',
enabled: true,
expires_at: futureExpiration(),
},
},
);
expect(blockedAdminResponse.status()).toBe(401);
const createResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
{
headers: ADMIN_HEADERS,
data: {
count: 1,
redeem_type: 'role',
value: 'case-role',
enabled: true,
expires_at: futureExpiration(),
},
},
);
expect(createResponse.ok()).toBe(true);
const code = (await createResponse.json()).codes[0] as string;
for (const path of ['query', 'result', 'redeem']) {
const missingPassword = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
{ data: { code } },
);
expect(missingPassword.status()).toBe(401);
const wrongPassword = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
{ headers: { 'x-custom-auth': 'wrong' }, data: { code } },
);
expect(wrongPassword.status()).toBe(401);
}
const validPassword = await request.post(`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, {
headers: SITE_HEADERS,
data: { code },
});
expect(validPassword.ok()).toBe(true);
expect(await validPassword.json()).toEqual({
redeem_type: 'role', value: 'case-role', status: 'unused',
});
const listResponse = await request.get(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=role`
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
{ headers: ADMIN_HEADERS },
);
const row = (await listResponse.json()).results[0];
const deleteResponse = await request.delete(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
{ headers: ADMIN_HEADERS },
);
expect(deleteResponse.ok()).toBe(true);
});
test('special-address redemption preserves the configured address regex', async ({ request }) => {
const createResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
{
headers: ADMIN_HEADERS,
data: {
count: 1,
redeem_type: 'address_prefix_once',
value: '',
enabled: true,
expires_at: futureExpiration(),
},
},
);
expect(createResponse.ok()).toBe(true);
const code = (await createResponse.json()).codes[0] as string;
const redeemResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/redeem`,
{
headers: SITE_HEADERS,
data: { code, name: 'blocked', domain: 'test.example.com' },
},
);
expect(redeemResponse.status()).toBe(400);
const queryResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`,
{ headers: SITE_HEADERS, data: { code } },
);
expect(queryResponse.ok()).toBe(true);
const listResponse = await request.get(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=address_prefix_once`
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
{ headers: ADMIN_HEADERS },
);
const row = (await listResponse.json()).results[0];
const deleteResponse = await request.delete(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
{ headers: ADMIN_HEADERS },
);
expect(deleteResponse.ok()).toBe(true);
});
});
test.describe('Redemption Admin authentication', () => {
const baseUrl = `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes`;
const listUrl = `${baseUrl}?redeem_type=address_prefix_once&limit=100&offset=0`;
const codeData = () => ({
count: 1,
redeem_type: 'address_prefix_once',
value: 'auth',
enabled: true,
expires_at: futureExpiration(),
});
const deniedCredentials: { name: string; headers: () => Record<string, string> }[] = [
{ name: 'site password alone', headers: () => ({}) },
{ name: 'wrong Admin password', headers: () => ({ 'x-admin-auth': 'wrong' }) },
{
name: 'mailbox JWT',
headers: () => ({
Authorization: `Bearer ${signTestToken({ address: 'mail@test.example.com', address_id: 1 })}`,
}),
},
{
name: 'user account JWT',
headers: () => ({
'x-user-token': signTestToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 3600 }),
}),
},
{
name: 'non-Admin role token',
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('case-role')) }),
},
{
name: 'forged Admin role token',
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('admin'), 'wrong-secret') }),
},
{
name: 'expired Admin role token',
headers: () => ({
'x-user-access-token': signTestToken({ ...tokenPayload('admin'), exp: Math.floor(Date.now() / 1000) - 60 }),
}),
},
{
name: 'Admin role token without expiration',
headers: () => ({ 'x-user-access-token': signTestToken({ user_id: 1, user_role: 'admin' }) }),
},
{ name: 'malformed role token', headers: () => ({ 'x-user-access-token': 'not-a-jwt' }) },
];
for (const credentials of deniedCredentials) {
test(`rejects ${credentials.name} on all five endpoints without changing data`, async ({ request }) => {
const original = codeData();
const created = await request.post(`${baseUrl}/batch`, { headers: ADMIN_HEADERS, data: original });
expect(created.ok()).toBe(true);
const code = (await created.json()).codes[0] as string;
const beforeResponse = await request.get(listUrl, { headers: ADMIN_HEADERS });
expect(beforeResponse.ok()).toBe(true);
const before = await beforeResponse.json();
const row = before.results.find((item: { code: string }) => item.code === code);
expect(row).toBeDefined();
try {
const headers = { ...SITE_HEADERS, ...credentials.headers() };
const responses = await Promise.all([
request.get(listUrl, { headers }),
request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers }),
request.post(`${baseUrl}/batch`, { headers, data: original }),
request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'changed' } }),
request.delete(`${baseUrl}/${row.id}`, { headers }),
]);
for (const response of responses) {
expect(response.status(), response.url()).toBe(401);
expect(await response.text()).not.toContain(code);
}
const after = await request.get(listUrl, { headers: ADMIN_HEADERS });
expect(after.ok()).toBe(true);
expect(await after.json()).toEqual(before);
} finally {
const deleted = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
expect(deleted.ok()).toBe(true);
}
});
}
for (const authType of ['password', 'role token'] as const) {
test(`accepts a valid Admin ${authType} for all five endpoints`, async ({ request }) => {
const headers: Record<string, string> = authType === 'password'
? ADMIN_HEADERS
: { ...SITE_HEADERS, 'x-user-access-token': signTestToken(tokenPayload('admin')) };
const original = codeData();
const created = await request.post(`${baseUrl}/batch`, { headers, data: original });
expect(created.ok()).toBe(true);
const code = (await created.json()).codes[0] as string;
const listResponse = await request.get(listUrl, { headers });
expect(listResponse.ok()).toBe(true);
const row = (await listResponse.json()).results.find((item: { code: string }) => item.code === code);
expect(row).toBeDefined();
let deleted = false;
try {
const exported = await request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers });
expect(exported.ok()).toBe(true);
expect(exported.headers()['content-type']).toContain('text/csv');
expect(await exported.text()).toContain(code);
const updated = await request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'updated' } });
expect(updated.ok()).toBe(true);
const afterUpdate = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
expect(afterUpdate.ok()).toBe(true);
expect((await afterUpdate.json()).results).toEqual([
expect.objectContaining({ id: row.id, code, value: 'updated' }),
]);
const deletion = await request.delete(`${baseUrl}/${row.id}`, { headers });
expect(deletion.ok()).toBe(true);
deleted = true;
const afterDelete = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
expect(afterDelete.ok()).toBe(true);
expect(await afterDelete.json()).toEqual({ results: [], count: 0 });
} finally {
if (!deleted) {
const cleanup = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
expect(cleanup.ok()).toBe(true);
}
}
});
}
});
+336
View File
@@ -0,0 +1,336 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { deleteAddress, WORKER_URL } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
const MAX_BATCH_SIZE = 500;
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
async function createCodes(
request: APIRequestContext,
{
count = 1,
type = 'role',
value = 'case-role',
enabled = true,
expiresAt = new Date(Date.now() + 3_600_000).toISOString(),
}: {
count?: unknown;
type?: string;
value?: unknown;
enabled?: unknown;
expiresAt?: unknown;
},
) {
return await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
headers: ADMIN_HEADERS,
data: {
count,
redeem_type: type,
value,
enabled,
expires_at: expiresAt,
},
});
}
async function listCodes(
request: APIRequestContext,
type: RedeemType,
query = '',
) {
const response = await request.get(
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}&limit=100&offset=0`
+ (query ? `&query=${encodeURIComponent(query)}` : ''),
{ headers: ADMIN_HEADERS },
);
expect(response.ok()).toBe(true);
return await response.json();
}
async function deleteCode(request: APIRequestContext, id: number) {
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${id}`, {
headers: ADMIN_HEADERS,
});
expect(response.ok()).toBe(true);
}
test.describe('Redemption code Admin API', () => {
test('rejects malformed JSON when creating codes', async ({ request }) => {
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
headers: { ...ADMIN_HEADERS, 'content-type': 'application/json' },
data: '{',
});
expect(response.status()).toBe(400);
});
test('reports the enabled switch and exposes the migrated table', async ({ request }) => {
const settingsResponse = await request.get(`${WORKER_URL}/open_api/settings`);
expect(settingsResponse.ok()).toBe(true);
expect((await settingsResponse.json()).enableRedeemCode).toBe(true);
const listResponse = await request.get(
`${WORKER_URL}/admin/redeem_codes?redeem_type=role&limit=20&offset=0`,
{ headers: ADMIN_HEADERS },
);
expect(listResponse.ok()).toBe(true);
await expect(listResponse.json()).resolves.toMatchObject({
results: expect.any(Array),
count: expect.any(Number),
});
});
test('batch generates UUIDs, filters by type, updates, and deletes', async ({ request }) => {
const createResponse = await createCodes(request, { count: 2 });
expect(createResponse.ok()).toBe(true);
const created = await createResponse.json();
expect(created).toMatchObject({ success: true, created: 2 });
expect(created.codes).toHaveLength(2);
expect(new Set(created.codes).size).toBe(2);
expect(created.codes.every((code: string) => UUID_V4_PATTERN.test(code))).toBe(true);
const [firstCode, secondCode] = created.codes as string[];
const queryPrefix = firstCode.slice(0, 8);
const roleList = await listCodes(request, 'role', queryPrefix);
expect(roleList.count).toBe(1);
expect(roleList.results[0]).toMatchObject({
code: firstCode,
redeem_type: 'role',
value: 'case-role',
enabled: 1,
redeemed: false,
result: null,
});
const balanceList = await listCodes(request, 'send_balance', queryPrefix);
expect(balanceList.count).toBe(0);
const firstId = roleList.results[0].id as number;
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstId}`, {
headers: ADMIN_HEADERS,
data: {
redeem_type: 'role',
value: 'empty-role',
enabled: false,
expires_at: expiresAt,
},
});
expect(updateResponse.ok()).toBe(true);
const updatedList = await listCodes(request, 'role', firstCode);
expect(updatedList.count).toBe(1);
expect(updatedList.results[0]).toMatchObject({
id: firstId,
code: firstCode,
value: 'empty-role',
enabled: 0,
});
expect(updatedList.results[0].expires_at).toEqual(expect.any(String));
const secondList = await listCodes(request, 'role', secondCode);
await deleteCode(request, firstId);
await deleteCode(request, secondList.results[0].id);
expect((await listCodes(request, 'role', firstCode)).count).toBe(0);
});
test('normalizes typed values and accepts both empty and maximum usable prefixes', async ({ request }) => {
const emptyResponse = await createCodes(request, {
type: 'address_prefix_once',
value: ' ',
});
expect(emptyResponse.ok()).toBe(true);
const emptyCode = (await emptyResponse.json()).codes[0] as string;
const maxResponse = await createCodes(request, {
type: 'address_prefix_once',
value: ` ${'A'.repeat(29)} `,
});
expect(maxResponse.ok()).toBe(true);
const maxCode = (await maxResponse.json()).codes[0] as string;
const balanceResponse = await createCodes(request, {
type: 'send_balance',
value: ' 42 ',
});
expect(balanceResponse.ok()).toBe(true);
const balanceCode = (await balanceResponse.json()).codes[0] as string;
const emptyRow = (await listCodes(request, 'address_prefix_once', emptyCode)).results[0];
const maxRow = (await listCodes(request, 'address_prefix_once', maxCode)).results[0];
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
expect(emptyRow.value).toBe('');
expect(maxRow.value).toBe('a'.repeat(29));
expect(balanceRow.value).toBe('42');
await deleteCode(request, emptyRow.id);
await deleteCode(request, maxRow.id);
await deleteCode(request, balanceRow.id);
});
test('rejects every invalid batch shape and typed business value', async ({ request }) => {
const invalidRequests = [
{ type: 'unknown', value: 'x' },
{ type: 'role', value: '' },
{ type: 'role', value: 'missing-role' },
{ type: 'role', value: 'admin' },
{ type: 'send_balance', value: '0' },
{ type: 'send_balance', value: '-1' },
{ type: 'send_balance', value: '1.5' },
{ type: 'send_balance', value: '1000000001' },
{ type: 'address_prefix_once', value: 'bad-' },
{ type: 'address_prefix_once', value: 'a'.repeat(30) },
{ count: 0 },
{ count: -1 },
{ count: 1.5 },
{ count: MAX_BATCH_SIZE + 1 },
{ count: '1' },
{ count: null },
{ enabled: 'yes' },
{ value: 1 },
{ expiresAt: null },
{ expiresAt: '' },
{ expiresAt: 'not-a-date' },
{ expiresAt: new Date(Date.now() - 60_000).toISOString() },
];
for (const invalidRequest of invalidRequests) {
const response = await createCodes(request, invalidRequest);
expect(response.status(), JSON.stringify(invalidRequest)).toBe(400);
}
});
test('rejects invalid updates and IDs', async ({ request }) => {
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
const createResponse = await createCodes(request, { count: 2 });
expect(createResponse.ok()).toBe(true);
const [firstCode, secondCode] = (await createResponse.json()).codes as string[];
const firstRow = (await listCodes(request, 'role', firstCode)).results[0];
const secondRow = (await listCodes(request, 'role', secondCode)).results[0];
const invalidId = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
headers: ADMIN_HEADERS,
data: { redeem_type: 'role', value: 'case-role', enabled: true, expires_at: expiresAt },
});
expect(invalidId.status()).toBe(400);
const invalidRole = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
headers: ADMIN_HEADERS,
data: { redeem_type: 'role', value: 'admin', enabled: true, expires_at: expiresAt },
});
expect(invalidRole.status()).toBe(400);
const wrongType = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
headers: ADMIN_HEADERS,
data: {
redeem_type: 'send_balance',
value: '1',
enabled: true,
expires_at: expiresAt,
},
});
expect(wrongType.status()).toBe(404);
const invalidDelete = await request.delete(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
headers: ADMIN_HEADERS,
});
expect(invalidDelete.status()).toBe(400);
await deleteCode(request, firstRow.id);
await deleteCode(request, secondRow.id);
});
test('does not update a redeemed code', async ({ request }) => {
let code = '';
let addressResult: { jwt?: string } | undefined;
try {
const createResponse = await createCodes(request, {
type: 'address_prefix_once',
value: 'locked',
});
expect(createResponse.ok()).toBe(true);
code = (await createResponse.json()).codes[0] as string;
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: 'mail', domain: 'test.example.com' },
});
expect(redeemResponse.ok()).toBe(true);
addressResult = await redeemResponse.json();
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
expect(row.redeemed).toBe(true);
expect(row.redeemed_at).toEqual(expect.any(String));
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
headers: ADMIN_HEADERS,
data: {
redeem_type: 'address_prefix_once',
value: 'changed',
enabled: false,
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
},
});
expect(updateResponse.status()).toBe(409);
const unchanged = (await listCodes(request, 'address_prefix_once', code)).results[0];
expect(unchanged).toMatchObject({
code,
value: 'locked',
enabled: 1,
redeemed: true,
});
await deleteCode(request, row.id);
} finally {
if (addressResult?.jwt) await deleteAddress(request, addressResult.jwt);
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
if (row) await deleteCode(request, row.id);
}
});
test('exports one selected type with its business columns and enforces the limit', async ({ request }) => {
const roleResponse = await createCodes(request, {});
expect(roleResponse.ok()).toBe(true);
const roleCode = (await roleResponse.json()).codes[0] as string;
const balanceResponse = await createCodes(request, {
type: 'send_balance',
value: '25',
});
expect(balanceResponse.ok()).toBe(true);
const balanceCode = (await balanceResponse.json()).codes[0] as string;
const roleExport = await request.get(
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=role&limit=100`,
{ headers: ADMIN_HEADERS },
);
expect(roleExport.ok()).toBe(true);
expect(roleExport.headers()['content-type']).toContain('text/csv');
expect(roleExport.headers()['content-disposition']).toContain('redeem-codes-role.csv');
const roleCsv = await roleExport.text();
expect(roleCsv).toContain('role,redeemed_user_id');
expect(roleCsv).toContain(roleCode);
expect(roleCsv).not.toContain(balanceCode);
const balanceExport = await request.get(
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=send_balance&limit=100`,
{ headers: ADMIN_HEADERS },
);
const balanceCsv = await balanceExport.text();
expect(balanceCsv).toContain('amount,target_address');
expect(balanceCsv).toContain(balanceCode);
expect(balanceCsv).not.toContain(roleCode);
for (const query of [
'redeem_type=unknown&limit=1',
'redeem_type=role&limit=0',
'redeem_type=role&limit=10001',
'redeem_type=role&limit=1x',
'redeem_type=role',
]) {
const response = await request.get(`${WORKER_URL}/admin/redeem_codes/export?${query}`, {
headers: ADMIN_HEADERS,
});
expect(response.status(), query).toBe(400);
}
const roleRow = (await listCodes(request, 'role', roleCode)).results[0];
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
await deleteCode(request, roleRow.id);
await deleteCode(request, balanceRow.id);
});
});
+628
View File
@@ -0,0 +1,628 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
createTestAddress,
deleteAddress,
getAddressSender,
hashPassword,
} from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
type AddressRedeemResult = {
type: 'address_prefix_once';
address: string;
address_id: number;
jwt: string;
password?: string;
};
const uniqueValue = (label: string) => (
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
);
async function createCode(
request: APIRequestContext,
type: RedeemType,
value: string,
options: { enabled?: boolean; expiresAt?: string } = {},
) {
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
headers: ADMIN_HEADERS,
data: {
count: 1,
redeem_type: type,
value,
enabled: options.enabled ?? true,
expires_at: options.expiresAt ?? new Date(Date.now() + 3_600_000).toISOString(),
},
});
expect(response.ok()).toBe(true);
const body = await response.json();
expect(body.codes).toHaveLength(1);
return body.codes[0] as string;
}
async function listCode(request: APIRequestContext, type: RedeemType, code: string) {
const response = await request.get(
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
+ `&limit=100&offset=0&query=${encodeURIComponent(code)}`,
{ headers: ADMIN_HEADERS },
);
expect(response.ok()).toBe(true);
const body = await response.json();
return body.results.find((row: { code: string }) => row.code === code);
}
async function deleteCode(request: APIRequestContext, type: RedeemType, code: string) {
const row = await listCode(request, type, code);
if (!row) return;
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
headers: ADMIN_HEADERS,
});
expect(response.ok()).toBe(true);
}
async function queryCode(request: APIRequestContext, code: unknown) {
return await request.post(`${WORKER_URL}/redeem_api/query`, { data: { code } });
}
async function queryResult(request: APIRequestContext, code: unknown) {
return await request.post(`${WORKER_URL}/redeem_api/result`, { data: { code } });
}
async function createUser(request: APIRequestContext) {
const email = `${uniqueValue('redeem-user')}@test.example.com`;
const createResponse = await request.post(`${WORKER_URL}/admin/users`, {
headers: ADMIN_HEADERS,
data: { email, password: hashPassword('redeem-user-password') },
});
expect(createResponse.ok()).toBe(true);
const listResponse = await request.get(
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
{ headers: ADMIN_HEADERS },
);
const user = (await listResponse.json()).results.find(
(row: { user_email: string }) => row.user_email === email,
);
expect(user).toBeTruthy();
await setUserRole(request, user.id, null);
return { id: user.id as number, email };
}
async function setUserRole(request: APIRequestContext, userId: number, role: string | null) {
const response = await request.post(`${WORKER_URL}/admin/user_roles`, {
headers: ADMIN_HEADERS,
data: { user_id: userId, role_text: role },
});
expect(response.ok()).toBe(true);
}
async function deleteUser(request: APIRequestContext, userId: number) {
await setUserRole(request, userId, null);
const response = await request.delete(`${WORKER_URL}/admin/users/${userId}`, {
headers: ADMIN_HEADERS,
});
expect(response.ok()).toBe(true);
}
test.describe('Redemption code query and guards', () => {
test('previews each supported type without consuming it', async ({ request }) => {
const roleCode = await createCode(request, 'role', 'case-role');
const balanceCode = await createCode(request, 'send_balance', '12');
const addressCode = await createCode(request, 'address_prefix_once', 'vip');
try {
const roleResponse = await queryCode(request, roleCode);
expect(roleResponse.ok()).toBe(true);
expect(await roleResponse.json()).toEqual({
redeem_type: 'role', value: 'case-role', status: 'unused',
});
const balanceResponse = await queryCode(request, balanceCode);
expect(balanceResponse.ok()).toBe(true);
expect(await balanceResponse.json()).toEqual({
redeem_type: 'send_balance', value: '12', status: 'unused',
});
const addressResponse = await queryCode(request, addressCode);
expect(addressResponse.ok()).toBe(true);
expect(await addressResponse.json()).toEqual({
redeem_type: 'address_prefix_once',
value: 'vip',
status: 'unused',
});
expect((await listCode(request, 'role', roleCode)).redeemed_at).toBeNull();
expect((await listCode(request, 'send_balance', balanceCode)).redeemed_at).toBeNull();
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed_at).toBeNull();
expect((await listCode(request, 'role', roleCode)).redeemed).toBe(false);
expect((await listCode(request, 'send_balance', balanceCode)).redeemed).toBe(false);
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed).toBe(false);
expect((await queryResult(request, addressCode)).status()).toBe(400);
} finally {
await deleteCode(request, 'role', roleCode);
await deleteCode(request, 'send_balance', balanceCode);
await deleteCode(request, 'address_prefix_once', addressCode);
}
});
test('rejects missing, malformed, unknown, and disabled codes, but reports expired codes', async ({ request }) => {
const disabledCode = await createCode(request, 'role', 'case-role', { enabled: false });
const expiredCode = await createCode(request, 'role', 'case-role', {
expiresAt: new Date(Date.now() + 5_000).toISOString(),
});
try {
await new Promise((resolve) => setTimeout(resolve, 5_500));
for (const code of [undefined, null, '', 'x'.repeat(257), uniqueValue('missing')]) {
expect((await queryCode(request, code)).status()).toBe(400);
}
expect((await queryCode(request, disabledCode)).status()).toBe(400);
const expiredResponse = await queryCode(request, expiredCode);
expect(expiredResponse.ok()).toBe(true);
expect(await expiredResponse.json()).toEqual({
redeem_type: 'role', value: 'case-role', status: 'expired',
});
} finally {
await deleteCode(request, 'role', disabledCode);
await deleteCode(request, 'role', expiredCode);
}
});
test('rejects malformed JSON request bodies', async ({ request }) => {
const options = {
headers: { 'content-type': 'application/json' },
data: '{',
};
for (const path of ['/query', '/result', '/redeem']) {
const response = await request.post(`${WORKER_URL}/redeem_api${path}`, options);
expect(response.status()).toBe(400);
}
});
test('uses the code type and does not consume when its required target is missing', async ({ request }) => {
const code = await createCode(request, 'send_balance', '9');
try {
const mismatchResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: 'nobody@test.example.com' },
});
expect(mismatchResponse.status()).toBe(400);
expect((await queryCode(request, code)).ok()).toBe(true);
expect((await listCode(request, 'send_balance', code)).redeemed_at).toBeNull();
} finally {
await deleteCode(request, 'send_balance', code);
}
});
});
test.describe('Role redemption', () => {
test('concurrent different roles consume only the winning code', async ({ request }) => {
const user = await createUser(request);
const roles = ['case-role', 'empty-role'];
const codes = await Promise.all(roles.map((role) => createCode(request, 'role', role)));
try {
const responses = await Promise.all(codes.map((code) => (
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email },
})
)));
expect(responses.filter((response) => response.ok())).toHaveLength(1);
expect(responses.filter((response) => response.status() === 409)).toHaveLength(1);
const winner = responses.findIndex((response) => response.ok());
const loser = 1 - winner;
const rows = await Promise.all(codes.map((code) => listCode(request, 'role', code)));
expect(rows[winner].redeemed).toBe(true);
expect(rows[loser]).toMatchObject({ redeemed: false, result: null, redeemed_at: null });
const users = await request.get(
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
{ headers: ADMIN_HEADERS },
);
expect((await users.json()).results[0].role_text).toBe(roles[winner]);
} finally {
for (const code of codes) await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('applies a configured role by case-insensitive account email and consumes once', async ({ request }) => {
const user = await createUser(request);
const code = await createCode(request, 'role', 'case-role');
try {
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email.toUpperCase() },
});
expect(response.ok()).toBe(true);
expect(await response.json()).toEqual({
success: true,
type: 'role',
role: 'case-role',
user_email: user.email,
});
const userResponse = await request.get(
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
{ headers: ADMIN_HEADERS },
);
expect((await userResponse.json()).results[0].role_text).toBe('case-role');
const row = await listCode(request, 'role', code);
expect(row.redeemed).toBe(true);
expect(row.redeemed_at).toEqual(expect.any(String));
expect(JSON.parse(row.result)).toMatchObject({
type: 'role',
user_id: user.id,
user_email: user.email,
role: 'case-role',
});
const publicResult = await queryResult(request, code);
expect(publicResult.ok()).toBe(true);
expect(await publicResult.json()).toEqual({
type: 'role', role: 'case-role', user_email: user.email,
});
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email },
});
expect(repeatedResponse.status()).toBe(400);
expect(await (await queryCode(request, code)).json()).toEqual({
redeem_type: 'role', value: 'case-role', status: 'redeemed',
});
} finally {
await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('accepts the same existing role', async ({ request }) => {
const user = await createUser(request);
const code = await createCode(request, 'role', 'case-role');
try {
await setUserRole(request, user.id, 'case-role');
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email },
});
expect(response.ok()).toBe(true);
} finally {
await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('keeps the code unused when the account is missing or has a conflicting role', async ({ request }) => {
const missingCode = await createCode(request, 'role', 'case-role');
const conflictCode = await createCode(request, 'role', 'case-role');
const user = await createUser(request);
try {
const missingResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: missingCode, user_email: `${uniqueValue('missing')}@test.example.com` },
});
expect(missingResponse.status()).toBe(400);
expect((await queryCode(request, missingCode)).ok()).toBe(true);
await setUserRole(request, user.id, 'empty-role');
const conflictResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: conflictCode, user_email: user.email },
});
expect(conflictResponse.status()).toBe(409);
expect((await queryCode(request, conflictCode)).ok()).toBe(true);
expect((await listCode(request, 'role', conflictCode)).result).toBeNull();
} finally {
await deleteCode(request, 'role', missingCode);
await deleteCode(request, 'role', conflictCode);
await deleteUser(request, user.id);
}
});
});
test.describe('Sending-credit redemption', () => {
test('preserves default credits before the mailbox is first opened', async ({ request }) => {
const address = await createTestAddress(request, 'rcfresh');
const code = await createCode(request, 'send_balance', '7');
try {
const before = await request.get(
`${WORKER_URL}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address.address)}`,
{ headers: ADMIN_HEADERS },
);
expect((await before.json()).results).toHaveLength(0);
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address },
});
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ amount: 7, balance: 17 });
for (let index = 0; index < 2; index++) {
const settings = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${address.jwt}` },
});
expect(settings.ok()).toBe(true);
}
expect((await getAddressSender(request, address.address)).balance).toBe(17);
} finally {
await deleteCode(request, 'send_balance', code);
await deleteAddress(request, address.jwt);
}
});
test('coalesces NULL balance, preserves disabled state, records result, and consumes once', async ({ request }) => {
const address = await createTestAddress(request, 'rcnull');
const code = await createCode(request, 'send_balance', '7');
try {
const settingsResponse = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${address.jwt}` },
});
expect(settingsResponse.ok()).toBe(true);
const sender = await getAddressSender(request, address.address);
const nullResponse = await request.post(`${WORKER_URL}/admin/address_sender`, {
headers: ADMIN_HEADERS,
data: {
address: address.address,
address_id: sender.id,
balance: null,
enabled: false,
},
});
expect(nullResponse.ok()).toBe(true);
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address.toUpperCase() },
});
expect(redeemResponse.ok()).toBe(true);
expect(await redeemResponse.json()).toEqual({
success: true,
type: 'send_balance',
address: address.address,
amount: 7,
balance: 7,
});
const updatedSender = await getAddressSender(request, address.address);
expect(updatedSender.balance).toBe(7);
expect(updatedSender.enabled).toBe(0);
expect(JSON.parse((await listCode(request, 'send_balance', code)).result)).toMatchObject({
type: 'send_balance',
address: address.address,
amount: 7,
});
const publicResult = await queryResult(request, code);
expect(publicResult.ok()).toBe(true);
expect(await publicResult.json()).toEqual({
type: 'send_balance', address: address.address, amount: 7,
});
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address },
});
expect(repeatedResponse.status()).toBe(400);
expect(await (await queryCode(request, code)).json()).toEqual({
redeem_type: 'send_balance', value: '7', status: 'redeemed',
});
} finally {
await deleteCode(request, 'send_balance', code);
await deleteAddress(request, address.jwt);
}
});
test('does not consume when the target address is missing', async ({ request }) => {
const code = await createCode(request, 'send_balance', '5');
try {
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: `${uniqueValue('missing')}@test.example.com` },
});
expect(response.status()).toBe(400);
expect((await queryCode(request, code)).ok()).toBe(true);
expect((await listCode(request, 'send_balance', code)).result).toBeNull();
} finally {
await deleteCode(request, 'send_balance', code);
}
});
test('concurrent requests add the balance exactly once', async ({ request }) => {
const address = await createTestAddress(request, 'rcrace');
const code = await createCode(request, 'send_balance', '5');
try {
await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${address.jwt}` },
});
const before = await getAddressSender(request, address.address);
const responses = await Promise.all([
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address },
}),
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address },
}),
]);
expect(responses.filter((response) => response.ok())).toHaveLength(1);
expect(responses.filter((response) => [400, 409].includes(response.status()))).toHaveLength(1);
expect((await getAddressSender(request, address.address)).balance).toBe(before.balance + 5);
} finally {
await deleteCode(request, 'send_balance', code);
await deleteAddress(request, address.jwt);
}
});
});
test.describe('Special-address redemption', () => {
test('uses the redemption prefix without the system prefix and repeats the complete original result', async ({ request }) => {
const code = await createCode(request, 'address_prefix_once', 'vip');
const name = `a${Math.random().toString(36).slice(2, 8)}`;
let result: AddressRedeemResult | undefined;
try {
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name, domain: 'TEST.EXAMPLE.COM' },
});
expect(firstResponse.ok()).toBe(true);
result = await firstResponse.json();
expect(result).toMatchObject({
type: 'address_prefix_once',
address: `vip${name}@test.example.com`,
address_id: expect.any(Number),
jwt: expect.any(String),
password: expect.any(String),
});
expect(result.address).not.toContain('tmpvip');
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: 'ignored', domain: 'manual.example.com' },
});
expect(repeatedResponse.ok()).toBe(true);
expect(await repeatedResponse.json()).toEqual(result);
const previewResponse = await queryCode(request, code);
expect(previewResponse.ok()).toBe(true);
const preview = await previewResponse.json();
expect(preview).toEqual({
redeem_type: 'address_prefix_once', value: 'vip', status: 'redeemed',
});
const publicResult = await queryResult(request, code);
expect(publicResult.ok()).toBe(true);
expect(await publicResult.json()).toEqual({
type: 'address_prefix_once',
address: result.address,
jwt: result.jwt,
password: result.password,
});
const row = await listCode(request, 'address_prefix_once', code);
expect(JSON.parse(row.result)).toEqual(result);
} finally {
if (result?.jwt) await deleteAddress(request, result.jwt);
await deleteCode(request, 'address_prefix_once', code);
}
});
test('supports an empty prefix and the longest usable prefix', async ({ request }) => {
const emptyCode = await createCode(request, 'address_prefix_once', '');
const maxCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
const emptyName = `n${Math.random().toString(36).slice(2, 8)}`;
let emptyResult: AddressRedeemResult | undefined;
let maxResult: AddressRedeemResult | undefined;
try {
const emptyResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: emptyCode, name: emptyName, domain: 'test.example.com' },
});
expect(emptyResponse.ok()).toBe(true);
emptyResult = await emptyResponse.json();
expect(emptyResult.address).toBe(`${emptyName}@test.example.com`);
const maxResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: maxCode, name: 'z', domain: 'test.example.com' },
});
expect(maxResponse.ok()).toBe(true);
maxResult = await maxResponse.json();
expect(maxResult.address.split('@')[0]).toBe(`${'a'.repeat(29)}z`);
} finally {
if (emptyResult?.jwt) await deleteAddress(request, emptyResult.jwt);
if (maxResult?.jwt) await deleteAddress(request, maxResult.jwt);
await deleteCode(request, 'address_prefix_once', emptyCode);
await deleteCode(request, 'address_prefix_once', maxCode);
}
});
test('keeps length and domain validation without consuming failed codes', async ({ request }) => {
const lengthCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
const domainCode = await createCode(request, 'address_prefix_once', 'vip');
try {
const lengthResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: lengthCode, name: 'zz', domain: 'test.example.com' },
});
expect(lengthResponse.status()).toBe(400);
const domainResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: domainCode, name: 'valid', domain: 'invalid.example.com' },
});
expect(domainResponse.status()).toBe(400);
expect((await queryCode(request, lengthCode)).ok()).toBe(true);
expect((await queryCode(request, domainCode)).ok()).toBe(true);
} finally {
await deleteCode(request, 'address_prefix_once', lengthCode);
await deleteCode(request, 'address_prefix_once', domainCode);
}
});
test('generates a valid random suffix when the user omits the name', async ({ request }) => {
const code = await createCode(request, 'address_prefix_once', 'rnd');
let result: AddressRedeemResult | undefined;
try {
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, domain: 'test.example.com' },
});
expect(response.ok()).toBe(true);
result = await response.json();
const localPart = result.address.split('@')[0];
expect(localPart.startsWith('rnd')).toBe(true);
expect(localPart.length).toBeLessThanOrEqual(30);
expect(localPart.length).toBeGreaterThan(3);
} finally {
if (result?.jwt) await deleteAddress(request, result.jwt);
await deleteCode(request, 'address_prefix_once', code);
}
});
test('does not return an address result after its original address is removed', async ({ request }) => {
const code = await createCode(request, 'address_prefix_once', 'removed');
try {
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: 'mail', domain: 'test.example.com' },
});
const first = await firstResponse.json();
await deleteAddress(request, first.jwt);
const repeatResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: 'ignored', domain: 'test.example.com' },
});
expect(repeatResponse.status()).toBe(400);
const resultResponse = await request.post(`${WORKER_URL}/redeem_api/result`, {
data: { code },
});
expect(resultResponse.status()).toBe(400);
} finally {
await deleteCode(request, 'address_prefix_once', code);
}
});
test('concurrent creations converge on one complete result', async ({ request }) => {
const code = await createCode(request, 'address_prefix_once', 'race');
let result: AddressRedeemResult | undefined;
try {
const responses = await Promise.all([
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: `a${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
}),
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: `b${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
}),
]);
expect(responses.every((response) => response.ok())).toBe(true);
const results = await Promise.all(responses.map((response) => response.json()));
expect(results[1]).toEqual(results[0]);
result = results[0];
expect(result.password).toEqual(expect.any(String));
expect(JSON.parse((await listCode(request, 'address_prefix_once', code)).result)).toEqual(result);
} finally {
if (result?.jwt) await deleteAddress(request, result.jwt);
await deleteCode(request, 'address_prefix_once', code);
}
});
test('expiry stops retrieval of an existing result', async ({ request }) => {
const expiredCode = await createCode(request, 'address_prefix_once', 'exp', {
expiresAt: new Date(Date.now() + 5_000).toISOString(),
});
let expiredResult: AddressRedeemResult | undefined;
try {
expiredResult = await (await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: expiredCode, name: 'mail', domain: 'test.example.com' },
})).json();
await new Promise((resolve) => setTimeout(resolve, 5_500));
expect((await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: expiredCode },
})).status()).toBe(400);
expect(await (await queryCode(request, expiredCode)).json()).toMatchObject({ status: 'expired' });
expect((await queryResult(request, expiredCode)).status()).toBe(400);
} finally {
if (expiredResult?.jwt) await deleteAddress(request, expiredResult.jwt);
await deleteCode(request, 'address_prefix_once', expiredCode);
}
});
});
+29
View File
@@ -51,4 +51,33 @@ test.describe('Send Mail via SMTP', () => {
// Cleanup
await deleteAddress(request, jwt);
});
test('external sending stores mail fields without bearer credentials', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'external-sender');
const mail = {
from_name: 'External sender', to_name: 'Recipient', to_mail: 'recipient@test.example.com',
subject: `External ${Date.now()}`, content: 'External message', is_html: false,
};
try {
const listener = onMailpitMessage(message => message.Subject === mail.subject);
await listener.ready;
const response = await request.post(`${WORKER_URL}/external/api/send_mail`, {
data: { ...mail, token: jwt, extra_credential: 'must-not-be-stored' },
});
expect(response.ok(), await response.text()).toBe(true);
expect((await listener.message).From.Address).toBe(address);
const sendbox = await request.get(`${WORKER_URL}/api/sendbox?limit=20&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(sendbox.ok()).toBe(true);
const { results } = await sendbox.json();
expect(results).toHaveLength(1);
const stored = JSON.parse(results[0].raw);
expect(stored).toMatchObject(mail);
expect(stored).not.toHaveProperty('token');
expect(stored).not.toHaveProperty('extra_credential');
} finally {
await deleteAddress(request, jwt);
}
});
});
+44 -12
View File
@@ -1,13 +1,15 @@
import { test, expect } from '@playwright/test';
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
const MANUAL_BASE_DOMAIN = 'manual.example.com';
const SUBDOMAIN = `team.${MANUAL_BASE_DOMAIN}`;
const NESTED_SUBDOMAIN = `deep.team.${MANUAL_BASE_DOMAIN}`;
const MIXED_CASE_SUBDOMAIN = `TeAm.${MANUAL_BASE_DOMAIN.toUpperCase()}`;
const INVALID_LOOKALIKE_DOMAIN = `bad${MANUAL_BASE_DOMAIN}`;
const INVALID_EMPTY_PREFIX_DOMAIN = `.${MANUAL_BASE_DOMAIN}`;
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${MANUAL_BASE_DOMAIN}`;
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${MANUAL_BASE_DOMAIN}`;
const RANDOM_SUBDOMAIN = `team.${TEST_DOMAIN}`;
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
let originalCreateAddressStoredEnabled: boolean | undefined;
let originalEnvOffStoredEnabled: boolean | undefined;
@@ -119,16 +121,16 @@ test.describe('Create Address Subdomain Match', () => {
);
});
test('persisted false still keeps exact match only', async ({ request }) => {
test('random subdomain scope allows a manually entered subdomain', async ({ request }) => {
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
const uniqueName = `subdomain-default-${Date.now()}`;
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: uniqueName, domain: SUBDOMAIN },
data: { name: uniqueName, domain: RANDOM_SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
expect(res.ok()).toBe(true);
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
});
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
@@ -184,13 +186,43 @@ test.describe('Create Address Subdomain Match', () => {
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
});
test('deleted random subdomain address can be recreated manually', async ({ request }) => {
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
const name = `subreuse${Date.now()}`;
const firstCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
data: { name, domain: TEST_DOMAIN, enableRandomSubdomain: true },
});
expect(firstCreate.ok()).toBe(true);
const firstAddress = await firstCreate.json();
const generatedDomain = firstAddress.address.split('@')[1];
expect(generatedDomain).not.toBe(TEST_DOMAIN);
const firstDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${firstAddress.jwt}` },
});
expect(firstDelete.ok()).toBe(true);
const secondCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
data: { name, domain: generatedDomain },
});
expect(secondCreate.ok()).toBe(true);
const secondAddress = await secondCreate.json();
expect(secondAddress.address).toBe(firstAddress.address);
const secondDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${secondAddress.jwt}` },
});
expect(secondDelete.ok()).toBe(true);
});
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
data: { name: `subdomain-env-off-${Date.now()}`, domain: RANDOM_SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
+99
View File
@@ -0,0 +1,99 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
function initData(userId: number) {
const fields = {
auth_date: String(Math.floor(Date.now() / 1000)),
user: JSON.stringify({ id: userId }),
};
const key = createHmac('sha256', 'WebAppData').update('e2e-telegram-test-token').digest();
const hash = createHmac('sha256', key)
.update(Object.entries(fields).map(([name, value]) => `${name}=${value}`).join('\n'))
.digest('hex');
return new URLSearchParams({ ...fields, hash }).toString();
}
async function bind(request: APIRequestContext, userId: number, jwt: string) {
const response = await request.post(`${WORKER_URL}/telegram/bind_address`, {
data: { initData: initData(userId), jwt },
});
expect(response.ok(), await response.text()).toBe(true);
}
async function unbind(request: APIRequestContext, userId: number, address: string, status = 200) {
const response = await request.post(`${WORKER_URL}/telegram/unbind_address`, {
data: { initData: initData(userId), address },
});
expect(response.status(), await response.text()).toBe(status);
}
async function addressList(request: APIRequestContext, userId: number) {
const response = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
data: { initData: initData(userId) },
});
expect(response.ok(), await response.text()).toBe(true);
return response.json();
}
test('Telegram users can remove their own bindings after another user binds the mailbox', async ({ request }) => {
const mailbox = await createTestAddress(request, 'tg-owner');
const owner = Date.now();
const other = owner + 1;
try {
await bind(request, owner, mailbox.jwt);
await unbind(request, other, mailbox.address, 400);
await unbind(request, owner, mailbox.address);
await bind(request, owner, mailbox.jwt);
await bind(request, other, mailbox.jwt);
await unbind(request, owner, mailbox.address);
expect(await addressList(request, owner)).toEqual([]);
expect(await addressList(request, other)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
await unbind(request, other, mailbox.address);
expect(await addressList(request, other)).toEqual([]);
await bind(request, owner, mailbox.jwt);
await bind(request, other, mailbox.jwt);
await bind(request, owner, mailbox.jwt);
expect(await addressList(request, owner)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
await unbind(request, other, mailbox.address);
await unbind(request, owner, mailbox.address);
expect(await addressList(request, owner)).toEqual([]);
} finally {
await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` },
});
}
});
test('stale Telegram credentials cannot unbind; internal mailbox cleanup still works', async ({ request }) => {
const original = await createTestAddress(request, 'tg-stale');
const owner = Date.now();
await bind(request, owner, original.jwt);
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
expect(deletion.ok()).toBe(true);
const [name, domain] = original.address.split('@');
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
data: { name, domain, enablePrefix: false },
});
expect(creation.ok()).toBe(true);
const recreated = await creation.json();
try {
expect(recreated.address_id).not.toBe(original.address_id);
await unbind(request, owner, recreated.address, 400);
const response = await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${recreated.jwt}` },
});
expect(response.ok(), await response.text()).toBe(true);
const listing = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
data: { initData: initData(owner) },
});
expect(listing.ok()).toBe(true);
expect(await listing.json()).toEqual([]);
} finally {
await request.delete(`${WORKER_URL}/admin/delete_address/${recreated.address_id}`);
}
});
+415
View File
@@ -0,0 +1,415 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
getAddressSender,
hashPassword,
onMailpitMessage,
updateAddressSender,
} from '../../fixtures/test-helpers';
async function createUser(request: APIRequestContext) {
const email = `user-send-${Date.now()}@test.example.com`;
const password = hashPassword('test-password-123');
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: { email, password },
});
expect(registerRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
expect(loginRes.ok()).toBe(true);
const { jwt } = await loginRes.json();
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
return { jwt, userId: payload.user_id as number };
}
async function bindAddress(
request: APIRequestContext,
userJwt: string,
addressJwt: string,
) {
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${addressJwt}`,
'x-user-token': userJwt,
},
});
expect(response.ok()).toBe(true);
}
test.describe('User send mail API', () => {
test('sends and manages sent items for a bound address only', async ({ request }) => {
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
let userId: number | undefined;
let originalUserSettings: Record<string, unknown> | undefined;
try {
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
expect(settingsRes.ok()).toBe(true);
originalUserSettings = await settingsRes.json();
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
...originalUserSettings,
enable: true,
enableMailVerify: false,
maxAddressCount: 0,
},
});
expect(enableUserRes.ok()).toBe(true);
const user = await createUser(request);
userId = user.userId;
const bound = await createTestAddress(request, 'user-send-bound-');
const accessRequest = await createTestAddress(request, 'user-send-access-');
const outsider = await createTestAddress(request, 'usr-outsider-');
addresses.push(bound, accessRequest, outsider);
await bindAddress(request, user.jwt, bound.jwt);
await bindAddress(request, user.jwt, accessRequest.jwt);
const invalidAddressSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/0/settings`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(invalidAddressSettingsRes.status()).toBe(400);
const outsiderSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${outsider.address_id}/settings`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(outsiderSettingsRes.status()).toBe(400);
const outsiderCredentialRes = await request.get(
`${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(outsiderCredentialRes.status()).toBe(400);
const outsiderAccessRes = await request.post(
`${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(outsiderAccessRes.status()).toBe(400);
const outsiderUserSendRes = await request.post(
`${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`,
{
headers: { 'x-user-token': user.jwt },
data: {
to_mail: 'recipient@test.example.com',
subject: 'Forbidden user send',
content: 'This message must not be sent',
is_html: false,
},
},
);
expect(outsiderUserSendRes.status()).toBe(400);
const unauthenticatedSendboxRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
);
expect(unauthenticatedSendboxRes.status()).toBe(401);
const requestAccessRes = await request.post(
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(requestAccessRes.ok()).toBe(true);
const accessSender = await getAddressSender(request, accessRequest.address);
await updateAddressSender(request, {
address: accessRequest.address,
address_id: accessSender.id,
balance: 0,
enabled: true,
});
const duplicateAccessRes = await request.post(
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(duplicateAccessRes.status()).toBe(400);
expect(await duplicateAccessRes.text()).toContain('Already');
const addressSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(addressSettingsRes.ok()).toBe(true);
const addressSettings = await addressSettingsRes.json();
expect(addressSettings.address).toBe(bound.address);
expect(addressSettings.send_balance).toBe(10);
await deleteAllMailpitMessages(request);
const subject = `User API send ${Date.now()}`;
const listener = onMailpitMessage((mail) => mail.Subject === subject);
await listener.ready;
const sendRes = await request.post(
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
{
headers: { 'x-user-token': user.jwt },
data: {
from_name: 'User Sender',
from_mail: outsider.address,
to_name: 'Recipient',
to_mail: 'recipient@test.example.com',
subject,
content: 'Sent through the user API',
is_html: false,
},
},
);
expect(sendRes.ok()).toBe(true);
const delivered = await listener.message;
expect(delivered.From.Address).toBe(bound.address);
const outsiderSubject = `Outsider send ${Date.now()}`;
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${outsider.jwt}` },
data: {
to_mail: 'recipient@test.example.com',
subject: outsiderSubject,
content: 'This sent item must remain inaccessible to the user',
is_html: false,
},
});
expect(outsiderSendRes.ok()).toBe(true);
const outsiderAddressSendboxRes = await request.get(
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
);
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
JSON.parse(item.raw).subject === outsiderSubject
));
expect(outsiderMail).toBeTruthy();
const unauthorizedDeleteRes = await request.delete(
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(unauthorizedDeleteRes.ok()).toBe(true);
const outsiderSendboxAfterDeleteRes = await request.get(
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
);
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
const updatedSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
{ headers: { 'x-user-token': user.jwt } },
);
expect((await updatedSettingsRes.json()).send_balance).toBe(9);
const sender = await getAddressSender(request, bound.address);
await updateAddressSender(request, {
address: bound.address,
address_id: sender.id,
balance: 0,
enabled: true,
});
const noBalanceRes = await request.post(
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
{
headers: { 'x-user-token': user.jwt },
data: {
to_mail: 'recipient@test.example.com',
subject: 'No balance user send',
content: 'This message must not be sent',
is_html: false,
},
},
);
expect(noBalanceRes.status()).toBe(400);
expect(await noBalanceRes.text()).toContain('No balance');
const userSendboxRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(userSendboxRes.ok()).toBe(true);
const userSendbox = await userSendboxRes.json();
expect(userSendbox.count).toBe(1);
expect(userSendbox.results).toHaveLength(1);
expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject);
const filteredSendboxRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(filteredSendboxRes.ok()).toBe(true);
expect((await filteredSendboxRes.json()).count).toBe(1);
const outsiderFilterRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(outsiderFilterRes.ok()).toBe(true);
expect((await outsiderFilterRes.json()).count).toBe(0);
const deleteRes = await request.delete(
`${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(deleteRes.ok()).toBe(true);
const emptySendboxRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
{ headers: { 'x-user-token': user.jwt } },
);
const emptySendbox = await emptySendboxRes.json();
expect(emptySendbox.count).toBe(0);
expect(emptySendbox.results).toHaveLength(0);
} finally {
try {
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
if (userId !== undefined) {
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
}
} finally {
if (originalUserSettings) {
await request.post(`${WORKER_URL}/admin/user_settings`, {
data: originalUserSettings,
});
}
}
}
});
test('applies unlimited balance from the user role access token', async ({ request }) => {
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
const userIds: number[] = [];
let originalUserSettings: Record<string, unknown> | undefined;
try {
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
expect(settingsRes.ok()).toBe(true);
originalUserSettings = await settingsRes.json();
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
...originalUserSettings,
enable: true,
enableMailVerify: false,
maxAddressCount: 0,
},
});
expect(enableUserRes.ok()).toBe(true);
const user = await createUser(request);
userIds.push(user.userId);
const address = await createTestAddress(request, 'user-send-role-');
addresses.push(address);
await bindAddress(request, user.jwt, address.jwt);
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
data: { user_id: user.userId, role_text: 'case-role' },
});
expect(updateRoleRes.ok()).toBe(true);
const accessRes = await request.post(
`${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(accessRes.ok()).toBe(true);
const sender = await getAddressSender(request, address.address);
await updateAddressSender(request, {
address: address.address,
address_id: sender.id,
balance: 0,
enabled: true,
});
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
headers: { 'x-user-token': user.jwt },
});
expect(userSettingsRes.ok()).toBe(true);
const { access_token: accessToken } = await userSettingsRes.json();
expect(accessToken).toBeTruthy();
const userHeaders = {
'x-user-token': user.jwt,
'x-user-access-token': accessToken,
};
const addressSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${address.address_id}/settings`,
{ headers: userHeaders },
);
expect(addressSettingsRes.ok()).toBe(true);
expect((await addressSettingsRes.json()).send_balance).toBe(99999);
const sendRes = await request.post(
`${WORKER_URL}/user_api/address/${address.address_id}/send_mail`,
{
headers: userHeaders,
data: {
to_mail: 'recipient@test.example.com',
subject: `Unlimited role send ${Date.now()}`,
content: 'Sent without consuming address balance',
is_html: false,
},
},
);
expect(sendRes.ok()).toBe(true);
expect((await getAddressSender(request, address.address)).balance).toBe(0);
const otherUser = await createUser(request);
userIds.push(otherUser.userId);
const otherAddress = await createTestAddress(request, 'user-send-other-');
addresses.push(otherAddress);
await bindAddress(request, otherUser.jwt, otherAddress.jwt);
const otherAccessRes = await request.post(
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': otherUser.jwt } },
);
expect(otherAccessRes.ok()).toBe(true);
const otherSender = await getAddressSender(request, otherAddress.address);
await updateAddressSender(request, {
address: otherAddress.address,
address_id: otherSender.id,
balance: 0,
enabled: true,
});
const mixedHeaders = {
'x-user-token': otherUser.jwt,
'x-user-access-token': accessToken,
};
const otherSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`,
{ headers: mixedHeaders },
);
expect(otherSettingsRes.ok()).toBe(true);
expect((await otherSettingsRes.json()).send_balance).toBe(0);
const otherSendRes = await request.post(
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`,
{
headers: mixedHeaders,
data: {
to_mail: 'recipient@test.example.com',
subject: `Mismatched role token ${Date.now()}`,
content: 'This message must not be sent',
is_html: false,
},
},
);
expect(otherSendRes.status()).toBe(400);
expect(await otherSendRes.text()).toContain('No balance');
} finally {
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
await Promise.allSettled(userIds.map((userId) => (
request.delete(`${WORKER_URL}/admin/users/${userId}`)
)));
if (originalUserSettings) {
await request.post(`${WORKER_URL}/admin/user_settings`, {
data: originalUserSettings,
});
}
}
});
});
@@ -0,0 +1,56 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL } from '../../fixtures/test-helpers';
test('waits for access settings before showing the Admin password dialog', async ({ page }) => {
await page.addInitScript(() => {
const testWindow = window as Window & { __adminPasswordRendered?: boolean };
testWindow.__adminPasswordRendered = false;
const detectAdminPassword = () => {
const textNodes = document.createTreeWalker(document, NodeFilter.SHOW_TEXT);
while (textNodes.nextNode()) {
if (textNodes.currentNode.textContent?.trim() === '管理员密码') {
testWindow.__adminPasswordRendered = true;
return;
}
}
};
new MutationObserver(detectAdminPassword).observe(document, {
childList: true,
subtree: true,
characterData: true,
});
});
let releaseSettings!: () => void;
const settingsPending = new Promise<void>((resolve) => {
releaseSettings = resolve;
});
let markSettingsRequested!: () => void;
const settingsRequested = new Promise<void>((resolve) => {
markSettingsRequested = resolve;
});
await page.route('**/open_api/settings', async (route) => {
markSettingsRequested();
await settingsPending;
await route.continue();
});
await page.goto(`${FRONTEND_URL}/zh/admin`);
await settingsRequested;
try {
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
} finally {
releaseSettings();
}
await expect(page.getByText('快速设置', { exact: true })).toBeVisible();
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
expect(await page.evaluate(() => (
window as Window & { __adminPasswordRendered?: boolean }
).__adminPasswordRendered)).toBe(false);
});
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL, TEST_DOMAIN, deleteAddress } from '../../fixtures/test-helpers';
test('create an address with a custom subdomain from the UI', async ({ page, request }) => {
let jwt: string | undefined;
try {
await page.goto(`${FRONTEND_URL}/en/`);
await page.getByRole('button', { name: 'Create Email Address' }).click();
const name = `subui${Date.now()}`;
const createForm = page.locator('.n-tab-pane:visible form');
await createForm.locator('.n-input-group .n-input input').fill(name);
const domainSelect = createForm.locator('.n-input-group .n-select');
await expect(domainSelect.locator('input')).toHaveCount(0);
const normalSubdomain = createForm.getByRole('radio', { name: 'Normal Domain' });
const randomSubdomain = createForm.getByRole('radio', { name: 'Use Random Subdomain' });
const customSubdomain = createForm.getByRole('radio', { name: 'Use Custom Subdomain' });
await expect(normalSubdomain).toBeChecked();
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
await expect(normalSubdomain).not.toBeChecked();
await expect(randomSubdomain).toBeChecked();
await expect(customSubdomain).not.toBeChecked();
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
await expect(randomSubdomain).not.toBeChecked();
await expect(customSubdomain).toBeChecked();
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
await expect(randomSubdomain).toBeChecked();
await expect(customSubdomain).not.toBeChecked();
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
await createForm.getByRole('button', { name: 'Create Email Address' }).click();
const domain = `team.${TEST_DOMAIN}`;
const address = `tmp${name}@${domain}`;
await expect(page.locator('code').getByText(address, { exact: true })).toBeVisible();
await page.waitForFunction(() => Boolean(localStorage.getItem('jwt')));
jwt = await page.evaluate(() => localStorage.getItem('jwt') || undefined);
expect(jwt).toBeTruthy();
} finally {
if (jwt) await deleteAddress(request, jwt);
}
});
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL, WORKER_URL } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test('persists the selected D1 plan and restores it after reload', async ({ page, request }) => {
const seedResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'd1_storage_plan', value: 'free' },
});
expect(seedResponse.ok()).toBe(true);
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'qucickSetup');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
const storagePanel = page.locator('.storage-panel');
const planSelect = storagePanel.locator('.plan-select .n-select');
await expect(storagePanel.getByText('Current Database Size', { exact: true })).toBeVisible();
await expect(storagePanel.getByText('Database Capacity Limit', { exact: true })).toBeVisible();
await expect(storagePanel.getByText('Capacity Usage', { exact: true })).toBeVisible();
await expect(planSelect).toContainText('Free');
await planSelect.click();
await page.locator('.n-base-select-option').filter({ hasText: 'Workers Paid' }).click();
await expect(page.getByText('Workers plan saved')).toBeVisible();
await expect(storagePanel).toContainText('10.0 GB');
await page.reload();
await expect(planSelect).toContainText('Workers Paid');
await expect(storagePanel).toContainText('10.0 GB');
});
+79
View File
@@ -0,0 +1,79 @@
import { expect, request as apiRequest, test } from '@playwright/test';
import {
FRONTEND_URL,
WORKER_URL,
createTestAddress,
deleteAddress,
seedTestMail,
} from '../../fixtures/test-helpers';
test('keeps refresh unread and supports automatic and manual state changes', async ({ page }) => {
const request = await apiRequest.newContext();
let jwt: string | undefined;
try {
const mailbox = await createTestAddress(request, 'mail-read-browser');
jwt = mailbox.jwt;
const subject = `Unread browser mail ${Date.now()}`;
await seedTestMail(request, mailbox.address, { subject });
await page.goto(`${FRONTEND_URL}/en/`);
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
await page.reload();
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
const mail = (await list.json()).results.find((item: any) => item.raw.includes(subject));
expect(mail.is_unread).toBe(1);
await expect(page.locator('.mail-list-unread').filter({ hasText: subject })).toBeVisible();
await page.route('**/api/mails/*/read', async route => {
await new Promise(resolve => setTimeout(resolve, 300));
await route.continue();
});
const waitForReadUpdate = (isUnread: boolean) => page.waitForResponse(response =>
/\/api\/mails\/\d+\/read$/.test(new URL(response.url()).pathname)
&& response.request().method() === 'PATCH'
&& response.request().postDataJSON().isUnread === isUnread
);
const readResponse = waitForReadUpdate(false);
await page.getByText(subject, { exact: true }).click();
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
expect((await readResponse).ok()).toBe(true);
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
const unreadResponse = waitForReadUpdate(true);
await page.getByRole('button', { name: 'Mark as Unread' }).click();
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(1);
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
await expect(page.getByRole('button', { name: 'Mark as Read' })).toHaveClass(/n-button--loading/);
expect((await unreadResponse).ok()).toBe(true);
await expect(page.getByRole('button', { name: 'Mark as Read' })).not.toHaveClass(/n-button--loading/);
const manualReadResponse = waitForReadUpdate(false);
await page.getByRole('button', { name: 'Mark as Read' }).click();
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toHaveClass(/n-button--loading/);
expect((await manualReadResponse).ok()).toBe(true);
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
const updatedList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
const updatedMail = (await updatedList.json()).results.find((item: any) => item.id === mail.id);
expect(updatedMail.is_unread).toBe(0);
} finally {
try {
if (jwt) await deleteAddress(request, jwt);
} finally {
await request.dispose();
}
}
});
+2 -2
View File
@@ -62,8 +62,8 @@ test.describe('Passkey Browser Flow', () => {
// Wait for user settings to load (shows user email)
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
// === Step 2: Click "User Account Settings" tab ===
await page.getByText('User Account Settings').click();
// === Step 2: Click "User Settings" tab ===
await page.getByText('User Settings').click();
// === Step 3: Create a passkey ===
await page.getByRole('button', { name: 'Create Passkey' }).click();
+387
View File
@@ -0,0 +1,387 @@
import { createHash } from 'crypto';
import { expect, test, type APIRequestContext } from '@playwright/test';
import {
createTestAddress,
deleteAddress,
FRONTEND_URL,
FRONTEND_URL_ENV_OFF,
WORKER_URL,
} from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
const uniqueValue = (label: string) => (
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
);
async function createCode(
request: APIRequestContext,
type: 'role' | 'send_balance' | 'address_prefix_once',
value: string,
) {
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
headers: ADMIN_HEADERS,
data: {
count: 1,
redeem_type: type,
value,
enabled: true,
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
},
});
expect(response.ok()).toBe(true);
const body = await response.json();
expect(body.codes).toHaveLength(1);
return body.codes[0] as string;
}
async function findCode(request: APIRequestContext, type: string, code: string) {
const response = await request.get(
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
{ headers: ADMIN_HEADERS },
);
const body = await response.json();
return body.results.find((row: { code: string }) => row.code === code);
}
async function deleteCode(request: APIRequestContext, type: string, code: string) {
const row = await findCode(request, type, code);
if (!row) return;
await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
headers: ADMIN_HEADERS,
});
}
async function createUser(request: APIRequestContext) {
const email = `${uniqueValue('browser-redeem-user')}@test.example.com`;
const password = createHash('sha256').update('browser-redeem-password').digest('hex');
const response = await request.post(`${WORKER_URL}/admin/users`, {
headers: ADMIN_HEADERS,
data: { email, password },
});
expect(response.ok()).toBe(true);
const usersResponse = await request.get(
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
{ headers: ADMIN_HEADERS },
);
const user = (await usersResponse.json()).results[0];
await request.post(`${WORKER_URL}/admin/user_roles`, {
headers: ADMIN_HEADERS,
data: { user_id: user.id, role_text: null },
});
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
expect(loginResponse.ok()).toBe(true);
return { id: user.id as number, email, jwt: (await loginResponse.json()).jwt as string };
}
async function deleteUser(request: APIRequestContext, userId: number) {
await request.post(`${WORKER_URL}/admin/user_roles`, {
headers: ADMIN_HEADERS,
data: { user_id: userId, role_text: null },
});
await request.delete(`${WORKER_URL}/admin/users/${userId}`, { headers: ADMIN_HEADERS });
}
test('the mailbox entry opens the pure redemption page and completes role redemption', async ({ page, request }) => {
const user = await createUser(request);
const code = await createCode(request, 'role', 'case-role');
try {
await page.goto(`${FRONTEND_URL}/en/`);
await expect(page.getByTestId('redeem-entry')).toBeVisible();
await page.getByTestId('redeem-entry').click();
await expect(page).toHaveURL(/\/en\/redeem$/);
await expect(page.getByTestId('redeem-code-link')).toHaveAttribute(
'href', 'https://example.com/redeem-codes',
);
await page.evaluate((jwt) => localStorage.setItem('userJwt', jwt), user.jwt);
const settingsResponse = page.waitForResponse((response) => (
new URL(response.url()).pathname === '/user_api/settings'
));
await page.reload();
await settingsResponse;
await page.getByTestId('redeem-code-input').locator('input').fill(code);
await page.getByRole('button', { name: 'Look up code' }).click();
await expect(page.getByRole('heading', { name: 'Role benefits' })).toBeVisible();
await expect(page.getByTestId('redeem-user-email')).toHaveCount(0);
await page.getByTestId('redeem-now').click();
const userEmailInput = page.getByTestId('redeem-user-email').locator('input');
await expect(userEmailInput).toHaveValue(user.email);
await userEmailInput.fill(user.email.toUpperCase());
const refreshedSettings = page.waitForResponse((response) => (
new URL(response.url()).pathname === '/user_api/settings' && response.ok()
));
await page.getByRole('button', { name: 'Confirm redemption' }).click();
const refreshedUser = await (await refreshedSettings).json();
expect(refreshedUser.user_role.role).toBe('case-role');
expect(refreshedUser.access_token).toEqual(expect.any(String));
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
await expect(page.getByText(user.email, { exact: false })).toBeVisible();
await expect(page.getByText('case-role', { exact: false })).toBeVisible();
const row = await findCode(request, 'role', code);
expect(JSON.parse(row.result)).toMatchObject({ user_email: user.email, role: 'case-role' });
await page.getByRole('button', { name: 'Redeem another code' }).click();
await page.getByTestId('redeem-code-input').locator('input').fill(code);
const nextQuery = page.waitForRequest((request) => (
new URL(request.url()).pathname === '/redeem_api/query'
));
await page.getByRole('button', { name: 'Look up code' }).click();
expect((await nextQuery).headers()['x-user-access-token']).toBe(refreshedUser.access_token);
} finally {
await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('sending credits defaults to the current mailbox and keeps the target editable', async ({ page, request }) => {
const address = await createTestAddress(request, 'rdb-');
const code = await createCode(request, 'send_balance', '3');
try {
await page.goto(`${FRONTEND_URL}/en/`);
await expect(page.getByTestId('redeem-entry')).toBeVisible();
await page.getByTestId('redeem-entry').click();
await expect(page).toHaveURL(/\/en\/redeem$/);
await page.evaluate((jwt) => localStorage.setItem('jwt', jwt), address.jwt);
const settingsResponse = page.waitForResponse((response) => (
new URL(response.url()).pathname === '/api/settings'
));
await page.reload();
await settingsResponse;
await page.getByTestId('redeem-code-input').locator('input').fill(code);
await page.getByRole('button', { name: 'Look up code' }).click();
await page.getByTestId('redeem-now').click();
const targetInput = page.getByTestId('redeem-target-address').locator('input');
await expect(targetInput).toHaveValue(address.address);
await targetInput.fill(address.address.toUpperCase());
await page.getByRole('button', { name: 'Confirm redemption' }).click();
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
} finally {
await deleteCode(request, 'send_balance', code);
await deleteAddress(request, address.jwt);
}
});
test('the special-address page shows and retrieves the same full credentials', async ({ page, request }) => {
const code = await createCode(request, 'address_prefix_once', 'ui');
const name = `m${Math.random().toString(36).slice(2, 8)}`;
let jwt = '';
try {
await page.goto(`${FRONTEND_URL}/en/redeem`);
await page.getByTestId('redeem-code-input').locator('input').fill(code);
await page.getByRole('button', { name: 'Look up code' }).click();
await expect(page.getByRole('heading', { name: 'Custom mailbox' })).toBeVisible();
await expect(page.getByText('Unused', { exact: true })).toBeVisible();
await page.getByTestId('redeem-address-name').locator('input').fill(name);
await page.getByRole('button', { name: 'Redeem mailbox' }).click();
const addressValue = page.getByTestId('address-credential-address');
const passwordValue = page.getByTestId('address-credential-password');
const jwtValue = page.getByTestId('address-credential-jwt');
await expect(addressValue).toHaveText(`ui${name}@test.example.com`);
const firstAddress = await addressValue.innerText();
const firstPassword = await passwordValue.innerText();
const firstJwt = await jwtValue.innerText();
expect(firstPassword).not.toBe('');
expect(firstJwt).not.toBe('');
jwt = firstJwt;
await page.getByRole('button', { name: 'Redeem another code' }).click();
await page.getByTestId('redeem-code-input').locator('input').fill(code);
await page.getByRole('button', { name: 'Look up code' }).click();
await expect(page.getByText('Used', { exact: true })).toBeVisible();
await expect(page.getByText(firstAddress, { exact: false })).toHaveCount(0);
await page.getByRole('button', { name: 'View redemption result' }).click();
await expect(page.getByTestId('address-credential-address')).toHaveText(firstAddress);
await expect(page.getByTestId('address-credential-password')).toHaveText(firstPassword);
await expect(page.getByTestId('address-credential-jwt')).toHaveText(firstJwt);
} finally {
if (jwt) {
await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${jwt}` },
});
}
await deleteCode(request, 'address_prefix_once', code);
}
});
test('Admin can batch-create, search, export, and display a redemption result', async ({ page, request }) => {
let code = '';
const user = await createUser(request);
try {
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'redeemCodes');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
await expect(page.getByTestId('redeem-admin-create')).toBeVisible();
await page.getByTestId('redeem-admin-create').click();
const dialog = page.getByRole('dialog');
await dialog.getByTestId('redeem-admin-count').locator('input').fill('1');
await dialog.getByTestId('redeem-admin-role').click();
await page.locator('.n-base-select-option').filter({ hasText: 'case-role' }).click();
await dialog.getByRole('button', { name: 'Generate' }).click();
await expect(page.getByText('Select a future expiration time')).toBeVisible();
const expirationInput = dialog.locator('.n-date-picker input');
await expirationInput.fill('2099-01-01 00:00:00');
await expirationInput.press('Enter');
const createResponsePromise = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& new URL(response.url()).pathname === '/admin/redeem_codes/batch'
));
const createdCodesDownload = page.waitForEvent('download');
await dialog.getByRole('button', { name: 'Generate' }).click();
const createResponse = await createResponsePromise;
code = (await createResponse.json()).codes[0];
expect((await createdCodesDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
await expect(page.getByText('Redemption codes generated: 1.')).toBeVisible();
await page.getByTestId('redeem-admin-search').locator('input').fill(code);
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText(code, { exact: true })).toBeVisible();
const exportDownload = page.waitForEvent('download');
await page.getByTestId('redeem-admin-export').click();
await expect(page.getByText('Export rows (maximum 10000)')).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Download CSV' }).click();
expect((await exportDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email },
});
expect(redeemResponse.ok()).toBe(true);
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText(user.email, { exact: true })).toBeVisible();
} finally {
if (code) await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('Admin ignores a stale list response after switching redemption type', async ({ page }) => {
let releaseRole!: () => void;
let markRoleRequested!: () => void;
let markRoleCompleted!: () => void;
const roleGate = new Promise<void>((resolve) => { releaseRole = resolve; });
const roleRequested = new Promise<void>((resolve) => { markRoleRequested = resolve; });
const roleCompleted = new Promise<void>((resolve) => { markRoleCompleted = resolve; });
await page.route('**/admin/redeem_codes?*', async (route) => {
const type = new URL(route.request().url()).searchParams.get('redeem_type');
if (type === 'role') {
markRoleRequested();
await roleGate;
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
count: 1,
results: [{
id: 1,
code: 'STALE-ROLE-CODE',
redeem_type: 'role',
value: 'case-role',
result: null,
enabled: 1,
expires_at: '2099-01-01 00:00:00',
redeemed_at: null,
}],
}),
});
markRoleCompleted();
return;
}
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
count: 1,
results: [{
id: 2,
code: 'CURRENT-BALANCE-CODE',
redeem_type: 'send_balance',
value: '20',
result: null,
enabled: 1,
expires_at: '2099-01-01 00:00:00',
redeemed_at: null,
}],
}),
});
});
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'redeemCodes');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
await roleRequested;
await page.getByTestId('redeem-admin-filter-type').click();
await page.locator('.n-base-select-option').filter({ hasText: 'Sending credits' }).click();
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
releaseRole();
await roleCompleted;
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
await expect(page.getByText('STALE-ROLE-CODE', { exact: true })).toHaveCount(0);
});
for (const { locale, roleRequired, roleType, addressType, create, generate, prefixError } of [
{
locale: 'en', roleRequired: 'Select a role', roleType: 'Role benefits',
addressType: 'Custom mailbox', create: 'Batch Generate', generate: 'Generate',
prefixError: 'Use only letters and digits, up to 29 characters. Leave empty for no prefix.',
},
{
locale: 'zh', roleRequired: '请选择角色', roleType: '角色权益',
addressType: '专属邮箱', create: '批量生成', generate: '生成',
prefixError: '前缀仅支持英文字母和数字,最多 29 个字符;留空表示无前缀。',
},
]) {
test(`Admin validates missing role and invalid prefix in ${locale}`, async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'redeemCodes');
});
let createRequests = 0;
page.on('request', (request) => {
if (request.method() === 'POST'
&& new URL(request.url()).pathname === '/admin/redeem_codes/batch') {
createRequests++;
}
});
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
await expect(page.getByTestId('redeem-admin-filter-type')).toContainText(roleType);
await page.getByRole('button', { name: create, exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: generate, exact: true }).click();
await expect(page.getByText(roleRequired, { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'close', exact: true }).click();
await page.getByTestId('redeem-admin-filter-type').click();
await page.locator('.n-base-select-option').filter({ hasText: addressType }).click();
await page.getByRole('button', { name: create, exact: true }).click();
await dialog.locator('input[maxlength]').fill('bad-');
await dialog.getByRole('button', { name: generate, exact: true }).click();
await expect(page.getByText(prefixError, { exact: true })).toBeVisible();
expect(createRequests).toBe(0);
});
}
test('the disabled frontend hides the entry and redirects the page', async ({ page }) => {
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/`);
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/redeem`);
await expect(page).not.toHaveURL(/\/redeem$/);
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
});
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/admin`);
await expect(page.getByTestId('redeem-admin-create')).toHaveCount(0);
await expect(page.getByText('Redemption Codes', { exact: true })).toHaveCount(0);
});
+134
View File
@@ -0,0 +1,134 @@
import { test, expect, request as apiRequest, type Page } from '@playwright/test';
import {
FRONTEND_URL,
createTestAddress,
deleteAddress,
requestSendAccess,
} from '../../fixtures/test-helpers';
const expectEditorOriginsToAlign = async (page: Page) => {
const editorOriginDelta = await page.locator('.compose-textarea').evaluate((editor) => {
const textareaElement = editor.querySelector('textarea');
const placeholder = editor.querySelector('.n-input__placeholder');
if (!textareaElement) throw new Error('compose textarea element not found');
if (!placeholder) throw new Error('compose placeholder element not found');
const textareaBox = textareaElement.getBoundingClientRect();
const placeholderBox = placeholder.getBoundingClientRect();
const textareaStyle = getComputedStyle(textareaElement);
const placeholderStyle = getComputedStyle(placeholder);
return {
x: textareaBox.x + parseFloat(textareaStyle.paddingLeft)
- placeholderBox.x - parseFloat(placeholderStyle.paddingLeft),
y: textareaBox.y + parseFloat(textareaStyle.paddingTop)
- placeholderBox.y - parseFloat(placeholderStyle.paddingTop),
};
});
expect(Math.abs(editorOriginDelta.x)).toBeLessThan(1);
expect(Math.abs(editorOriginDelta.y)).toBeLessThan(1);
};
test.describe('Send mail composer', () => {
test('edits a draft, changes format, and previews HTML', async ({ page }) => {
const api = await apiRequest.newContext();
let jwt: string | undefined;
try {
const created = await createTestAddress(api, 'compose-ui');
jwt = created.jwt;
await requestSendAccess(api, jwt);
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
await page.getByText('Send Mail', { exact: true }).click();
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
await expect(page.locator('.composer-title')).toContainText(created.address);
const expectedFieldOrder = [
'send-mail-sender-address',
'send-mail-sender-name',
'send-mail-recipient-address',
'send-mail-recipient-name',
];
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
(inputs) => inputs.map((input) => input.id)
);
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
const textarea = page.locator('.compose-textarea textarea');
await expectEditorOriginsToAlign(page);
const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
const subject = page.getByRole('textbox', { name: /^Subject/ });
await recipient.fill('recipient@test.example.com');
await subject.fill('Composer preview');
await page.getByText('HTML', { exact: true }).click();
const htmlContent = [
'<h1>Preview heading</h1><p>Preview body</p>',
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
].join('');
let previewDialogAppeared = false;
page.on('dialog', async (dialog) => {
previewDialogAppeared = true;
await dialog.dismiss();
});
await textarea.fill(htmlContent);
await page.getByRole('button', { name: 'Preview' }).click();
const preview = page.locator('.compose-preview');
await expect(preview).toBeVisible();
await expect(preview).toContainText('Preview heading');
await expect(preview.locator('script, [onerror]')).toHaveCount(0);
expect(previewDialogAppeared).toBe(false);
await page.getByRole('button', { name: 'Edit' }).click();
await expect(preview).toBeHidden();
await expect(recipient).toHaveValue('recipient@test.example.com');
await expect(subject).toHaveValue('Composer preview');
await expect(textarea).toHaveValue(htmlContent);
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
await page.getByText('Enviar correo', { exact: true }).click();
await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
await page.getByText('HTML', { exact: true }).click();
const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
await expect(previewButton).toBeVisible();
const previewBox = await previewButton.boundingBox();
expect(previewBox).not.toBeNull();
expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
} finally {
try {
if (jwt) await deleteAddress(api, jwt);
} finally {
await api.dispose();
}
}
});
test('keeps the Admin field order and editor placeholder aligned', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'mails');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
await page.getByText('Send Mail', { exact: true }).click();
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
const expectedFieldOrder = [
'admin-send-mail-sender-address',
'admin-send-mail-sender-name',
'admin-send-mail-recipient-address',
'admin-send-mail-recipient-name',
];
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
(inputs) => inputs.map((input) => input.id)
);
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
await expectEditorOriginsToAlign(page);
});
});
@@ -100,7 +100,7 @@ test.describe('User address pagination browser flow', () => {
return url.pathname === '/user_api/bind_address'
&& url.searchParams.get('limit') === '100';
});
await page.getByText('Mail Box', { exact: true }).click();
await page.getByText('Inbox', { exact: true }).click();
const initialMailboxResponse = await initialMailboxAddressesResponse;
expect(initialMailboxResponse.ok()).toBe(true);
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
+137
View File
@@ -0,0 +1,137 @@
import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test';
import {
FRONTEND_URL,
WORKER_URL,
createTestAddress,
deleteAddress,
hashPassword,
} from '../../fixtures/test-helpers';
async function createUser(request: APIRequestContext) {
const email = `user-send-browser-${Date.now()}@test.example.com`;
const password = hashPassword('test-password-123');
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: { email, password },
});
expect(registerRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
expect(loginRes.ok()).toBe(true);
const { jwt } = await loginRes.json();
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
return { email, jwt, userId: payload.user_id as number };
}
test.describe('User send mail page', () => {
test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => {
const request = await apiRequest.newContext();
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
let userId: number | undefined;
let originalUserSettings: Record<string, unknown> | undefined;
try {
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
expect(settingsRes.ok()).toBe(true);
originalUserSettings = await settingsRes.json();
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
...originalUserSettings,
enable: true,
enableMailVerify: false,
maxAddressCount: 0,
},
});
expect(enableUserRes.ok()).toBe(true);
const user = await createUser(request);
userId = user.userId;
const address = await createTestAddress(request, 'usr-browser-');
const secondAddress = await createTestAddress(request, 'usr-second-');
addresses.push(address, secondAddress);
for (const boundAddress of addresses) {
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${boundAddress.jwt}`,
'x-user-token': user.jwt,
},
});
expect(bindRes.ok()).toBe(true);
}
await page.goto(`${FRONTEND_URL}/en/`);
await page.evaluate((userJwt) => {
localStorage.setItem('userJwt', userJwt);
}, user.jwt);
await page.goto(`${FRONTEND_URL}/en/user`);
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
const credentialResponse = page.waitForResponse((response) => (
response.request().method() === 'GET'
&& new URL(response.url()).pathname
=== `/user_api/bind_address_jwt/${address.address_id}`
));
const addressRow = page.getByRole('row').filter({ hasText: address.address });
await addressRow.getByRole('button', { name: 'Address Credential' }).click();
expect((await credentialResponse).ok()).toBe(true);
await expect(page.getByRole('dialog')).toContainText(address.address);
await page.getByRole('button', { name: 'close' }).click();
const initialSettingsResponse = page.waitForResponse((response) => (
new URL(response.url()).pathname
=== `/user_api/address/${secondAddress.address_id}/settings`
));
await page.getByText('Send Mail', { exact: true }).click();
expect((await initialSettingsResponse).ok()).toBe(true);
await expect(page.locator('.composer-title h2')).toHaveText('Compose email');
const settingsResponse = page.waitForResponse((response) => (
new URL(response.url()).pathname
=== `/user_api/address/${address.address_id}/settings`
));
await page.locator('.address-picker-select').click();
await page.locator('.n-base-select-option').filter({ hasText: address.address }).click();
expect((await settingsResponse).ok()).toBe(true);
await expect(page.locator('.address-picker-select')).toContainText(address.address);
const subject = `Browser user send ${Date.now()}`;
await page.getByRole('textbox', { name: /^Recipient address/ })
.fill('recipient@test.example.com');
await page.getByRole('textbox', { name: /^Subject/ }).fill(subject);
await page.locator('.compose-textarea textarea').fill('Sent from the user page');
const sendResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& new URL(response.url()).pathname
=== `/user_api/address/${address.address_id}/send_mail`
));
const sendboxResponse = page.waitForResponse((response) => (
response.request().method() === 'GET'
&& new URL(response.url()).pathname === '/user_api/sendbox'
));
await page.getByRole('button', { name: 'Send', exact: true }).click();
expect((await sendResponse).ok()).toBe(true);
expect((await sendboxResponse).ok()).toBe(true);
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
await expect(page.locator('.n-thing-header__title').filter({ hasText: subject }))
.toHaveText(subject, { timeout: 15_000 });
} finally {
try {
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
if (userId !== undefined) {
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
}
} finally {
if (originalUserSettings) {
await request.post(`${WORKER_URL}/admin/user_settings`, {
data: originalUserSettings,
});
}
await request.dispose();
}
}
});
});
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { after, before, test } from 'node:test';
import { WebSocketServer } from 'ws';
let server;
let onMailpitMessage;
before(async () => {
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
await once(server, 'listening');
const originalApi = process.env.MAILPIT_API;
process.env.MAILPIT_API = `http://127.0.0.1:${server.address().port}/api`;
try {
({ onMailpitMessage } = await import('../../fixtures/test-helpers.ts'));
} finally {
if (originalApi === undefined) delete process.env.MAILPIT_API;
else process.env.MAILPIT_API = originalApi;
}
});
after(async () => {
for (const client of server.clients) client.terminate();
await new Promise((resolve) => server.close(resolve));
});
const target = { Subject: 'target-message' };
const newEvent = JSON.stringify({ Type: 'new', Data: target });
const statsEvent = JSON.stringify({ Type: 'stats', Data: { total: 1 } });
for (const [name, payload] of [
['single event', newEvent],
['new event followed by stats', `${newEvent}\n${statsEvent}`],
['stats followed by new event', `${statsEvent}\n${newEvent}`],
['malformed and unrelated events before the match',
`invalid-json\n${JSON.stringify({ Type: 'new', Data: { Subject: 'other' } })}\n${newEvent}\n`],
]) {
test(name, async () => {
const connected = once(server, 'connection');
const listener = onMailpitMessage((mail) => mail.Subject === target.Subject, { timeout: 1000 });
const received = assert.doesNotReject(async () => {
assert.deepEqual(await listener.message, target);
});
const [client] = await connected;
await listener.ready;
client.send(payload);
await received;
});
}
+1
View File
@@ -1,3 +1,4 @@
VITE_API_BASE=https://temp-email-api.xxx.xxx
VITE_DEFAULT_LANG=zh
VITE_CF_WEB_ANALY_TOKEN=
VITE_IS_TELEGRAM=false
+1
View File
@@ -14,6 +14,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/logo.png" sizes="any">
<link rel="apple-touch-icon" href="/logo.png">
<script id="app-config" type="application/json">{}</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
</head>
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.11.0",
"version": "1.12.0",
"private": true,
"type": "module",
"scripts": {
@@ -28,16 +28,16 @@
"@vueuse/core": "^14.4.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.19.0",
"dompurify": "^3.4.13",
"axios": "^1.20.0",
"dompurify": "^3.4.15",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.5",
"naive-ui": "^2.45.3",
"postal-mime": "^2.7.6",
"vooks": "^0.2.12",
"vue": "^3.5.41",
"vue": "^3.5.42",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.4.8",
"vue-i18n": "^11.4.10",
"vue-router": "^4.6.4"
},
"devDependencies": {
@@ -50,10 +50,10 @@
"vite": "^7.3.6",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-wasm": "^3.6.0",
"vitest": "^4.1.10",
"vitest": "^4.1.11",
"workbox-build": "^7.4.1",
"workbox-window": "^7.4.1",
"wrangler": "^4.119.0"
"wrangler": "^4.129.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+791 -523
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -3,7 +3,7 @@ import {
darkTheme,
} from 'naive-ui'
import { computed, onMounted, watchEffect } from 'vue'
import { useScript } from '@unhead/vue'
import { useHead } from '@unhead/vue'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables'
@@ -12,12 +12,13 @@ import Footer from './views/Footer.vue';
import { api } from './api'
import { getNaiveLocaleConfig } from './i18n/naive-locale'
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
import { APP_CONFIG } from './config'
const {
isDark, loading, useSideMargin, telegramApp, isTelegram
} = useGlobalState()
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
const adClient = APP_CONFIG.GOOGLE_AD_CLIENT;
const adSlot = APP_CONFIG.GOOGLE_AD_SLOT;
const { locale } = useI18n({ useScope: 'global' });
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
@@ -31,12 +32,13 @@ watchEffect(() => {
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
})
// Load Google Ad script at top level (not inside onMounted)
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
useHead({
script: [{
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: 'anonymous',
}],
})
}
@@ -47,7 +49,7 @@ onMounted(async () => {
console.error(error);
}
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
const token = APP_CONFIG.CF_WEB_ANALY_TOKEN;
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
if (token && !exist) {
@@ -66,7 +68,7 @@ onMounted(async () => {
// check if telegram is enabled
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
const enableTelegram = APP_CONFIG.IS_TELEGRAM;
if (
(typeof enableTelegram === 'boolean' && enableTelegram === true)
||
+71
View File
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const setRuntimeConfig = (config) => {
const element = document.createElement('script')
element.id = 'app-config'
element.type = 'application/json'
element.textContent = JSON.stringify(config)
document.head.appendChild(element)
}
describe('APP_CONFIG', () => {
beforeEach(() => {
vi.resetModules()
vi.stubEnv('VITE_API_BASE', 'https://build.example.com')
vi.stubEnv('VITE_DEFAULT_LANG', 'zh')
vi.stubEnv('VITE_IS_TELEGRAM', 'false')
})
afterEach(() => {
document.querySelector('#app-config')?.remove()
vi.unstubAllEnvs()
})
it('uses build settings when runtime settings are absent', async () => {
const { APP_CONFIG } = await import('../config')
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
})
it('overrides only settings provided by index.html', async () => {
setRuntimeConfig({ API_BASE: 'https://runtime.example.com', DEFAULT_LANG: 'en' })
const { APP_CONFIG } = await import('../config')
expect(APP_CONFIG.API_BASE).toBe('https://runtime.example.com')
expect(APP_CONFIG.DEFAULT_LANG).toBe('en')
})
it('allows an explicit empty runtime value', async () => {
setRuntimeConfig({ API_BASE: '' })
const { APP_CONFIG } = await import('../config')
expect(APP_CONFIG.API_BASE).toBe('')
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
})
it('falls back to build settings for invalid runtime value types', async () => {
setRuntimeConfig({ API_BASE: {}, DEFAULT_LANG: 1, IS_TELEGRAM: [] })
const { APP_CONFIG } = await import('../config')
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
expect(APP_CONFIG.IS_TELEGRAM).toBe('false')
})
it('reads runtime settings only once', async () => {
setRuntimeConfig({ DEFAULT_LANG: 'en' })
const firstImport = await import('../config')
document.querySelector('#app-config').textContent = JSON.stringify({ DEFAULT_LANG: 'de' })
const secondImport = await import('../config')
expect(secondImport.APP_CONFIG).toBe(firstImport.APP_CONFIG)
expect(secondImport.APP_CONFIG.DEFAULT_LANG).toBe('en')
})
})
+8 -3
View File
@@ -6,8 +6,9 @@ import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
import { sanitizeHtml } from '../utils/sanitize-html'
import { APP_CONFIG } from '../config'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const API_BASE = APP_CONFIG.API_BASE || "";
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings, announcement,
@@ -21,7 +22,8 @@ const instance = axios.create({
});
const apiFetch = async (path, options = {}) => {
loading.value = true;
const showLoading = options.showLoading !== false;
if (showLoading) loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
@@ -67,7 +69,7 @@ const apiFetch = async (path, options = {}) => {
}
throw error;
} finally {
loading.value = false;
if (showLoading) loading.value = false;
}
}
@@ -99,6 +101,7 @@ const getOpenSettings = async (message, notification) => {
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableMailReadStatus: res["enableMailReadStatus"] === true,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright,
@@ -108,6 +111,8 @@ const getOpenSettings = async (message, notification) => {
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
enableAddressPassword: res["enableAddressPassword"] || false,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
enableRedeemCode: res["enableRedeemCode"] || false,
redeemCodeUrl: res["redeemCodeUrl"] || "",
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
@@ -0,0 +1,311 @@
<script setup>
import { computed } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { APP_CONFIG } from '@/config'
import { useGlobalState } from '../store'
const props = defineProps({
address: {
type: String,
default: '',
},
jwt: {
type: String,
default: '',
},
addressPassword: {
type: String,
default: '',
},
})
const { openSettings, auth } = useGlobalState()
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
const message = useMessage()
const configuredApiBaseUrl = APP_CONFIG.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>
<div class="credential-content">
<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 v-if="address" class="credential-field">
<span class="credential-label">{{ t('currentAddress') }}</span>
<div class="credential-copy-row">
<code data-testid="address-credential-address" 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 data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div v-if="addressPassword" class="credential-field">
<span class="credential-label">{{ t('addressPassword') }}</span>
<code data-testid="address-credential-password" 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>
</div>
</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,8 +1,9 @@
<script setup>
import { computed } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../store'
import AddressCredentialContent from './AddressCredentialContent.vue'
const props = defineProps({
show: {
@@ -24,299 +25,17 @@ const props = defineProps({
})
const emit = defineEmits(['update:show'])
const { openSettings, auth } = useGlobalState()
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
const message = useMessage()
const { t } = useScopedI18n('components.AddressCredentialModal')
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>
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
</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>
+2 -2
View File
@@ -153,7 +153,7 @@ const refreshAddressOptions = async () => {
if (userJwt.value) {
const userChildren = await buildUserOptions();
if (userChildren.length > 0) {
groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
groups.push({ type: 'group', label: t('linkedMailboxes'), children: userChildren });
}
const userAddressSet = new Set(userChildren.map((item) => item.address));
const localChildren = buildLocalOptions(userAddressSet);
@@ -215,7 +215,7 @@ watch([userJwt, isTelegram, () => settings.value.address], async () => {
<template>
<n-flex class="address-row" align="center" justify="center" :wrap="true">
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
:loading="addressLoading" :placeholder="t('selectMailbox')" @update:value="onAddressChange"
class="address-select" />
<slot name="actions" />
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
+58 -9
View File
@@ -55,6 +55,14 @@ const props = defineProps({
default: false,
required: false
},
enableMailReadStatus: {
type: Boolean,
default: false
},
updateMailReadStatus: {
type: Function,
default: () => { }
},
})
const localFilterKeyword = ref('')
@@ -94,6 +102,29 @@ const data = computed(() => {
});
})
const openMail = (mail) => {
curMail.value = mail
if (mail?.is_unread !== 1 || !props.enableMailReadStatus) return
mail.is_unread = 0
void props.updateMailReadStatus(mail.id, false).catch(() => {
mail.is_unread = 1
})
}
const toggleCurrentMailUnread = async () => {
if (!curMail.value || !props.enableMailReadStatus) return
const mail = curMail.value
const previousValue = mail.is_unread
const isUnread = previousValue !== 1
mail.is_unread = isUnread ? 1 : 0
try {
await props.updateMailReadStatus(mail.id, isUnread)
message.success(t("success"))
} catch {
mail.is_unread = previousValue
}
}
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
@@ -111,12 +142,12 @@ const prevMail = async () => {
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex > 0) {
curMail.value = data.value[currentIndex - 1]
openMail(data.value[currentIndex - 1])
} else if (page.value > 1) {
page.value--
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[data.value.length - 1]
openMail(data.value[data.value.length - 1])
}
}
}
@@ -126,12 +157,12 @@ const nextMail = async () => {
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex < data.value.length - 1) {
curMail.value = data.value[currentIndex + 1]
openMail(data.value[currentIndex + 1])
} else if (count.value > page.value * pageSize.value) {
page.value++
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[0]
openMail(data.value[0])
}
}
}
@@ -205,7 +236,7 @@ const backFirstPageAndRefresh = async () => {
await refresh();
}
const clickRow = async (row) => {
const clickRow = (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
curMail.value = row;
@@ -215,7 +246,7 @@ const clickRow = async (row) => {
curMail.value = null;
return;
}
curMail.value = row;
openMail(row);
};
@@ -397,7 +428,7 @@ onBeforeUnmount(() => {
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
@@ -461,6 +492,7 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
@@ -475,7 +507,7 @@ onBeforeUnmount(() => {
<div v-else class="mail-list-scroll">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
@@ -536,7 +568,8 @@ onBeforeUnmount(() => {
</div>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="{ 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
@@ -567,6 +600,7 @@ onBeforeUnmount(() => {
<n-card :bordered="false" embedded style="overflow: auto;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:onSaveToS3="saveToS3Proxy" />
</n-card>
@@ -676,6 +710,21 @@ onBeforeUnmount(() => {
min-width: 0;
}
.mail-list-unread :deep(.n-thing-header__title) {
font-weight: 700;
}
.mail-list-unread :deep(.n-thing-header__title)::before {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 8px;
border-radius: 50%;
background: #2080f0;
content: '';
vertical-align: middle;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
@@ -34,6 +34,10 @@ const props = defineProps({
type: Boolean,
default: false
},
enableMailReadStatus: {
type: Boolean,
default: false
},
// props
onDelete: {
type: Function,
@@ -50,6 +54,10 @@ const props = defineProps({
onSaveToS3: {
type: Function,
default: () => { }
},
onUpdateMailReadStatus: {
type: Function,
default: () => { }
}
});
@@ -57,6 +65,7 @@ const showTextMail = ref(preferShowTextMail.value);
const showAttachments = ref(false);
const curAttachments = ref([]);
const attachmentLoding = ref(false);
const readStatusUpdating = ref(false);
const showFullscreen = ref(false);
// Per-mail consent, deliberately independent of the global setting: it only
@@ -96,6 +105,15 @@ const handleForward = () => {
props.onForward();
};
const handleUpdateMailReadStatus = async () => {
readStatusUpdating.value = true;
try {
await props.onUpdateMailReadStatus();
} finally {
readStatusUpdating.value = false;
}
};
const handleSaveToS3 = async (filename, blob) => {
attachmentLoding.value = true;
@@ -138,6 +156,11 @@ const handleSaveToS3 = async (filename, blob) => {
{{ t('attachments') }}
</n-button>
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" :loading="readStatusUpdating"
@click="handleUpdateMailReadStatus">
{{ mail.is_unread === 1 ? t('markAsRead') : t('markAsUnread') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
:href="getDownloadEmlUrl(mail.raw)">
<template #icon>
+39
View File
@@ -0,0 +1,39 @@
type RuntimeConfig = Record<string, unknown>
const getRuntimeConfig = (): RuntimeConfig => {
if (typeof document === 'undefined') return {}
const content = document.querySelector('#app-config')?.textContent
if (!content?.trim()) return {}
try {
const config = JSON.parse(content)
return config && typeof config === 'object' && !Array.isArray(config) ? config : {}
} catch (error) {
console.error('Failed to parse app config', error)
return {}
}
}
const runtimeConfig = getRuntimeConfig()
const getStringConfigValue = (key: string, buildValue: string): string => {
const runtimeValue = runtimeConfig[key]
return typeof runtimeValue === 'string' ? runtimeValue : buildValue
}
const getTelegramConfigValue = (buildValue: string): string | boolean => {
const runtimeValue = runtimeConfig.IS_TELEGRAM
return typeof runtimeValue === 'string' || typeof runtimeValue === 'boolean'
? runtimeValue
: buildValue
}
export const APP_CONFIG = {
API_BASE: getStringConfigValue('API_BASE', import.meta.env.VITE_API_BASE || ''),
DEFAULT_LANG: getStringConfigValue('DEFAULT_LANG', import.meta.env.VITE_DEFAULT_LANG || ''),
CF_WEB_ANALY_TOKEN: getStringConfigValue('CF_WEB_ANALY_TOKEN', import.meta.env.VITE_CF_WEB_ANALY_TOKEN || ''),
IS_TELEGRAM: getTelegramConfigValue(import.meta.env.VITE_IS_TELEGRAM || ''),
GOOGLE_AD_CLIENT: getStringConfigValue('GOOGLE_AD_CLIENT', import.meta.env.VITE_GOOGLE_AD_CLIENT || ''),
GOOGLE_AD_SLOT: getStringConfigValue('GOOGLE_AD_SLOT', import.meta.env.VITE_GOOGLE_AD_SLOT || ''),
} as const
+74 -28
View File
@@ -1,14 +1,44 @@
export const deMessages = {
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
"views.index.SendMail.composeMail": "E-Mail verfassen",
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
"views.index.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
"views.index.SendMail.recipientAddress": "Empfängeradresse",
"views.index.SendMail.recipientName": "Empfängername (optional)",
"views.index.SendMail.senderAddress": "Absenderadresse",
"views.index.SendMail.senderName": "Absendername (optional)",
"views.admin.SendMail.adminComposeTip": "Von einer konfigurierten E-Mail-Adresse senden",
"views.admin.SendMail.composeMail": "E-Mail verfassen",
"views.admin.SendMail.contentPlaceholder": "Nachricht schreiben...",
"views.admin.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
"views.admin.SendMail.recipientAddress": "Empfängeradresse",
"views.admin.SendMail.recipientName": "Empfängername (optional)",
"views.admin.SendMail.senderAddress": "Absenderadresse",
"views.admin.SendMail.senderName": "Absendername (optional)",
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Workers-Tarif",
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare-Workers-Tarif auswählen",
"views.admin.DatabaseManager.planSaved": "Workers-Tarif gespeichert",
"views.admin.DatabaseManager.single_database_limit": "Datenbank-Kapazitätslimit",
"views.admin.DatabaseManager.storage_description": "Vergleiche die aktuelle Datenbankgröße mit den Tariflimits.",
"views.admin.DatabaseManager.storage_tip": "Die Auslastung wird anhand der aktuellen Datenbankgröße und des gewählten Tariflimits berechnet.",
"views.admin.DatabaseManager.storage_title": "D1-Speicherkapazität",
"views.admin.DatabaseManager.storage_usage": "Kapazitätsauslastung",
"views.admin.DatabaseManager.unavailable": "Nicht verfügbar",
"views.index.SimpleIndex.mailCount": "{current} / {total} E-Mails",
"views.admin.Statistics.activeAddressCount30days": "Aktive Adressanzahl in 30 Tagen",
"views.admin.Statistics.activeAddressCount7days": "Aktive Adressanzahl in 7 Tagen",
"views.Index.about": "Über",
"views.Admin.about": "Über",
"views.Header.accessHeader": "Zugangspasswort",
"views.Admin.account": "Konto",
"views.Index.accountSettings": "Kontoeinstellungen",
"views.Admin.account_settings": "Kontoeinstellungen",
"views.index.SimpleIndex.accountSettings": "Kontoeinstellungen",
"views.Header.admin": "Admin-Konsole",
"views.Admin.mailbox_management": "Postfachverwaltung",
"views.Admin.mailbox_list": "Postfächer",
"views.Index.mailboxSettings": "Postfacheinstellungen",
"views.Admin.mailbox_settings": "Globale Postfacheinstellungen",
"views.index.SimpleIndex.mailboxSettings": "Postfacheinstellungen",
"views.index.Attachment.action": "Aktion",
"views.admin.SenderAccess.action": "Aktion",
"views.user.UserSettings.actions": "Aktionen",
@@ -22,7 +52,7 @@ export const deMessages = {
"views.admin.Maintenance.customSqlTip": "Füge benutzerdefinierte DELETE-SQL-Anweisungen für die geplante Bereinigung hinzu. Pro Eintrag ist nur eine DELETE-Anweisung erlaubt.",
"views.admin.Maintenance.addCustomSql": "Benutzerdefinierte SQL hinzufügen",
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen",
"components.AddressSelect.address": "Adresse",
"components.AddressSelect.selectMailbox": "Postfach auswählen",
"views.user.AddressManagement.address": "Adresse",
"views.index.TelegramAddress.address": "Adresse",
"views.admin.SenderAccess.address": "Adresse",
@@ -58,10 +88,10 @@ export const deMessages = {
"views.admin.Account.clearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente dieser E-Mail-Adresse leeren?",
"views.admin.Account.multiDeleteTip": "Möchtest du wirklich die ausgewählten Adressen löschen?",
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
"views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
"views.admin.Account.deleteTip": "Möchtest du wirklich dieses Postfach löschen?",
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du wirklich dein Konto und alle zugehörigen E-Mails löschen?",
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen? Dein Benutzerkonto wird dabei nicht gelöscht.",
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich von der aktuellen E-Mail-Adresse abmelden?",
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
@@ -95,10 +125,10 @@ export const deMessages = {
"views.admin.AccountSettings.cancel": "Abbrechen",
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
"views.user.AddressManagement.changeMailAddress": "Adresse wechseln",
"views.user.AddressManagement.openMailbox": "Postfach öffnen",
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
"views.index.AccountSettings.changePassword": "Passwort ändern",
"views.index.AccountSettings.changePassword": "Adresspasswort ändern",
"views.admin.UserManagement.changeRole": "Ändern Rolle",
"components.SendBox.showCode": "Ansicht des Originalcodes umschalten",
"views.admin.Telegram.status": "Status prüfen",
@@ -134,8 +164,8 @@ export const deMessages = {
"views.index.SimpleIndex.copyAddress": "Kopieren",
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
"views.Footer.copyright": "Urheberrecht",
"views.Admin.account_create": "Konto erstellen",
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
"views.Admin.mailbox_create": "E-Mail-Adresse erstellen",
"views.admin.CreateAccount.createEmailAddress": "E-Mail-Adresse erstellen",
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
@@ -170,8 +200,8 @@ export const deMessages = {
"views.admin.UserManagement.delete": "Löschen",
"views.admin.AccountSettings.delete_rule": "Löschen",
"views.admin.Maintenance.deleteCustomSql": "Löschen",
"views.index.AccountSettings.deleteAccount": "Konto löschen",
"views.admin.Account.deleteAccount": "Konto löschen",
"views.index.AccountSettings.deleteMailbox": "Postfach löschen",
"views.admin.Account.deleteMailbox": "Postfach löschen",
"views.user.UserSettings.deletePasskey": "Passkey löschen",
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
@@ -242,7 +272,7 @@ export const deMessages = {
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
"views.common.Login.help": "Hilfe",
"views.Header.home": "Startseite",
"views.Header.mailbox": "Postfach",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)",
@@ -282,7 +312,7 @@ export const deMessages = {
"views.user.UserSettings.logout": "Abmelden",
"views.user.BindAddress.logout": "Abmelden",
"views.Admin.logout": "Abmelden",
"views.index.AccountSettings.logout": "Abmelden",
"views.index.AccountSettings.logout": "Von aktueller E-Mail-Adresse abmelden",
"views.Admin.logoutSuccess": "Erfolgreich abgemeldet",
"views.admin.UserOauth2Settings.mailAllowList": "E-Mail-Adressfreigabeliste",
"views.admin.UserSettings.mailAllowList": "E-Mail-Adressfreigabeliste",
@@ -291,7 +321,7 @@ export const deMessages = {
"views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten",
"views.admin.CreateAccount.addressCredential": "E-Mail-Adresszugangsdaten",
"views.index.AddressBar.fetchAddressError": "Die E-Mail-Zugangsdaten sind ungültig oder das Konto existiert nicht. Es kann auch ein Netzwerkproblem sein. Bitte später erneut versuchen.",
"views.Index.mailbox": "Posteingang",
"views.Index.inbox": "Posteingang",
"views.User.user_mail_box_tab": "Posteingang",
"views.user.AddressManagement.mail_count": "E-Mail-Anzahl",
"views.admin.Account.mail_count": "E-Mail-Anzahl",
@@ -321,11 +351,11 @@ export const deMessages = {
"views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren",
"views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren",
"views.admin.Account.multiDelete": "Mehrfach löschen",
"views.user.AddressManagement.name": "Name",
"views.user.AddressManagement.emailAddress": "E-Mail-Adresse",
"views.index.AutoReply.name": "Name",
"views.admin.Account.name": "Name",
"views.admin.Account.emailAddress": "E-Mail-Adresse",
"views.admin.UserOauth2Settings.name": "Name",
"views.admin.UserAddressManagement.name": "Name",
"views.admin.UserAddressManagement.emailAddress": "E-Mail-Adresse",
"views.admin.Maintenance.sqlName": "Name",
"views.index.AccountSettings.newPassword": "Neues Passwort",
"views.admin.Account.newPassword": "Neues Passwort",
@@ -334,7 +364,8 @@ export const deMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Adressliste ohne Guthabenlimit",
"views.index.SimpleIndex.noMails": "Keine E-Mails gefunden",
"views.admin.RoleAddressConfig.noRolesAvailable": "In der Systemkonfiguration sind keine Rollen verfügbar",
"views.index.SendMail.requestAccessTip": "Noch kein Sendeguthaben vorhanden. Wenn der Administrator ein Standardguthaben aktiviert hat, wird es automatisch zugewiesen; andernfalls Zugriff anfordern oder den Administrator kontaktieren.",
"views.index.SendMail.requestAccessTip": "Sendeberechtigung und Guthaben werden für jede E-Mail-Adresse separat verwaltet. Die aktuelle Adresse {address} hat kein verfügbares Guthaben. Fordere die Berechtigung für diese Adresse an oder kontaktiere den Administrator.",
"views.index.SendMail.requestSuccess": "Sendeberechtigung für die aktuelle Adresse angefordert",
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
@@ -373,6 +404,7 @@ export const deMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
"views.admin.CreateAccount.generateName": "Zufälligen Namen erzeugen",
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
@@ -465,7 +497,7 @@ export const deMessages = {
"views.admin.UserAddressManagement.send_count": "Sendeanzahl",
"views.Index.sendmail": "E-Mail senden",
"views.Admin.sendMail": "E-Mail senden",
"views.index.SendMail.send_balance": "Verbleibendes Sendeguthaben",
"views.index.SendMail.send_balance": "Sendeguthaben der aktuellen Adresse",
"views.admin.Statistics.sendMailCount": "Anzahl gesendeter E-Mails",
"views.admin.AccountSettings.send_mail_limit": "Sende-Limit",
"views.user.UserLogin.sendVerificationCode": "Bestätigungscode senden",
@@ -526,7 +558,7 @@ export const deMessages = {
"views.index.LocalAddress.tip": "Diese Adressen werden in deinem Browser gespeichert und können verloren gehen, wenn du den Browser-Cache leerst.",
"views.admin.UserOauth2Settings.tip": "Drittanbieter-Login verwendet automatisch die E-Mail-Adresse des Benutzers zur Registrierung eines Kontos (dieselbe E-Mail gilt als dasselbe Konto). Das Konto entspricht dem regulär registrierten Konto, und das Passwort kann auch über „Passwort vergessen“ gesetzt werden.",
"views.admin.AccountSettings.send_mail_limit_tip": "Dies gilt für alle Sendekanäle. Verwende -1 für unbegrenzt und 0, um das Senden zu blockieren.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Dies unterscheidet sich von RANDOM_SUBDOMAIN_DOMAINS: Dieser Schalter erlaubt API-Aufrufern, benutzerdefinierte Subdomains direkt anzugeben, während die zufällige Subdomain nur bei der Erstellung automatisch erzeugt wird.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS erlaubt bereits zufällige oder manuelle Subdomains für gelistete Basisdomains. Dieser Schalter erlaubt API-Aufrufern zusätzlich Subdomains unter anderen erlaubten Basisdomains.",
"views.index.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
"views.admin.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
"views.common.Appearance.top": "oben",
@@ -555,13 +587,13 @@ export const deMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "Regex verwenden, um die E-Mail umzuwandeln. Beispiel: ^(.+)@old\\.com$ mit $1@new.com",
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
"views.Header.user": "Benutzer",
"views.Header.userCenter": "Benutzerbereich",
"views.Admin.user": "Benutzer",
"components.AddressSelect.userAddresses": "Benutzeradressen",
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
"components.AddressSelect.linkedMailboxes": "Verknüpfte Postfächer",
"views.Admin.loginViaUserAdmin": "Benutzerkonto (Administratorrolle)",
"views.admin.Statistics.userCount": "Benutzeranzahl",
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
"views.index.AddressBar.userLogin": "Benutzeranmeldung",
"views.index.AddressBar.userCenter": "Benutzerbereich",
"views.Admin.user_management": "Benutzerverwaltung",
"views.Admin.user_settings": "Benutzereinstellungen",
"views.User.user_settings": "Benutzereinstellungen",
@@ -582,6 +614,10 @@ export const deMessages = {
"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. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
"views.admin.CreateAccount.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
"views.admin.CreateAccount.normalSubdomain": "Normale Domain",
"views.common.Login.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
"views.common.Login.normalSubdomain": "Normale Domain",
"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",
@@ -617,5 +653,15 @@ export const deMessages = {
"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"
"components.AddressCredentialModal.username": "Benutzername",
"views.User.send_mail": "E-Mail senden",
"views.user.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
"views.user.UserSendBox.sendbox": "Gesendet",
"components.MailContentRenderer.markAsRead": "Als gelesen markieren",
"components.MailContentRenderer.markAsUnread": "Als ungelesen markieren",
"components.MailContentRenderer.loadRemoteImages": "Bilder laden",
"components.MailContentRenderer.remoteImagesBlocked": "{count} externe Ressourcen wurden zum Schutz deiner Privatsphäre blockiert",
"views.user.AddressManagement.itemCount": "Gesamt",
"views.index.SimpleIndex.readStatusUpdated": "Lesestatus aktualisiert",
"views.common.Appearance.autoLoadRemoteImages": "Externe Bilder im E-Mail-Inhalt automatisch laden"
}
+74 -28
View File
@@ -1,14 +1,44 @@
export const esMessages = {
"views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
"views.index.SendMail.composeMail": "Redactar correo",
"views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
"views.index.SendMail.draftSaved": "Borrador guardado en este navegador",
"views.index.SendMail.recipientAddress": "Dirección del destinatario",
"views.index.SendMail.recipientName": "Nombre del destinatario (opcional)",
"views.index.SendMail.senderAddress": "Dirección del remitente",
"views.index.SendMail.senderName": "Nombre del remitente (opcional)",
"views.admin.SendMail.adminComposeTip": "Enviar desde una dirección de correo configurada",
"views.admin.SendMail.composeMail": "Redactar correo",
"views.admin.SendMail.contentPlaceholder": "Escribe tu mensaje...",
"views.admin.SendMail.draftSaved": "Borrador guardado en este navegador",
"views.admin.SendMail.recipientAddress": "Dirección del destinatario",
"views.admin.SendMail.recipientName": "Nombre del destinatario (opcional)",
"views.admin.SendMail.senderAddress": "Dirección del remitente",
"views.admin.SendMail.senderName": "Nombre del remitente (opcional)",
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Plan de Workers",
"views.admin.DatabaseManager.plan_placeholder": "Selecciona tu plan de Cloudflare Workers",
"views.admin.DatabaseManager.planSaved": "Plan de Workers guardado",
"views.admin.DatabaseManager.single_database_limit": "Límite de capacidad de la base de datos",
"views.admin.DatabaseManager.storage_description": "Compara el tamaño actual de la base de datos con los límites de tu plan.",
"views.admin.DatabaseManager.storage_tip": "El uso se calcula con el tamaño actual de la base de datos y el límite del plan seleccionado.",
"views.admin.DatabaseManager.storage_title": "Capacidad de almacenamiento D1",
"views.admin.DatabaseManager.storage_usage": "Uso de capacidad",
"views.admin.DatabaseManager.unavailable": "No disponible",
"views.index.SimpleIndex.mailCount": "{current} / {total} correos",
"views.admin.Statistics.activeAddressCount30days": "Cantidad de direcciones activas en 30 días",
"views.admin.Statistics.activeAddressCount7days": "Cantidad de direcciones activas en 7 días",
"views.Index.about": "Acerca de",
"views.Admin.about": "Acerca de",
"views.Header.accessHeader": "Contraseña de acceso",
"views.Admin.account": "Cuenta",
"views.Index.accountSettings": "Configuración de la cuenta",
"views.Admin.account_settings": "Configuración de la cuenta",
"views.index.SimpleIndex.accountSettings": "Configuración de la cuenta",
"views.Header.admin": "Consola de administración",
"views.Admin.mailbox_management": "Gestión de buzones",
"views.Admin.mailbox_list": "Buzones",
"views.Index.mailboxSettings": "Configuración del buzón",
"views.Admin.mailbox_settings": "Configuración global de buzones",
"views.index.SimpleIndex.mailboxSettings": "Configuración del buzón",
"views.index.Attachment.action": "Acción",
"views.admin.SenderAccess.action": "Acción",
"views.user.UserSettings.actions": "Acciones",
@@ -22,7 +52,7 @@ export const esMessages = {
"views.admin.Maintenance.customSqlTip": "Añade sentencias SQL DELETE personalizadas para la limpieza programada. Solo se permite una sentencia DELETE por entrada.",
"views.admin.Maintenance.addCustomSql": "Añadir SQL personalizada",
"views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2",
"components.AddressSelect.address": "Dirección",
"components.AddressSelect.selectMailbox": "Seleccionar buzón",
"views.user.AddressManagement.address": "Dirección",
"views.index.TelegramAddress.address": "Dirección",
"views.admin.SenderAccess.address": "Dirección",
@@ -58,10 +88,10 @@ export const esMessages = {
"views.admin.Account.clearSentItemsTip": "¿Seguro que quieres vaciar los enviados de este correo?",
"views.admin.Account.multiDeleteTip": "¿Seguro que quieres eliminar las direcciones seleccionadas?",
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este buzón?",
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar tu cuenta y todos sus correos?",
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección de correo y todos sus correos? Esto no elimina tu cuenta de usuario.",
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar la sesión de la dirección de correo actual?",
"components.MailBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.MailContentRenderer.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.SendBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
@@ -95,10 +125,10 @@ export const esMessages = {
"views.admin.AccountSettings.cancel": "Cancelar",
"components.MailBox.cancelMultiAction": "Cancelar selección múltiple",
"components.SendBox.cancelMultiAction": "Cancelar selección múltiple",
"views.user.AddressManagement.changeMailAddress": "Cambiar dirección",
"views.user.AddressManagement.openMailbox": "Abrir buzón",
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
"views.index.AccountSettings.changePassword": "Cambiar contraseña",
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
"views.admin.UserManagement.changeRole": "Cambiar Rol",
"components.SendBox.showCode": "Cambiar vista del código original",
"views.admin.Telegram.status": "Ver estado",
@@ -134,8 +164,8 @@ export const esMessages = {
"views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Error al copiar",
"views.Footer.copyright": "Derechos de autor",
"views.Admin.account_create": "Crear cuenta",
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
"views.Admin.mailbox_create": "Crear dirección de correo",
"views.admin.CreateAccount.createEmailAddress": "Crear dirección de correo",
"views.common.Login.getNewEmail": "Crear nuevo correo",
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
@@ -170,8 +200,8 @@ export const esMessages = {
"views.admin.UserManagement.delete": "Eliminar",
"views.admin.AccountSettings.delete_rule": "Eliminar",
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
"views.index.AccountSettings.deleteAccount": "Eliminar cuenta",
"views.admin.Account.deleteAccount": "Eliminar cuenta",
"views.index.AccountSettings.deleteMailbox": "Eliminar buzón",
"views.admin.Account.deleteMailbox": "Eliminar buzón",
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
@@ -242,7 +272,7 @@ export const esMessages = {
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
"views.common.Login.help": "Ayuda",
"views.Header.home": "Inicio",
"views.Header.mailbox": "Buzón",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)",
@@ -282,7 +312,7 @@ export const esMessages = {
"views.user.UserSettings.logout": "Cerrar sesión",
"views.user.BindAddress.logout": "Cerrar sesión",
"views.Admin.logout": "Cerrar sesión",
"views.index.AccountSettings.logout": "Cerrar sesión",
"views.index.AccountSettings.logout": "Cerrar sesión de la dirección de correo actual",
"views.Admin.logoutSuccess": "Sesión cerrada correctamente",
"views.admin.UserOauth2Settings.mailAllowList": "Lista blanca de direcciones de correo",
"views.admin.UserSettings.mailAllowList": "Lista blanca de direcciones de correo",
@@ -291,7 +321,7 @@ export const esMessages = {
"views.admin.Account.addressCredential": "Credencial de dirección de correo",
"views.admin.CreateAccount.addressCredential": "Credencial de dirección de correo",
"views.index.AddressBar.fetchAddressError": "La credencial del correo no es válida o la cuenta no existe; también puede ser un problema de red. Inténtalo más tarde.",
"views.Index.mailbox": "Bandeja de entrada",
"views.Index.inbox": "Bandeja de entrada",
"views.User.user_mail_box_tab": "Bandeja de entrada",
"views.user.AddressManagement.mail_count": "Cantidad de correos",
"views.admin.Account.mail_count": "Cantidad de correos",
@@ -321,11 +351,11 @@ export const esMessages = {
"views.admin.Account.multiClearInbox": "Vaciar varias bandejas",
"views.admin.Account.multiClearSentItems": "Vaciar varios enviados",
"views.admin.Account.multiDelete": "Eliminar en lote",
"views.user.AddressManagement.name": "Nombre",
"views.user.AddressManagement.emailAddress": "Dirección de correo",
"views.index.AutoReply.name": "Nombre",
"views.admin.Account.name": "Nombre",
"views.admin.Account.emailAddress": "Dirección de correo",
"views.admin.UserOauth2Settings.name": "Nombre",
"views.admin.UserAddressManagement.name": "Nombre",
"views.admin.UserAddressManagement.emailAddress": "Dirección de correo",
"views.admin.Maintenance.sqlName": "Nombre",
"views.index.AccountSettings.newPassword": "Nueva contraseña",
"views.admin.Account.newPassword": "Nueva contraseña",
@@ -334,7 +364,8 @@ export const esMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de direcciones sin límite de saldo",
"views.index.SimpleIndex.noMails": "No se encontraron correos",
"views.admin.RoleAddressConfig.noRolesAvailable": "No hay roles disponibles en la configuración del sistema",
"views.index.SendMail.requestAccessTip": "Todavía no hay saldo de envío. Si el administrador activó un saldo por defecto, se asignará automáticamente; si no, solicita acceso o contacta con el administrador.",
"views.index.SendMail.requestAccessTip": "El permiso y el saldo de envío se gestionan por separado para cada dirección. La dirección actual, {address}, no tiene saldo disponible. Solicita permiso para esta dirección o contacta con el administrador.",
"views.index.SendMail.requestSuccess": "Permiso de envío solicitado para la dirección actual",
"components.SendBox.emptySent": "No hay correos enviados",
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
@@ -373,6 +404,7 @@ export const esMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
"views.admin.CreateAccount.generateName": "Generar nombre aleatorio",
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
@@ -465,7 +497,7 @@ export const esMessages = {
"views.admin.UserAddressManagement.send_count": "Cantidad enviada",
"views.Index.sendmail": "Enviar correo",
"views.Admin.sendMail": "Enviar correo",
"views.index.SendMail.send_balance": "Saldo restante de envío",
"views.index.SendMail.send_balance": "Saldo de envío de la dirección actual",
"views.admin.Statistics.sendMailCount": "Cantidad de correos enviados",
"views.admin.AccountSettings.send_mail_limit": "Límite de envío",
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificación",
@@ -526,7 +558,7 @@ export const esMessages = {
"views.index.LocalAddress.tip": "Estas direcciones se guardan en tu navegador y podrían perderse si borras la caché.",
"views.admin.UserOauth2Settings.tip": "El inicio de sesión de terceros usará automáticamente el correo del usuario para registrar una cuenta (el mismo correo se considera la misma cuenta). También puedes establecer la contraseña con “olvidé mi contraseña”.",
"views.admin.AccountSettings.send_mail_limit_tip": "Se aplica a todos los canales de envío. Usa -1 para ilimitado y 0 para bloquear el envío.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Esto es diferente de RANDOM_SUBDOMAIN_DOMAINS: este interruptor permite indicar subdominios personalizados; el subdominio aleatorio solo genera uno al crear.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS ya permite subdominios aleatorios o manuales en los dominios base listados. Este interruptor permite además que la API especifique subdominios en otros dominios base permitidos.",
"views.index.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
"views.admin.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
"views.common.Appearance.top": "arriba",
@@ -555,13 +587,13 @@ export const esMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "Usa regex para transformar el correo. Ejemplo: ^(.+)@old\\.com$ con $1@new.com",
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
"views.Header.user": "Usuario",
"views.Header.userCenter": "Centro de usuario",
"views.Admin.user": "Usuario",
"components.AddressSelect.userAddresses": "Direcciones del usuario",
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
"components.AddressSelect.linkedMailboxes": "Buzones vinculados",
"views.Admin.loginViaUserAdmin": "Cuenta de usuario (rol de administrador)",
"views.admin.Statistics.userCount": "Cantidad de usuarios",
"views.admin.UserManagement.user_email": "Correo del usuario",
"views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
"views.index.AddressBar.userCenter": "Centro de usuario",
"views.Admin.user_management": "Gestión de usuarios",
"views.Admin.user_settings": "Configuración de usuario",
"views.User.user_settings": "Configuración de usuario",
@@ -582,6 +614,10 @@ export const esMessages = {
"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. 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.CreateAccount.enableCustomSubdomain": "Usar subdominio personalizado",
"views.admin.CreateAccount.normalSubdomain": "Dominio normal",
"views.common.Login.enableCustomSubdomain": "Usar subdominio personalizado",
"views.common.Login.normalSubdomain": "Dominio normal",
"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",
@@ -617,5 +653,15 @@ export const esMessages = {
"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"
"components.AddressCredentialModal.username": "Usuario",
"views.User.send_mail": "Enviar correo",
"views.user.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada",
"views.user.UserSendBox.sendbox": "Enviados",
"components.MailContentRenderer.markAsRead": "Marcar como leído",
"components.MailContentRenderer.markAsUnread": "Marcar como no leído",
"components.MailContentRenderer.loadRemoteImages": "Cargar imágenes",
"components.MailContentRenderer.remoteImagesBlocked": "Se bloquearon {count} recursos remotos para proteger tu privacidad",
"views.user.AddressManagement.itemCount": "Total",
"views.index.SimpleIndex.readStatusUpdated": "Estado de lectura actualizado",
"views.common.Appearance.autoLoadRemoteImages": "Cargar automáticamente las imágenes externas del correo"
}
+74 -28
View File
@@ -1,14 +1,44 @@
export const jaMessages = {
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
"views.index.SendMail.composeMail": "メールを作成",
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
"views.index.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
"views.index.SendMail.recipientAddress": "宛先メールアドレス",
"views.index.SendMail.recipientName": "宛先名(任意)",
"views.index.SendMail.senderAddress": "送信元メールアドレス",
"views.index.SendMail.senderName": "送信者名(任意)",
"views.admin.SendMail.adminComposeTip": "設定済みのメールアドレスから送信",
"views.admin.SendMail.composeMail": "メールを作成",
"views.admin.SendMail.contentPlaceholder": "メッセージを入力...",
"views.admin.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
"views.admin.SendMail.recipientAddress": "宛先メールアドレス",
"views.admin.SendMail.recipientName": "宛先名(任意)",
"views.admin.SendMail.senderAddress": "送信元メールアドレス",
"views.admin.SendMail.senderName": "送信者名(任意)",
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Workers プラン",
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare Workers プランを選択",
"views.admin.DatabaseManager.planSaved": "Workers プランを保存しました",
"views.admin.DatabaseManager.single_database_limit": "データベース容量上限",
"views.admin.DatabaseManager.storage_description": "現在のデータベースサイズとプラン上限を比較します。",
"views.admin.DatabaseManager.storage_tip": "使用率は現在のデータベースサイズと選択したプラン上限から計算されます。",
"views.admin.DatabaseManager.storage_title": "D1 ストレージ容量",
"views.admin.DatabaseManager.storage_usage": "容量使用率",
"views.admin.DatabaseManager.unavailable": "利用不可",
"views.index.SimpleIndex.mailCount": "{current} / {total} 件のメール",
"views.admin.Statistics.activeAddressCount30days": "30日間のアクティブなアドレス数",
"views.admin.Statistics.activeAddressCount7days": "7日間のアクティブなアドレス数",
"views.Index.about": "概要",
"views.Admin.about": "概要",
"views.Header.accessHeader": "アクセス用パスワード",
"views.Admin.account": "アカウント",
"views.Index.accountSettings": "アカウント設定",
"views.Admin.account_settings": "アカウント設定",
"views.index.SimpleIndex.accountSettings": "アカウント設定",
"views.Header.admin": "管理画面",
"views.Admin.mailbox_management": "メールボックス管理",
"views.Admin.mailbox_list": "メールボックス一覧",
"views.Index.mailboxSettings": "メールボックス設定",
"views.Admin.mailbox_settings": "メールボックスのグローバル設定",
"views.index.SimpleIndex.mailboxSettings": "メールボックス設定",
"views.index.Attachment.action": "操作",
"views.admin.SenderAccess.action": "操作",
"views.user.UserSettings.actions": "操作",
@@ -22,7 +52,7 @@ export const jaMessages = {
"views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。",
"views.admin.Maintenance.addCustomSql": "カスタム SQL を追加",
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加",
"components.AddressSelect.address": "アドレス",
"components.AddressSelect.selectMailbox": "メールボックスを選択",
"views.user.AddressManagement.address": "アドレス",
"views.index.TelegramAddress.address": "アドレス",
"views.admin.SenderAccess.address": "アドレス",
@@ -58,10 +88,10 @@ export const jaMessages = {
"views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?",
"views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?",
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールボックスを削除してもよろしいですか?",
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
@@ -95,10 +125,10 @@ export const jaMessages = {
"views.admin.AccountSettings.cancel": "キャンセル",
"components.MailBox.cancelMultiAction": "複数選択をキャンセル",
"components.SendBox.cancelMultiAction": "複数選択をキャンセル",
"views.user.AddressManagement.changeMailAddress": "アドレスを変更",
"views.user.AddressManagement.openMailbox": "メールボックスを開く",
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
"views.index.AccountSettings.changePassword": "パスワードを変更",
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
"views.admin.UserManagement.changeRole": "ロールを変更",
"components.SendBox.showCode": "元のコード表示を切り替え",
"views.admin.Telegram.status": "ステータスを確認",
@@ -134,8 +164,8 @@ export const jaMessages = {
"views.index.SimpleIndex.copyAddress": "コピー",
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
"views.Footer.copyright": "著作権",
"views.Admin.account_create": "アカウントを作成",
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
"views.Admin.mailbox_create": "メールアドレスを作成",
"views.admin.CreateAccount.createEmailAddress": "メールアドレスを作成",
"views.common.Login.getNewEmail": "新しいメールを作成",
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
@@ -170,8 +200,8 @@ export const jaMessages = {
"views.admin.UserManagement.delete": "削除",
"views.admin.AccountSettings.delete_rule": "削除",
"views.admin.Maintenance.deleteCustomSql": "削除",
"views.index.AccountSettings.deleteAccount": "アカウントを削除",
"views.admin.Account.deleteAccount": "アカウントを削除",
"views.index.AccountSettings.deleteMailbox": "メールボックスを削除",
"views.admin.Account.deleteMailbox": "メールボックスを削除",
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
"views.admin.AccountSettings.delete_success": "削除しました",
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
@@ -242,7 +272,7 @@ export const jaMessages = {
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
"views.common.Appearance.globalTabplacement": "全体タブ位置",
"views.common.Login.help": "ヘルプ",
"views.Header.home": "ホーム",
"views.Header.mailbox": "メールボックス",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)",
@@ -282,7 +312,7 @@ export const jaMessages = {
"views.user.UserSettings.logout": "ログアウト",
"views.user.BindAddress.logout": "ログアウト",
"views.Admin.logout": "ログアウト",
"views.index.AccountSettings.logout": "ログアウト",
"views.index.AccountSettings.logout": "現在のメールアドレスからログアウト",
"views.Admin.logoutSuccess": "ログアウトしました",
"views.admin.UserOauth2Settings.mailAllowList": "メールアドレス許可リスト",
"views.admin.UserSettings.mailAllowList": "メールアドレス許可リスト",
@@ -291,7 +321,7 @@ export const jaMessages = {
"views.admin.Account.addressCredential": "メールアドレス資格情報",
"views.admin.CreateAccount.addressCredential": "メールアドレス資格情報",
"views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。",
"views.Index.mailbox": "受信箱",
"views.Index.inbox": "受信箱",
"views.User.user_mail_box_tab": "受信箱",
"views.user.AddressManagement.mail_count": "メール数",
"views.admin.Account.mail_count": "メール数",
@@ -321,11 +351,11 @@ export const jaMessages = {
"views.admin.Account.multiClearInbox": "受信箱を一括削除",
"views.admin.Account.multiClearSentItems": "送信済みを一括削除",
"views.admin.Account.multiDelete": "一括削除",
"views.user.AddressManagement.name": "名前",
"views.user.AddressManagement.emailAddress": "メールアドレス",
"views.index.AutoReply.name": "名前",
"views.admin.Account.name": "名前",
"views.admin.Account.emailAddress": "メールアドレス",
"views.admin.UserOauth2Settings.name": "名前",
"views.admin.UserAddressManagement.name": "名前",
"views.admin.UserAddressManagement.emailAddress": "メールアドレス",
"views.admin.Maintenance.sqlName": "名前",
"views.index.AccountSettings.newPassword": "新しいパスワード",
"views.admin.Account.newPassword": "新しいパスワード",
@@ -334,7 +364,8 @@ export const jaMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
"views.index.SimpleIndex.noMails": "メールが見つかりません",
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者連絡してください。",
"views.index.SendMail.requestAccessTip": "送信権限と残高はメールアドレスごとに管理されます。現在のアドレス {address} には利用可能な残高がありません。このアドレスの権限申請するか、管理者連絡してください。",
"views.index.SendMail.requestSuccess": "現在のアドレスの送信権限を申請しました",
"components.SendBox.emptySent": "送信済みメールはありません",
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
"views.Admin.userOauth2Settings": "OAuth2設定",
@@ -373,6 +404,7 @@ export const jaMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
"views.admin.CreateAccount.generateName": "ランダム名を生成",
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
@@ -465,7 +497,7 @@ export const jaMessages = {
"views.admin.UserAddressManagement.send_count": "送信数",
"views.Index.sendmail": "メール送信",
"views.Admin.sendMail": "メール送信",
"views.index.SendMail.send_balance": "残り送信枠",
"views.index.SendMail.send_balance": "現在のアドレスの送信残高",
"views.admin.Statistics.sendMailCount": "送信メール数",
"views.admin.AccountSettings.send_mail_limit": "送信上限",
"views.user.UserLogin.sendVerificationCode": "認証コードを送信",
@@ -526,7 +558,7 @@ export const jaMessages = {
"views.index.LocalAddress.tip": "これらのアドレスはブラウザに保存されており、キャッシュを消すと失われる可能性があります。",
"views.admin.UserOauth2Settings.tip": "サードパーティログインではユーザーのメールアドレスで自動的にアカウント登録されます(同じメールは同一アカウントとして扱われます)。「パスワードを忘れた」からパスワード設定も可能です。",
"views.admin.AccountSettings.send_mail_limit_tip": "すべての送信チャネルに適用されます。-1 は無制限、0 は送信禁止です。",
"views.admin.AccountSettings.create_address_subdomain_match_note": "これは RANDOM_SUBDOMAIN_DOMAINS と異なります。この設定では API 呼び出し側が独自サブドメインを指定でき、ランダムサブドメインは作成時に自動生成されるだけです。",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS に列挙したベースドメインでは、ランダムまたは手動のサブドメインを利用できます。この設定は、他の許可済みベースドメインでも API からサブドメインを指定できるようにします。",
"views.index.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.admin.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.common.Appearance.top": "上",
@@ -555,13 +587,13 @@ export const jaMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com",
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
"views.Header.user": "ユーザー",
"views.Header.userCenter": "ユーザーセンター",
"views.Admin.user": "ユーザー",
"components.AddressSelect.userAddresses": "ユーザーのアドレス",
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
"components.AddressSelect.linkedMailboxes": "連携済みメールボックス",
"views.Admin.loginViaUserAdmin": "ユーザーアカウント(管理者ロール)",
"views.admin.Statistics.userCount": "ユーザー数",
"views.admin.UserManagement.user_email": "ユーザーメール",
"views.index.AddressBar.userLogin": "ユーザーログイン",
"views.index.AddressBar.userCenter": "ユーザーセンター",
"views.Admin.user_management": "ユーザー管理",
"views.Admin.user_settings": "ユーザー設定",
"views.User.user_settings": "ユーザー設定",
@@ -582,6 +614,10 @@ export const jaMessages = {
"views.admin.AiExtractSettings.disabledTip": "無効時は AI 抽出がすべてのメールアドレスを処理します",
"views.admin.AiExtractSettings.enableAllowListTip": "有効時は AI 抽出は許可リストのアドレス宛メールのみ処理します",
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.admin.CreateAccount.enableCustomSubdomain": "カスタムサブドメインを使用",
"views.admin.CreateAccount.normalSubdomain": "通常ドメイン",
"views.common.Login.enableCustomSubdomain": "カスタムサブドメインを使用",
"views.common.Login.normalSubdomain": "通常ドメイン",
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.admin.AiExtractSettings.allowListTip": "ワイルドカード * は任意の文字に一致します。例: *{'@'}example.com は example.com ドメイン配下のすべてのアドレスに一致します",
"views.Admin.workerconfig": "Worker設定",
@@ -617,5 +653,15 @@ export const jaMessages = {
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
"components.AddressCredentialModal.username": "ユーザー名"
"components.AddressCredentialModal.username": "ユーザー名",
"views.User.send_mail": "メール送信",
"views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
"views.user.UserSendBox.sendbox": "送信済み",
"components.MailContentRenderer.markAsRead": "既読にする",
"components.MailContentRenderer.markAsUnread": "未読にする",
"components.MailContentRenderer.loadRemoteImages": "画像を読み込む",
"components.MailContentRenderer.remoteImagesBlocked": "プライバシー保護のため、{count} 件の外部リソースをブロックしました",
"views.user.AddressManagement.itemCount": "合計",
"views.index.SimpleIndex.readStatusUpdated": "既読状態を更新しました",
"views.common.Appearance.autoLoadRemoteImages": "メール本文の外部画像を自動的に読み込む"
}
+74 -28
View File
@@ -1,14 +1,44 @@
export const ptBRMessages = {
"views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
"views.index.SendMail.composeMail": "Escrever e-mail",
"views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
"views.index.SendMail.draftSaved": "Rascunho salvo neste navegador",
"views.index.SendMail.recipientAddress": "Endereço do destinatário",
"views.index.SendMail.recipientName": "Nome do destinatário (opcional)",
"views.index.SendMail.senderAddress": "Endereço do remetente",
"views.index.SendMail.senderName": "Nome do remetente (opcional)",
"views.admin.SendMail.adminComposeTip": "Enviar usando um endereço de e-mail configurado",
"views.admin.SendMail.composeMail": "Escrever e-mail",
"views.admin.SendMail.contentPlaceholder": "Escreva sua mensagem...",
"views.admin.SendMail.draftSaved": "Rascunho salvo neste navegador",
"views.admin.SendMail.recipientAddress": "Endereço do destinatário",
"views.admin.SendMail.recipientName": "Nome do destinatário (opcional)",
"views.admin.SendMail.senderAddress": "Endereço do remetente",
"views.admin.SendMail.senderName": "Nome do remetente (opcional)",
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Plano do Workers",
"views.admin.DatabaseManager.plan_placeholder": "Selecione seu plano do Cloudflare Workers",
"views.admin.DatabaseManager.planSaved": "Plano do Workers salvo",
"views.admin.DatabaseManager.single_database_limit": "Limite de capacidade do banco de dados",
"views.admin.DatabaseManager.storage_description": "Compare o tamanho atual do banco de dados com os limites do seu plano.",
"views.admin.DatabaseManager.storage_tip": "O uso é calculado com o tamanho atual do banco de dados e o limite do plano selecionado.",
"views.admin.DatabaseManager.storage_title": "Capacidade de armazenamento D1",
"views.admin.DatabaseManager.storage_usage": "Uso da capacidade",
"views.admin.DatabaseManager.unavailable": "Indisponível",
"views.index.SimpleIndex.mailCount": "{current} / {total} e-mails",
"views.admin.Statistics.activeAddressCount30days": "Quantidade de endereços ativos em 30 dias",
"views.admin.Statistics.activeAddressCount7days": "Quantidade de endereços ativos em 7 dias",
"views.Index.about": "Sobre",
"views.Admin.about": "Sobre",
"views.Header.accessHeader": "Senha de acesso",
"views.Admin.account": "Conta",
"views.Index.accountSettings": "Configurações da conta",
"views.Admin.account_settings": "Configurações da conta",
"views.index.SimpleIndex.accountSettings": "Configurações da conta",
"views.Header.admin": "Painel administrativo",
"views.Admin.mailbox_management": "Gerenciamento de caixas de correio",
"views.Admin.mailbox_list": "Caixas de correio",
"views.Index.mailboxSettings": "Configurações da caixa de correio",
"views.Admin.mailbox_settings": "Configurações globais de caixas de correio",
"views.index.SimpleIndex.mailboxSettings": "Configurações da caixa de correio",
"views.index.Attachment.action": "Ação",
"views.admin.SenderAccess.action": "Ação",
"views.user.UserSettings.actions": "Ações",
@@ -22,7 +52,7 @@ export const ptBRMessages = {
"views.admin.Maintenance.customSqlTip": "Adicione instruções SQL DELETE personalizadas para a limpeza agendada. Apenas uma instrução DELETE é permitida por item.",
"views.admin.Maintenance.addCustomSql": "Adicionar SQL personalizada",
"views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2",
"components.AddressSelect.address": "Endereço",
"components.AddressSelect.selectMailbox": "Selecionar caixa de correio",
"views.user.AddressManagement.address": "Endereço",
"views.index.TelegramAddress.address": "Endereço",
"views.admin.SenderAccess.address": "Endereço",
@@ -58,10 +88,10 @@ export const ptBRMessages = {
"views.admin.Account.clearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados deste e-mail?",
"views.admin.Account.multiDeleteTip": "Tem certeza de que deseja excluir os endereços selecionados?",
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir esta caixa de correio?",
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir sua conta e todos os e-mails dela?",
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair?",
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço de e-mail e todas as mensagens dele? Isso não exclui sua conta de usuário.",
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair do endereço de e-mail atual?",
"components.MailBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.MailContentRenderer.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.SendBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
@@ -95,10 +125,10 @@ export const ptBRMessages = {
"views.admin.AccountSettings.cancel": "Cancelar",
"components.MailBox.cancelMultiAction": "Cancelar múltipla seleção",
"components.SendBox.cancelMultiAction": "Cancelar múltipla seleção",
"views.user.AddressManagement.changeMailAddress": "Alterar endereço",
"views.user.AddressManagement.openMailbox": "Abrir caixa de correio",
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
"views.index.AccountSettings.changePassword": "Alterar senha",
"views.index.AccountSettings.changePassword": "Alterar senha do endereço",
"views.admin.UserManagement.changeRole": "Alterar Função",
"components.SendBox.showCode": "Alternar visualização do código original",
"views.admin.Telegram.status": "Ver status",
@@ -134,8 +164,8 @@ export const ptBRMessages = {
"views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
"views.Footer.copyright": "Direitos autorais",
"views.Admin.account_create": "Criar conta",
"views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
"views.Admin.mailbox_create": "Criar endereço de e-mail",
"views.admin.CreateAccount.createEmailAddress": "Criar endereço de e-mail",
"views.common.Login.getNewEmail": "Criar novo e-mail",
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
@@ -170,8 +200,8 @@ export const ptBRMessages = {
"views.admin.UserManagement.delete": "Excluir",
"views.admin.AccountSettings.delete_rule": "Excluir",
"views.admin.Maintenance.deleteCustomSql": "Excluir",
"views.index.AccountSettings.deleteAccount": "Excluir conta",
"views.admin.Account.deleteAccount": "Excluir conta",
"views.index.AccountSettings.deleteMailbox": "Excluir caixa de correio",
"views.admin.Account.deleteMailbox": "Excluir caixa de correio",
"views.user.UserSettings.deletePasskey": "Excluir passkey",
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
"views.admin.UserManagement.deleteUser": "Excluir usuário",
@@ -242,7 +272,7 @@ export const ptBRMessages = {
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
"views.common.Appearance.globalTabplacement": "Posição global das abas",
"views.common.Login.help": "Ajuda",
"views.Header.home": "Início",
"views.Header.mailbox": "Caixa de correio",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)",
@@ -282,7 +312,7 @@ export const ptBRMessages = {
"views.user.UserSettings.logout": "Sair",
"views.user.BindAddress.logout": "Sair",
"views.Admin.logout": "Sair",
"views.index.AccountSettings.logout": "Sair",
"views.index.AccountSettings.logout": "Sair do endereço de e-mail atual",
"views.Admin.logoutSuccess": "Logout realizado com sucesso",
"views.admin.UserOauth2Settings.mailAllowList": "Lista branca de endereços de e-mail",
"views.admin.UserSettings.mailAllowList": "Lista branca de endereços de e-mail",
@@ -291,7 +321,7 @@ export const ptBRMessages = {
"views.admin.Account.addressCredential": "Credencial do endereço de e-mail",
"views.admin.CreateAccount.addressCredential": "Credencial do endereço de e-mail",
"views.index.AddressBar.fetchAddressError": "A credencial do e-mail é inválida ou a conta não existe; também pode ser um problema de rede. Tente novamente mais tarde.",
"views.Index.mailbox": "Caixa de entrada",
"views.Index.inbox": "Caixa de entrada",
"views.User.user_mail_box_tab": "Caixa de entrada",
"views.user.AddressManagement.mail_count": "Quantidade de e-mails",
"views.admin.Account.mail_count": "Quantidade de e-mails",
@@ -321,11 +351,11 @@ export const ptBRMessages = {
"views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada",
"views.admin.Account.multiClearSentItems": "Limpar vários itens enviados",
"views.admin.Account.multiDelete": "Excluir em lote",
"views.user.AddressManagement.name": "Nome",
"views.user.AddressManagement.emailAddress": "Endereço de e-mail",
"views.index.AutoReply.name": "Nome",
"views.admin.Account.name": "Nome",
"views.admin.Account.emailAddress": "Endereço de e-mail",
"views.admin.UserOauth2Settings.name": "Nome",
"views.admin.UserAddressManagement.name": "Nome",
"views.admin.UserAddressManagement.emailAddress": "Endereço de e-mail",
"views.admin.Maintenance.sqlName": "Nome",
"views.index.AccountSettings.newPassword": "Nova senha",
"views.admin.Account.newPassword": "Nova senha",
@@ -334,7 +364,8 @@ export const ptBRMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de endereços sem limite de saldo",
"views.index.SimpleIndex.noMails": "Nenhum e-mail encontrado",
"views.admin.RoleAddressConfig.noRolesAvailable": "Nenhuma função disponível na configuração do sistema",
"views.index.SendMail.requestAccessTip": "Ainda não há saldo de envio. Se o administrador ativou um saldo padrão, ele será atribuído automaticamente; caso contrário, solicite acesso ou fale com o administrador.",
"views.index.SendMail.requestAccessTip": "A permissão e o saldo de envio são gerenciados separadamente para cada endereço. O endereço atual, {address}, não tem saldo disponível. Solicite permissão para este endereço ou fale com o administrador.",
"views.index.SendMail.requestSuccess": "Permissão de envio solicitada para o endereço atual",
"components.SendBox.emptySent": "Nenhum e-mail enviado",
"views.admin.RoleAddressConfig.notConfigured": "Não configurado (usar configurações globais)",
"views.Admin.userOauth2Settings": "Configurações de OAuth2",
@@ -373,6 +404,7 @@ export const ptBRMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
"views.admin.CreateAccount.generateName": "Gerar nome aleatório",
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
"views.user.UserLogin.pleaseInputCode": "Informe o código",
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
@@ -465,7 +497,7 @@ export const ptBRMessages = {
"views.admin.UserAddressManagement.send_count": "Quantidade enviada",
"views.Index.sendmail": "Enviar e-mail",
"views.Admin.sendMail": "Enviar e-mail",
"views.index.SendMail.send_balance": "Saldo restante de envio",
"views.index.SendMail.send_balance": "Saldo de envio do endereço atual",
"views.admin.Statistics.sendMailCount": "Quantidade de e-mails enviados",
"views.admin.AccountSettings.send_mail_limit": "Limite de envio",
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificação",
@@ -526,7 +558,7 @@ export const ptBRMessages = {
"views.index.LocalAddress.tip": "Esses endereços ficam armazenados no navegador e podem ser perdidos se você limpar o cache.",
"views.admin.UserOauth2Settings.tip": "O login de terceiros usará automaticamente o e-mail do usuário para registrar uma conta (o mesmo e-mail será considerado a mesma conta). Você também pode definir a senha via “esqueci minha senha”.",
"views.admin.AccountSettings.send_mail_limit_tip": "Aplica-se a todos os canais de envio. Use -1 para ilimitado e 0 para bloquear o envio.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Isso é diferente de RANDOM_SUBDOMAIN_DOMAINS: esta opção permite informar subdomínios personalizados; o subdomínio aleatório apenas gera um durante a criação.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS já permite subdomínios aleatórios ou manuais nos domínios base listados. Esta opção também permite que a API informe subdomínios em outros domínios base permitidos.",
"views.index.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
"views.admin.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
"views.common.Appearance.top": "topo",
@@ -555,13 +587,13 @@ export const ptBRMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "Use regex para transformar o e-mail. Exemplo: ^(.+)@old\\.com$ com $1@new.com",
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
"views.common.Appearance.useUTCDate": "Usar data UTC",
"views.Header.user": "Usuário",
"views.Header.userCenter": "Central do usuário",
"views.Admin.user": "Usuário",
"components.AddressSelect.userAddresses": "Endereços do usuário",
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
"components.AddressSelect.linkedMailboxes": "Caixas de correio vinculadas",
"views.Admin.loginViaUserAdmin": "Conta de usuário (função de administrador)",
"views.admin.Statistics.userCount": "Quantidade de usuários",
"views.admin.UserManagement.user_email": "E-mail do usuário",
"views.index.AddressBar.userLogin": "Login do usuário",
"views.index.AddressBar.userCenter": "Central do usuário",
"views.Admin.user_management": "Gerenciamento de usuários",
"views.Admin.user_settings": "Configurações do usuário",
"views.User.user_settings": "Configurações do usuário",
@@ -582,6 +614,10 @@ export const ptBRMessages = {
"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. 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.CreateAccount.enableCustomSubdomain": "Usar subdomínio personalizado",
"views.admin.CreateAccount.normalSubdomain": "Domínio normal",
"views.common.Login.enableCustomSubdomain": "Usar subdomínio personalizado",
"views.common.Login.normalSubdomain": "Domínio normal",
"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",
@@ -617,5 +653,15 @@ export const ptBRMessages = {
"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"
"components.AddressCredentialModal.username": "Nome de usuário",
"views.User.send_mail": "Enviar e-mail",
"views.user.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado",
"views.user.UserSendBox.sendbox": "Enviados",
"components.MailContentRenderer.markAsRead": "Marcar como lido",
"components.MailContentRenderer.markAsUnread": "Marcar como não lido",
"components.MailContentRenderer.loadRemoteImages": "Carregar imagens",
"components.MailContentRenderer.remoteImagesBlocked": "{count} recursos externos foram bloqueados para proteger sua privacidade",
"views.user.AddressManagement.itemCount": "Total",
"views.index.SimpleIndex.readStatusUpdated": "Status de leitura atualizado",
"views.common.Appearance.autoLoadRemoteImages": "Carregar automaticamente imagens externas no corpo do e-mail"
}
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,11 +1,11 @@
import { LOCALE_REGISTRY, SUPPORTED_LOCALES } from './locale-registry'
import { APP_CONFIG } from '../config'
export { SUPPORTED_LOCALES } from './locale-registry'
export type { SupportedLocale } from './locale-registry'
import type { SupportedLocale } from './locale-registry'
export const DEFAULT_LOCALE: SupportedLocale = 'zh'
export const FALLBACK_LOCALE: SupportedLocale = 'zh'
export const PREFERRED_LOCALE_STORAGE_KEY = 'preferredLocale'
export const EMPTY_LOCALE_MESSAGES = Object.fromEntries(
@@ -29,6 +29,9 @@ export const resolveSupportedLocale = (locale: string | null | undefined): Suppo
return null
}
export const DEFAULT_LOCALE: SupportedLocale = resolveSupportedLocale(APP_CONFIG.DEFAULT_LANG)
|| FALLBACK_LOCALE
export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => {
if (!locale) return null
const normalizedLocale = locale.trim().toLowerCase()
@@ -135,4 +138,4 @@ const getLocaleAliasPath = (path: string, locale: SupportedLocale): string => {
}
return getPathWithLocale(basePath, locale)
}
}
+5
View File
@@ -27,6 +27,11 @@ const router = createRouter({
alias: '/:lang/user',
component: User
},
{
path: '/redeem',
alias: '/:lang/redeem',
component: () => import('../views/Redeem.vue')
},
{
path: '/user/oauth2/callback',
alias: '/:lang/user/oauth2/callback',
+3
View File
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableMailReadStatus: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
@@ -42,6 +43,8 @@ export const useGlobalState = createGlobalState(
disableAdminPasswordCheck: false,
enableAddressPassword: false,
enableAgentEmailInfo: false,
enableRedeemCode: false,
redeemCodeUrl: '',
smtpImapProxyConfig: {
smtp: {
host: '',
+1 -1
View File
@@ -1,7 +1,7 @@
import { useBreakpoint, useMemo } from 'vooks'
export function useIsMobile() {
const breakpointRef = useBreakpoint()
const breakpointRef = useBreakpoint({ xs: 0, s: 768 })
return useMemo(() => {
return breakpointRef.value === 'xs'
})
+15 -11
View File
@@ -30,6 +30,7 @@ import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue';
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
import AiExtractSettings from './admin/AiExtractSettings.vue';
import RedeemCodes from './admin/RedeemCodes.vue';
const {
adminAuth, showAdminAuth, adminTab, loading,
@@ -106,7 +107,7 @@ onMounted(async () => {
</script>
<template>
<div v-if="userSettings.fetched">
<div v-if="openSettings.fetched && userSettings.fetched">
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
@@ -120,11 +121,11 @@ onMounted(async () => {
</n-modal>
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="quick-setup-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
@@ -135,15 +136,15 @@ onMounted(async () => {
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="account" :tab="t('account')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="account" :tab="t('account')">
<n-tab-pane name="account" :tab="t('mailbox_management')">
<n-tabs key="account-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="account" :tab="t('mailbox_list')">
<Account />
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<n-tab-pane name="account_create" :tab="t('mailbox_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
@@ -161,7 +162,7 @@ onMounted(async () => {
</n-tabs>
</n-tab-pane>
<n-tab-pane name="user" :tab="t('user')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="user-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="user_management" :tab="t('user_management')">
<UserManagement />
</n-tab-pane>
@@ -177,7 +178,7 @@ onMounted(async () => {
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="mails-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>
@@ -201,8 +202,11 @@ onMounted(async () => {
<n-tab-pane name="statistics" :tab="t('statistics')">
<Statistics />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableRedeemCode" name="redeemCodes" :tab="t('redeemCodes')">
<RedeemCodes />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="maintenance-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
+37 -10
View File
@@ -9,7 +9,7 @@ import {
AdminPanelSettingsFilled, MonitorHeartFilled,
KeyboardArrowDownOutlined, OpenInNewOutlined
} from '@vicons/material'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { Envelope, GithubAlt, Language, User } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
@@ -123,8 +123,8 @@ const menuOptions = computed(() => [
}
},
{
default: () => t('home'),
icon: () => h(NIcon, { component: Home })
default: () => t('mailbox'),
icon: () => h(NIcon, { component: Envelope })
}),
key: "home"
},
@@ -142,7 +142,7 @@ const menuOptions = computed(() => [
}
},
{
default: () => t('user'),
default: () => t('userCenter'),
icon: () => h(NIcon, { component: User }),
}
),
@@ -165,7 +165,7 @@ const menuOptions = computed(() => [
}
},
{
default: () => "Admin",
default: () => t('admin'),
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
@@ -331,6 +331,32 @@ onMounted(async () => {
</template>
<style scoped>
:deep(.n-page-header) {
align-items: center;
flex-wrap: nowrap;
}
:deep(.n-page-header__main) {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
:deep(.n-page-header__title) {
min-width: 0;
overflow: hidden;
}
:deep(.n-page-header__title h3) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.n-page-header__extra) {
flex: 0 0 auto;
}
.n-layout-header {
display: flex;
align-items: center;
@@ -436,7 +462,7 @@ onMounted(async () => {
@media (max-width: 640px) {
:deep(.n-page-header) {
padding: 10px;
padding: 10px 12px;
}
:deep(.n-page-header__title) {
@@ -444,10 +470,11 @@ onMounted(async () => {
}
:deep(.n-page-header__title h3) {
max-width: calc(100vw - 136px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100vw - 104px);
margin: 0;
font-size: clamp(16px, 5vw, 20px);
line-height: 1.2;
}
}
</style>
+12 -3
View File
@@ -45,6 +45,14 @@ const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
const updateMailReadStatus = async (id, isUnread) => {
await api.fetch(`/api/mails/${id}/read`, {
method: 'PATCH',
body: JSON.stringify({ isUnread }),
showLoading: false
})
}
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
};
@@ -116,7 +124,7 @@ onMounted(() => {
{{ t('enterSimpleMode') }}
</n-button>
</template>
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<n-tab-pane name="mailbox" :tab="t('inbox')">
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
<n-input-group>
<n-input v-model:value="mailIdQuery" />
@@ -127,7 +135,8 @@ onMounted(() => {
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
:enableMailReadStatus="openSettings.enableMailReadStatus" :updateMailReadStatus="updateMailReadStatus" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
@@ -136,7 +145,7 @@ onMounted(() => {
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
<n-tab-pane name="accountSettings" :tab="t('mailboxSettings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')">
+627
View File
@@ -0,0 +1,627 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
AlternateEmailOutlined,
ArrowBackOutlined,
CheckCircleOutlined,
RedeemOutlined,
SendOutlined,
} from '@vicons/material'
import { api } from '../api'
import AddressCredentialContent from '../components/AddressCredentialContent.vue'
import { useGlobalState } from '../store'
import { useScopedI18n } from '../i18n/app'
import { getRouterPathWithLang } from '../utils'
const router = useRouter()
const message = useMessage()
const notification = useNotification()
const { locale, t } = useScopedI18n('views.Redeem')
const {
jwt,
settings,
openSettings,
userSettings,
addressPassword,
showAddressCredential,
} = useGlobalState()
const code = ref('')
const codeInfo = ref(null)
const redeemStarted = ref(false)
const completed = ref(null)
const targetUserEmail = ref('')
const targetAddress = ref(settings.value.address || '')
const emailName = ref('')
const emailDomain = ref('')
const subdomainMode = ref('normal')
const customSubdomain = ref('')
let latestQueryRequest = 0
const activeRedeemType = computed(() => completed.value?.type || codeInfo.value?.redeem_type || '')
const codeStatusType = computed(() => {
if (codeInfo.value?.status === 'expired') return 'error'
return codeInfo.value?.status === 'unused' ? 'success' : 'default'
})
const redeemTypes = computed(() => [
{
type: 'role',
icon: RedeemOutlined,
title: t('roleTitle'),
},
{
type: 'send_balance',
icon: SendOutlined,
title: t('balanceTitle'),
},
{
type: 'address_prefix_once',
icon: AlternateEmailOutlined,
title: t('prefixTitle'),
},
])
const redeemCodeUrl = computed(() => {
try {
const url = new URL(openSettings.value.redeemCodeUrl)
return ['http:', 'https:'].includes(url.protocol) ? url.href : ''
} catch {
return ''
}
})
const domainsOptions = computed(() => {
const allowed = openSettings.value.defaultDomains?.length
? openSettings.value.defaultDomains
: openSettings.value.domains.map((item) => item.value)
return openSettings.value.domains.filter((item) => allowed.includes(item.value))
})
const canUseRandomSubdomain = computed(() => (
openSettings.value.randomSubdomainDomains?.includes(emailDomain.value)
))
const addressNameMaxLength = computed(() => Math.max(
(openSettings.value.maxAddressLen || 30) - (codeInfo.value?.value?.length || 0),
1,
))
const addressNameMinLength = computed(() => Math.min(
addressNameMaxLength.value,
Math.max(
(openSettings.value.minAddressLen || 1) - (codeInfo.value?.value?.length || 0),
1,
),
))
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) subdomainMode.value = 'normal'
})
watch(addressNameMaxLength, (maxLength) => {
emailName.value = emailName.value.slice(0, maxLength)
})
watch(domainsOptions, (options) => {
if (!options.some((item) => item.value === emailDomain.value)) {
emailDomain.value = options[0]?.value || ''
}
}, { immediate: true })
watch(code, () => {
latestQueryRequest += 1
codeInfo.value = null
redeemStarted.value = false
})
watch(() => userSettings.value.user_email, (email) => {
if (!targetUserEmail.value) targetUserEmail.value = email || ''
}, { immediate: true })
watch(() => settings.value.address, (address) => {
if (!targetAddress.value) targetAddress.value = address || ''
}, { immediate: true })
const loadContext = async () => {
if (!openSettings.value.fetched) {
await api.getOpenSettings(message, notification)
}
if (!openSettings.value.enableRedeemCode) {
await router.replace(getRouterPathWithLang('/', locale.value))
return
}
if (jwt.value && !settings.value.address) {
try {
await api.getSettings()
} catch (error) {
console.error(error)
}
}
}
const queryCode = async () => {
const queriedCode = code.value.trim()
if (!queriedCode) return
const requestId = ++latestQueryRequest
try {
const result = await api.fetch('/redeem_api/query', {
method: 'POST',
body: JSON.stringify({ code: queriedCode }),
})
if (requestId !== latestQueryRequest || code.value.trim() !== queriedCode) return
codeInfo.value = result
redeemStarted.value = false
completed.value = null
await loadContext()
} catch (error) {
if (requestId !== latestQueryRequest) return
codeInfo.value = null
message.error(error.message || 'error')
}
}
const resetRedemption = () => {
code.value = ''
codeInfo.value = null
redeemStarted.value = false
completed.value = null
}
const redeem = async () => {
if (!codeInfo.value) return
try {
let result
if (codeInfo.value.redeem_type === 'role') {
if (!targetUserEmail.value.trim()) return
result = await api.fetch('/redeem_api/redeem', {
method: 'POST',
body: JSON.stringify({
code: code.value,
user_email: targetUserEmail.value.trim(),
}),
})
} else if (codeInfo.value.redeem_type === 'send_balance') {
if (!targetAddress.value.trim()) return
result = await api.fetch('/redeem_api/redeem', {
method: 'POST',
body: JSON.stringify({
code: code.value,
address: targetAddress.value.trim(),
}),
})
}
completed.value = result
codeInfo.value = null
if (result?.type === 'role'
&& result.user_email.toLowerCase() === userSettings.value.user_email?.trim().toLowerCase()
) {
await api.getUserSettings(message)
}
} catch (error) {
message.error(error.message || 'error')
}
}
const generateName = () => {
const length = Math.min(
addressNameMaxLength.value,
Math.max(addressNameMinLength.value, 8),
)
const charset = 'abcdefghijklmnopqrstuvwxyz0123456789'
const bytes = crypto.getRandomValues(new Uint8Array(length))
emailName.value = Array.from(bytes, (value) => charset[value % charset.length]).join('')
}
const createAddress = async () => {
if (!codeInfo.value || codeInfo.value.redeem_type !== 'address_prefix_once') return
try {
const domain = subdomainMode.value === 'custom'
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value
const result = await api.fetch('/redeem_api/redeem', {
method: 'POST',
body: JSON.stringify({
code: code.value,
name: openSettings.value.disableCustomAddressName ? '' : emailName.value,
domain,
enableRandomSubdomain: subdomainMode.value === 'random',
}),
})
completed.value = result
codeInfo.value = null
} catch (error) {
message.error(error.message || 'error')
}
}
const showRedeemResult = async () => {
try {
completed.value = await api.fetch('/redeem_api/result', {
method: 'POST',
body: JSON.stringify({ code: code.value }),
})
codeInfo.value = null
} catch (error) {
message.error(error.message || 'error')
}
}
const useRedeemedAddress = async () => {
if (completed.value?.type !== 'address_prefix_once') return
jwt.value = completed.value.jwt
addressPassword.value = completed.value.password || ''
await api.getSettings()
showAddressCredential.value = true
await router.push(getRouterPathWithLang('/', locale.value))
}
const goBack = async () => {
await router.push(getRouterPathWithLang('/', locale.value))
}
onMounted(loadContext)
</script>
<template>
<main v-if="openSettings.enableRedeemCode" class="redeem-page">
<section class="redeem-shell">
<n-card class="redeem-card" :bordered="false" embedded>
<n-button text class="back-link" @click="goBack">
<template #icon>
<n-icon :component="ArrowBackOutlined" />
</template>
{{ t('back') }}
</n-button>
<div class="heading-copy">
<div class="heading-icon">
<n-icon :component="RedeemOutlined" />
</div>
<div>
<h1>{{ t('title') }}</h1>
</div>
</div>
<div class="type-section">
<div class="section-label">{{ t('categoryTitle') }}</div>
<div class="type-grid">
<div v-for="item in redeemTypes" :key="item.type" class="type-card"
:class="{ active: activeRedeemType === item.type }">
<n-icon :component="item.icon" />
<span>{{ item.title }}</span>
</div>
</div>
</div>
<n-divider />
<div v-if="completed" class="success-state">
<n-icon :component="CheckCircleOutlined" />
<h2>{{ t('success') }}</h2>
<p v-if="completed.type === 'role'">
{{ t('roleSuccess', { role: completed.role, email: completed.user_email }) }}
</p>
<p v-else-if="completed.type === 'send_balance'">
{{ t('balanceSuccess', { amount: completed.amount, address: completed.address }) }}
</p>
<template v-else>
<p>{{ t('addressSuccess') }}</p>
<AddressCredentialContent class="credential-result" :address="completed.address"
:jwt="completed.jwt" :address-password="completed.password" />
<n-button type="primary" block @click="useRedeemedAddress">
{{ t('openAddress') }}
</n-button>
</template>
<n-button type="primary" secondary @click="resetRedemption">
{{ t('redeemAnother') }}
</n-button>
</div>
<div v-else class="redeem-form">
<n-form-item :label="t('codeLabel')" :show-feedback="false">
<n-input data-testid="redeem-code-input" v-model:value="code" size="large" :placeholder="t('codePlaceholder')"
@keyup.enter="queryCode" />
</n-form-item>
<n-button v-if="!codeInfo" type="primary" size="large" block :disabled="!code.trim()"
@click="queryCode">
{{ t('checkCode') }}
</n-button>
<n-button v-if="redeemCodeUrl" data-testid="redeem-code-link" class="get-code-link"
text tag="a" target="_blank" rel="noopener noreferrer" :href="redeemCodeUrl">
{{ t('getCode') }}
</n-button>
<div v-if="codeInfo" class="redemption-detail">
<n-divider />
<template v-if="codeInfo.redeem_type === 'role'">
<div class="detail-heading">
<n-icon :component="RedeemOutlined" />
<div>
<h2>{{ t('roleTitle') }}</h2>
<p>{{ t('roleDescription', { role: codeInfo.value }) }}</p>
</div>
<n-tag :type="codeStatusType" :bordered="false">
{{ t(codeInfo.status) }}
</n-tag>
</div>
<n-button v-if="codeInfo.status === 'unused' && !redeemStarted" data-testid="redeem-now" type="primary" block
@click="redeemStarted = true">
{{ t('redeemNow') }}
</n-button>
<template v-else-if="codeInfo.status === 'unused'">
<n-form-item :label="t('userEmail')">
<n-input data-testid="redeem-user-email" v-model:value="targetUserEmail"
:placeholder="t('userEmailPlaceholder')" />
</n-form-item>
<n-button type="primary" block :disabled="!targetUserEmail.trim()" @click="redeem">
{{ t('confirmRole') }}
</n-button>
</template>
<n-button v-else-if="codeInfo.status === 'redeemed'" data-testid="redeem-result"
type="primary" block @click="showRedeemResult">
{{ t('viewResult') }}
</n-button>
</template>
<template v-else-if="codeInfo.redeem_type === 'send_balance'">
<div class="detail-heading">
<n-icon :component="SendOutlined" />
<div>
<h2>{{ t('balanceTitle') }}</h2>
<p>{{ t('balanceDescription', { amount: codeInfo.value }) }}</p>
</div>
<n-tag :type="codeStatusType" :bordered="false">
{{ t(codeInfo.status) }}
</n-tag>
</div>
<n-button v-if="codeInfo.status === 'unused' && !redeemStarted" data-testid="redeem-now" type="primary" block
@click="redeemStarted = true">
{{ t('redeemNow') }}
</n-button>
<template v-else-if="codeInfo.status === 'unused'">
<n-form-item :label="t('targetAddress')">
<n-input data-testid="redeem-target-address" v-model:value="targetAddress"
:placeholder="t('targetAddress')" />
</n-form-item>
<n-button type="primary" block :disabled="!targetAddress.trim()" @click="redeem">
{{ t('confirmBalance') }}
</n-button>
</template>
<n-button v-else-if="codeInfo.status === 'redeemed'" data-testid="redeem-result"
type="primary" block @click="showRedeemResult">
{{ t('viewResult') }}
</n-button>
</template>
<template v-else>
<div class="detail-heading">
<n-icon :component="AlternateEmailOutlined" />
<div>
<h2>{{ t('prefixTitle') }}</h2>
<p>{{ codeInfo.value
? t('prefixDescription', { prefix: codeInfo.value })
: t('noPrefixDescription') }}</p>
</div>
<n-tag :type="codeStatusType" :bordered="false">
{{ t(codeInfo.status) }}
</n-tag>
</div>
<n-button v-if="codeInfo.status === 'redeemed'" data-testid="redeem-result" type="primary" block
@click="showRedeemResult">
{{ t('viewResult') }}
</n-button>
<n-form v-else-if="codeInfo.status === 'unused'">
<n-form-item :label="t('address')">
<n-input-group>
<n-input-group-label v-if="codeInfo.value">{{ codeInfo.value }}</n-input-group-label>
<n-input v-if="!openSettings.disableCustomAddressName" data-testid="redeem-address-name"
v-model:value="emailName"
:minlength="addressNameMinLength" :maxlength="addressNameMaxLength" />
<n-input v-else :value="t('autoName')" disabled />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :options="domainsOptions"
:consistent-menu-width="false" />
</n-input-group>
</n-form-item>
<n-button v-if="!openSettings.disableCustomAddressName" size="small" @click="generateName">
{{ t('randomName') }}
</n-button>
<n-form-item v-if="canUseRandomSubdomain" class="subdomain-options">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalDomain') }}</n-radio>
<n-radio value="random">{{ t('randomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('customSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<n-input-group v-if="subdomainMode === 'custom'">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</n-form-item>
<n-button type="primary" block
:disabled="!emailDomain || (subdomainMode === 'custom' && !customSubdomain.trim())"
@click="createAddress">
{{ t('createAddress') }}
</n-button>
</n-form>
</template>
</div>
</div>
</n-card>
</section>
</main>
</template>
<style scoped>
.redeem-page {
min-height: 70vh;
padding: 24px 18px 56px;
}
.redeem-shell {
width: min(720px, 100%);
margin: 0 auto;
text-align: left;
}
.back-link {
margin-bottom: 12px;
}
.redeem-card {
border-radius: 10px;
}
.heading-copy {
display: flex;
align-items: center;
gap: 12px;
}
.heading-icon {
display: grid;
flex: 0 0 auto;
width: 42px;
height: 42px;
place-items: center;
color: #18a058;
font-size: 24px;
background: rgba(24, 160, 88, 0.12);
border-radius: 8px;
}
.heading-copy h1 {
margin: 0;
font-size: 24px;
line-height: 1.3;
}
.type-section {
margin-top: 22px;
}
.section-label {
margin-bottom: 10px;
font-weight: 600;
}
.type-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.type-card {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 12px;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
}
.type-card .n-icon {
flex: 0 0 auto;
color: #18a058;
font-size: 20px;
}
.type-card span {
min-width: 0;
}
.type-card.active {
color: #18a058;
border-color: rgba(24, 160, 88, 0.55);
background: rgba(24, 160, 88, 0.08);
}
.redeem-card > :deep(.n-card__content) > .n-divider {
margin: 22px 0;
}
.redeem-form > :deep(.n-form-item) {
margin-bottom: 14px;
}
.redeem-form > .n-button {
margin-top: 2px;
}
.redeem-form > .get-code-link {
width: max-content;
margin: 12px auto 0;
}
.redemption-detail :deep(.n-button),
.success-state > .n-button {
margin-top: 14px;
}
.detail-heading {
display: flex;
gap: 14px;
align-items: flex-start;
margin-bottom: 18px;
}
.detail-heading > .n-icon {
flex: 0 0 auto;
color: #18a058;
font-size: 28px;
}
.detail-heading > .n-tag {
flex: 0 0 auto;
margin-left: auto;
}
.detail-heading p,
.success-state p {
margin: 0;
opacity: 0.68;
}
.detail-heading h2,
.success-state h2 {
margin: 0 0 4px;
font-size: 20px;
}
.subdomain-options :deep(.n-form-item-blank) {
display: grid;
gap: 12px;
}
.success-state {
padding: 24px 8px;
text-align: center;
}
.success-state > .n-icon {
color: #18a058;
font-size: 50px;
}
.credential-result {
margin-top: 18px;
}
@media (max-width: 640px) {
.redeem-page {
padding: 16px 12px 48px;
}
.heading-copy h1 {
font-size: 21px;
}
.type-card {
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 10px 5px;
text-align: center;
}
}
</style>
+9 -1
View File
@@ -8,12 +8,14 @@ import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
import UserMailBox from './user/UserMailBox.vue';
import UserSendBox from './user/UserSendBox.vue';
const {
userTab, globalTabplacement, userSettings
userTab, globalTabplacement, userSettings, openSettings
} = useGlobalState()
const { t } = useScopedI18n('views.User')
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
</script>
@@ -27,6 +29,12 @@ const { t } = useScopedI18n('views.User')
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
<UserMailBox />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="user_sendbox" :tab="userMailT('sendbox')">
<UserSendBox mode="sendbox" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
<UserSendBox mode="send_mail" @sent="userTab = 'user_sendbox'" />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>
+3 -3
View File
@@ -265,7 +265,7 @@ const columns = computed(() => [
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
},
{
title: t('name'),
title: t('emailAddress'),
key: "name",
sorter: true,
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
@@ -479,11 +479,11 @@ onMounted(async () => {
<div style="margin-top: 10px;">
<AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
:jwt="curEmailCredential" />
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('deleteAccount') }}
{{ t('deleteMailbox') }}
</n-button>
</template>
</n-modal>
+77 -21
View File
@@ -14,7 +14,8 @@ const message = useMessage()
const { t } = useScopedI18n('views.admin.CreateAccount')
const enablePrefix = ref(true)
const enableRandomSubdomain = ref(false)
const subdomainMode = ref("normal")
const customSubdomain = ref("")
const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
@@ -22,6 +23,40 @@ const result = ref("")
const addressPassword = ref("")
const createdAddress = ref("")
const addressRegex = computed(() => {
try {
if (openSettings.value.addressRegex) {
return new RegExp(openSettings.value.addressRegex, 'g');
}
} catch (error) {
console.error(error);
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
}
return /[^a-z0-9]/g;
});
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.internet.email()
.split('@')[0]
.replace(/\s+/g, '.')
.replace(/\.{2,}/g, '.')
.replace(addressRegex.value, '')
.toLowerCase();
// support maxAddressLen
if (emailName.value.length > openSettings.value.maxAddressLen) {
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
}
} catch (error) {
message.error(error.message || "error");
} finally {
generateNameLoading.value = false;
}
};
const canUseRandomSubdomain = computed(() => {
if (!emailDomain.value) {
return false
@@ -31,7 +66,7 @@ const canUseRandomSubdomain = computed(() => {
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false
subdomainMode.value = "normal"
}
})
@@ -41,13 +76,16 @@ const newEmail = async () => {
return
}
try {
const domain = subdomainMode.value === "custom"
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value
const res = await api.fetch(`/admin/new_address`, {
method: 'POST',
body: JSON.stringify({
enablePrefix: enablePrefix.value,
enableRandomSubdomain: enableRandomSubdomain.value,
enableRandomSubdomain: subdomainMode.value === "random",
name: emailName.value,
domain: emailDomain.value,
domain,
})
})
result.value = res["jwt"];
@@ -77,26 +115,44 @@ onMounted(async () => {
<n-switch v-model:value="enablePrefix" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('address')">
<n-input-group>
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
<n-spin :show="generateNameLoading" style="width: 100%;">
<div>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
</div>
</n-spin>
</n-form-item-row>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<div style="width: 100%;">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</div>
</n-form-item-row>
<n-button @click="newEmail" type="primary" block :loading="loading">
{{ t('creatNewEmail') }}
<n-button @click="newEmail" type="primary" block :loading="loading"
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
{{ t('createEmailAddress') }}
</n-button>
</n-card>
</div>
+211 -4
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useScopedI18n } from '@/i18n/app'
import { CleaningServicesFilled } from '@vicons/material'
@@ -7,24 +7,95 @@ import { api } from '../../api'
import { init } from 'vooks/lib/on-fonts-ready';
const message = useMessage()
const D1_STORAGE_PLAN_CONFIG_KEY = 'd1_storage_plan'
const dbVersionData = ref({
need_initialization: false,
need_migration: false,
current_db_version: '',
code_db_version: ''
code_db_version: '',
database_size: null
})
const selectedPlan = ref(null)
const savedPlan = ref(null)
const savingPlan = ref(false)
const planOptions = computed(() => [
{
label: t('free_plan'),
value: 'free',
databaseLimit: 500 * 1024 ** 2
},
{
label: t('paid_plan'),
value: 'paid',
databaseLimit: 10 * 1024 ** 3
}
])
const selectedPlanDetails = computed(() => (
planOptions.value.find((plan) => plan.value === selectedPlan.value)
))
const storagePercentage = computed(() => {
if (!selectedPlanDetails.value || dbVersionData.value.database_size === null) return 0
return dbVersionData.value.database_size / selectedPlanDetails.value.databaseLimit * 100
})
const progressPercentage = computed(() => Math.min(storagePercentage.value, 100))
const progressStatus = computed(() => {
if (storagePercentage.value >= 90) return 'error'
if (storagePercentage.value >= 75) return 'warning'
return 'success'
})
const { t } = useScopedI18n('views.admin.DatabaseManager')
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined) return t('unavailable')
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / 1024 ** unitIndex
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIndex]}`
}
const fetchData = async () => {
try {
const res = await api.fetch('/admin/db_version');
if (res) Object.assign(dbVersionData.value, res);
const [versionRes, configRes] = await Promise.all([
api.fetch('/admin/db_version'),
api.fetch(`/admin/config/${D1_STORAGE_PLAN_CONFIG_KEY}`)
]);
if (versionRes) Object.assign(dbVersionData.value, versionRes);
const configuredPlan = configRes?.value
if (planOptions.value.some((plan) => plan.value === configuredPlan)) {
selectedPlan.value = configuredPlan
savedPlan.value = configuredPlan
}
} catch (error) {
message.error(error.message || "error");
}
}
const savePlan = async (plan) => {
savingPlan.value = true
try {
await api.fetch('/admin/config', {
method: 'POST',
body: { key: D1_STORAGE_PLAN_CONFIG_KEY, value: plan }
})
savedPlan.value = plan
message.success(t('planSaved'))
} catch (error) {
selectedPlan.value = savedPlan.value
message.error(error.message || "error")
} finally {
savingPlan.value = false
}
}
const initialization = async () => {
try {
await api.fetch('/admin/db_initialize', {
@@ -77,6 +148,58 @@ onMounted(async () => {
</span>
</n-alert>
<div class="storage-panel">
<div class="storage-heading">
<div>
<h3>{{ t('storage_title') }}</h3>
<p>{{ t('storage_description') }}</p>
</div>
<div class="plan-select">
<span>{{ t('plan') }}</span>
<n-select
v-model:value="selectedPlan"
:options="planOptions"
:placeholder="t('plan_placeholder')"
:disabled="dbVersionData.need_initialization"
:loading="savingPlan"
@update:value="savePlan"
/>
</div>
</div>
<n-grid cols="1 s:2" responsive="screen" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="storage-stat">
<span>{{ t('current_database_size') }}</span>
<strong>{{ formatBytes(dbVersionData.database_size) }}</strong>
</div>
</n-grid-item>
<n-grid-item>
<div class="storage-stat">
<span>{{ t('single_database_limit') }}</span>
<strong>{{ selectedPlanDetails ? formatBytes(selectedPlanDetails.databaseLimit) : '—' }}</strong>
</div>
</n-grid-item>
</n-grid>
<div v-if="selectedPlanDetails" class="storage-progress">
<div class="storage-progress-label">
<span>{{ t('storage_usage') }}</span>
<span>{{ storagePercentage.toFixed(2) }}%</span>
</div>
<n-progress
type="line"
:percentage="progressPercentage"
:status="progressStatus"
:show-indicator="false"
/>
</div>
<n-alert class="storage-tip" type="default" :show-icon="false" :bordered="false">
{{ t('storage_tip') }}
</n-alert>
</div>
</n-card>
</div>
</template>
@@ -100,4 +223,88 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
}
.storage-panel {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--n-border-color);
text-align: left;
}
.storage-heading {
display: flex;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
}
.storage-heading h3,
.storage-heading p {
margin: 0;
}
.storage-heading p {
margin-top: 4px;
color: var(--n-text-color-3);
}
.plan-select {
width: 220px;
}
.plan-select > span {
display: block;
margin-bottom: 6px;
color: var(--n-text-color-2);
}
.storage-stat {
display: flex;
min-height: 72px;
padding: 14px;
box-sizing: border-box;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--n-border-color);
border-radius: var(--n-border-radius);
}
.storage-stat span {
color: var(--n-text-color-3);
}
.storage-stat strong {
margin-top: 8px;
font-size: 18px;
color: var(--n-text-color);
}
.storage-progress {
margin-top: 16px;
}
.storage-progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
color: var(--n-text-color-2);
}
.storage-tip {
margin-top: 16px;
margin-bottom: 0;
}
@media (max-width: 640px) {
.storage-heading {
align-items: stretch;
flex-direction: column;
gap: 12px;
}
.plan-select {
width: 100%;
}
}
</style>
+493
View File
@@ -0,0 +1,493 @@
<script setup>
import { computed, h, onMounted, ref, watch } from 'vue'
import { NButton, NEllipsis, NPopconfirm, NTag } from 'naive-ui'
import { useScopedI18n } from '@/i18n/app'
import { api } from '../../api'
import { useGlobalState } from '../../store'
const MAX_BATCH_SIZE = 500
const MAX_EXPORT_SIZE = 10000
const { loading, openSettings } = useGlobalState()
const { t } = useScopedI18n('views.admin.RedeemCodes')
const typeOptions = computed(() => [
{ label: t('typeRole'), value: 'role' },
{ label: t('typeBalance'), value: 'send_balance' },
{ label: t('typeAddress'), value: 'address_prefix_once' },
])
const message = useMessage()
const data = ref([])
const roleOptions = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const query = ref('')
const selectedType = ref('role')
const showEditor = ref(false)
const showExport = ref(false)
const editorMode = ref('create')
const exportType = ref('role')
const exportLimit = ref(1000)
const form = ref({})
const prefixMaxLength = computed(() => Math.max((openSettings.value.maxAddressLen || 30) - 1, 0))
let latestFetchRequest = 0
const resetForm = () => {
form.value = {
id: null,
type: selectedType.value,
count: 1,
role: '',
amount: 100,
prefix: '',
enabled: true,
expires_at: null,
}
}
const parseJson = (value) => {
try {
const parsed = JSON.parse(value || '{}')
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
const parseDate = (value) => {
if (!value) return null
const normalized = /(?:z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date
}
const fetchData = async () => {
const requestId = ++latestFetchRequest
try {
const result = await api.fetch(
`/admin/redeem_codes?redeem_type=${encodeURIComponent(selectedType.value)}`
+ `&limit=${pageSize.value}&offset=${(page.value - 1) * pageSize.value}`
+ (query.value.trim() ? `&query=${encodeURIComponent(query.value.trim())}` : '')
)
if (requestId !== latestFetchRequest) return
data.value = result.results || []
count.value = result.count || 0
} catch (error) {
if (requestId !== latestFetchRequest) return
message.error(error.message || 'error')
}
}
const fetchRoleOptions = async () => {
try {
const [roles, config] = await Promise.all([
api.fetch('/admin/user_roles'),
api.fetch('/admin/worker/configs'),
])
roleOptions.value = roles
.filter((item) => item.role && item.role !== config.ADMIN_USER_ROLE)
.map((item) => ({ label: item.role, value: item.role }))
} catch (error) {
message.error(error.message || 'error')
}
}
const search = async () => {
page.value = 1
await fetchData()
}
const openCreate = () => {
editorMode.value = 'create'
resetForm()
showEditor.value = true
}
const openEdit = (row) => {
editorMode.value = 'edit'
form.value = {
id: row.id,
type: selectedType.value,
count: 1,
role: selectedType.value === 'role' ? row.value : '',
amount: selectedType.value === 'send_balance' ? Number(row.value) : null,
prefix: selectedType.value === 'address_prefix_once' ? row.value : '',
enabled: Boolean(row.enabled),
expires_at: parseDate(row.expires_at)?.getTime() || null,
}
showEditor.value = true
}
const buildValue = () => {
if (form.value.type === 'role') return form.value.role.trim()
if (form.value.type === 'send_balance') return String(form.value.amount)
return form.value.prefix.trim()
}
const save = async () => {
if (form.value.type === 'role' && !form.value.role.trim()) {
message.error(t('roleRequired'))
return
}
const value = buildValue()
if (form.value.type === 'address_prefix_once'
&& (!/^[a-z0-9]*$/i.test(value) || value.length > prefixMaxLength.value)
) {
message.error(t('invalidPrefix', { max: prefixMaxLength.value }))
return
}
if (!form.value.expires_at || form.value.expires_at <= Date.now()) {
message.error(t('invalidExpiration'))
return
}
try {
if (editorMode.value === 'create') {
if (!Number.isInteger(form.value.count)
|| form.value.count < 1
|| form.value.count > MAX_BATCH_SIZE
) {
message.error(t('countLimit', { max: MAX_BATCH_SIZE }))
return
}
const result = await api.fetch('/admin/redeem_codes/batch', {
method: 'POST',
body: JSON.stringify({
count: form.value.count,
redeem_type: form.value.type,
value,
enabled: form.value.enabled,
expires_at: new Date(form.value.expires_at).toISOString(),
}),
})
downloadCreatedCodes(result.codes, {
redeem_type: form.value.type,
value,
enabled: form.value.enabled,
expires_at: new Date(form.value.expires_at).toISOString(),
})
message.success(t('createdResult', result))
} else {
await api.fetch(`/admin/redeem_codes/${form.value.id}`, {
method: 'PUT',
body: JSON.stringify({
redeem_type: form.value.type,
value,
enabled: form.value.enabled,
expires_at: new Date(form.value.expires_at).toISOString(),
}),
})
message.success(t('saved'))
}
showEditor.value = false
await fetchData()
} catch (error) {
message.error(error.message || 'error')
}
}
const remove = async (id) => {
try {
await api.fetch(`/admin/redeem_codes/${id}`, { method: 'DELETE' })
await fetchData()
} catch (error) {
message.error(error.message || 'error')
}
}
const copyCode = async (code) => {
try {
await navigator.clipboard.writeText(code)
message.success(t('copied'))
} catch {
message.error(t('copyFailed'))
}
}
const download = (content, filename, type) => {
const url = URL.createObjectURL(new Blob([content], { type }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
const csvCell = (value) => {
let text = String(value ?? '')
if (/^[=+\-@]/.test(text)) text = `'${text}`
return `"${text.replace(/"/g, '""')}"`
}
const downloadCreatedCodes = (codes, fields) => {
if (!Array.isArray(codes) || !codes.length) return
const columns = ['code', 'redeem_type', 'value', 'enabled', 'expires_at']
const csv = [
columns.join(','),
...codes.map((code) => columns.map((column) => (
csvCell(column === 'code' ? code : fields[column])
)).join(',')),
].join('\n')
download(`\uFEFF${csv}`, `redeem-codes-${fields.redeem_type}.csv`, 'text/csv;charset=utf-8')
}
const formatTime = (value) => parseDate(value)?.toLocaleString() || '-'
const exportList = async () => {
try {
const csv = await api.fetch(
`/admin/redeem_codes/export?redeem_type=${encodeURIComponent(exportType.value)}`
+ `&limit=${exportLimit.value}`
)
download(csv, `redeem-codes-${exportType.value}.csv`, 'text/csv;charset=utf-8')
showExport.value = false
} catch (error) {
message.error(error.message || 'error')
}
}
const commonColumns = computed(() => [
{
title: t('code'),
key: 'code',
width: 180,
render: (row) => h(NEllipsis, { style: 'max-width: 160px' }, { default: () => row.code }),
},
])
const businessColumns = computed(() => {
if (selectedType.value === 'role') {
return [
{ title: t('role'), key: 'role', width: 140, render: (row) => row.value || '-' },
{
title: t('redeemedUser'),
key: 'user',
width: 240,
render: (row) => parseJson(row.result).user_email || '-',
},
]
}
if (selectedType.value === 'send_balance') {
return [
{ title: t('amount'), key: 'amount', width: 140, render: (row) => row.value || '-' },
{
title: t('targetAddress'),
key: 'address',
width: 240,
render: (row) => parseJson(row.result).address || '-',
},
]
}
return [
{
title: t('prefix'),
key: 'prefix',
width: 160,
render: (row) => row.value || t('emptyPrefix'),
},
{
title: t('resultAddress'),
key: 'address',
width: 240,
render: (row) => parseJson(row.result).address || '-',
},
]
})
const columns = computed(() => [
...commonColumns.value,
...businessColumns.value,
{
title: t('enabled'),
key: 'enabled',
width: 90,
render: (row) => h(NTag, { type: row.enabled ? 'success' : 'default', bordered: false }, {
default: () => row.enabled ? t('yes') : t('no'),
}),
},
{
title: t('expiresAt'),
key: 'expires_at',
width: 260,
render: (row) => {
const expiration = parseDate(row.expires_at)
const expired = !expiration || expiration.getTime() <= Date.now()
return h('div', { class: 'expiration-cell' }, [
h(NTag, { type: expired ? 'error' : 'success', size: 'small', bordered: false }, {
default: () => expired ? t('expired') : t('valid'),
}),
h('span', formatTime(row.expires_at)),
])
},
},
{ title: t('redeemedAt'), key: 'redeemed_at', width: 180, render: (row) => formatTime(row.redeemed_at) },
{
title: t('actions'),
key: 'actions',
width: 230,
fixed: 'right',
render: (row) => h('div', { class: 'actions' }, [
h(NButton, { size: 'small', tertiary: true, onClick: () => copyCode(row.code) }, {
default: () => t('copy'),
}),
row.redeemed
? null
: h(NButton, { size: 'small', tertiary: true, onClick: () => openEdit(row) }, {
default: () => t('edit'),
}),
h(NPopconfirm, { onPositiveClick: () => remove(row.id) }, {
trigger: () => h(NButton, { size: 'small', tertiary: true, type: 'error' }, {
default: () => t('delete'),
}),
default: () => t('deleteConfirm'),
}),
]),
},
])
watch([page, pageSize], fetchData)
watch(selectedType, async () => {
page.value = 1
await fetchData()
})
onMounted(async () => {
await Promise.all([fetchData(), fetchRoleOptions()])
})
</script>
<template>
<div class="redeem-admin">
<div class="redeem-toolbar">
<div class="redeem-filters">
<n-input-group class="type-filter">
<n-input-group-label>{{ t('typeFilter') }}</n-input-group-label>
<n-select data-testid="redeem-admin-filter-type" v-model:value="selectedType"
:options="typeOptions" class="type-select" />
</n-input-group>
<n-input data-testid="redeem-admin-search" v-model:value="query"
:placeholder="t('searchPlaceholder')" class="search-input" @keyup.enter="search" />
<n-button @click="search">{{ t('search') }}</n-button>
</div>
<div class="redeem-toolbar-actions">
<n-button data-testid="redeem-admin-export" @click="exportType = selectedType; showExport = true">
{{ t('export') }}
</n-button>
<n-button data-testid="redeem-admin-create" type="primary" @click="openCreate">
{{ t('batchCreate') }}
</n-button>
</div>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" :scroll-x="1320" />
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker />
<n-modal v-model:show="showEditor" preset="dialog"
:title="editorMode === 'create' ? t('batchCreate') : t('edit')">
<n-form label-placement="top">
<n-form-item v-if="editorMode === 'edit'" :label="t('type')">
<n-select v-model:value="form.type" :options="typeOptions" disabled />
</n-form-item>
<n-form-item v-if="editorMode === 'create'" :label="t('generationCount')">
<n-input-number data-testid="redeem-admin-count" v-model:value="form.count"
:min="1" :max="MAX_BATCH_SIZE" :precision="0" />
</n-form-item>
<n-form-item v-if="form.type === 'role'" :label="t('role')" required>
<n-select data-testid="redeem-admin-role" v-model:value="form.role"
:options="roleOptions" filterable />
</n-form-item>
<n-form-item v-else-if="form.type === 'send_balance'" :label="t('amount')">
<n-input-number v-model:value="form.amount" :min="1" :max="1000000000" />
</n-form-item>
<n-form-item v-else :label="t('prefix')">
<n-input v-model:value="form.prefix" :maxlength="prefixMaxLength"
:placeholder="t('emptyPrefixTip')" />
</n-form-item>
<n-form-item :label="t('expiresAt')" required>
<n-date-picker v-model:value="form.expires_at" type="datetime" clearable />
</n-form-item>
<n-form-item :label="t('enabled')">
<n-switch v-model:value="form.enabled" />
</n-form-item>
</n-form>
<template #action>
<n-button :loading="loading" type="primary" @click="save">
{{ editorMode === 'create' ? t('generate') : t('save') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showExport" preset="dialog" :title="t('export')">
<n-form label-placement="top">
<n-form-item :label="t('exportLimit', { max: MAX_EXPORT_SIZE })">
<n-input-number v-model:value="exportLimit" :min="1" :max="MAX_EXPORT_SIZE" />
</n-form-item>
</n-form>
<template #action>
<n-button :loading="loading" type="primary" @click="exportList">{{ t('downloadCsv') }}</n-button>
</template>
</n-modal>
</div>
</template>
<style scoped>
.redeem-admin {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
padding-top: 10px;
}
.redeem-toolbar,
.redeem-filters,
.redeem-toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.redeem-toolbar,
.redeem-filters {
flex-wrap: wrap;
min-width: 0;
}
.redeem-toolbar {
justify-content: space-between;
}
.redeem-filters {
flex: 1 1 520px;
}
.type-select {
flex: 1;
min-width: 0;
}
.type-filter {
flex: 1 1 300px;
min-width: 240px;
}
.search-input {
flex: 1 1 180px;
min-width: 120px;
}
.redeem-toolbar-actions {
margin-left: auto;
}
:deep(.actions) {
display: flex;
gap: 4px;
}
:deep(.expiration-cell) {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
</style>
+229 -63
View File
@@ -2,14 +2,20 @@
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useScopedI18n } from '@/i18n/app'
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useSessionStorage } from '@vueuse/core'
import { SendRound } from '@vicons/material'
import { api } from '../../api'
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
import { useGlobalState } from '../../store'
import { blockRemoteContent } from '../../utils/remote-content-policy'
import { sanitizeHtml } from '../../utils/sanitize-html'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const sending = ref(false)
const { autoLoadRemoteImages, isDark } = useGlobalState()
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
fromName: "",
@@ -23,11 +29,18 @@ const sendMailModel = useSessionStorage('sendMailByAdminModel', {
const { t } = useScopedI18n('views.admin.SendMail')
const contentTypes = [
const contentTypes = computed(() => [
{ label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' },
]
])
const previewContent = computed(() => {
const content = `${sendMailModel.value.content ?? ''}`
return autoLoadRemoteImages.value
? sanitizeHtml(content)
: blockRemoteContent(content).html
})
const normalizeSendMailText = (content) => {
return content
@@ -110,6 +123,7 @@ const send = async () => {
contentType: 'text',
content: "",
}
isPreview.value = false
message.success(t("successSend"));
} catch (error) {
message.error(error.message || "error");
@@ -146,78 +160,230 @@ const handleCreated = (editor) => {
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<n-flex justify="end">
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
</n-flex>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.fromName" />
<n-input v-model:value="sendMailModel.fromMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.toName" />
<n-input v-model:value="sendMailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="sendMailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-radio-group v-model:value="sendMailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card :bordered="false" embedded v-if="isPreview">
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
<div class="composer-page">
<n-card class="composer-card" :bordered="false" embedded>
<template #header>
<div class="composer-title">
<h2>{{ t('composeMail') }}</h2>
<n-text depth="3">{{ t('adminComposeTip') }}</n-text>
</div>
</template>
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
<n-grid-item>
<n-form-item :label="t('senderAddress')" required
:label-props="{ for: 'admin-send-mail-sender-address' }">
<n-input v-model:value="sendMailModel.fromMail"
:input-props="{ id: 'admin-send-mail-sender-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('senderName')"
:label-props="{ for: 'admin-send-mail-sender-name' }">
<n-input v-model:value="sendMailModel.fromName"
:input-props="{ id: 'admin-send-mail-sender-name' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('recipientAddress')" required
:label-props="{ for: 'admin-send-mail-recipient-address' }">
<n-input v-model:value="sendMailModel.toMail"
:input-props="{ id: 'admin-send-mail-recipient-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('recipientName')"
:label-props="{ for: 'admin-send-mail-recipient-name' }">
<n-input v-model:value="sendMailModel.toName"
:input-props="{ id: 'admin-send-mail-recipient-name' }" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item :label="t('subject')" required
:label-props="{ for: 'admin-send-mail-subject' }">
<n-input v-model:value="sendMailModel.subject"
:input-props="{ id: 'admin-send-mail-subject' }" />
</n-form-item>
<div class="editor-panel">
<div class="editor-panel-header">
<n-text id="admin-send-mail-content-label" strong>{{ t('content') }} <span
class="required-mark">*</span></n-text>
<div class="editor-controls">
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
size="small" aria-labelledby="admin-send-mail-content-label">
<n-radio-button v-for="option in contentTypes" :key="option.value"
:value="option.value" :label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
@click="isPreview = !isPreview">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</div>
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>
</n-form>
</div>
</div>
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
</div>
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
@onCreated="handleCreated" />
</div>
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
:input-props="{ 'aria-label': t('content') }"
:autosize="{ minRows: 14, maxRows: 24 }" />
</div>
<div class="composer-actions">
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
<template #icon><n-icon :component="SendRound" /></template>
{{ t('send') }}
</n-button>
</div>
</n-form>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-button {
.composer-page {
width: 100%;
padding: 14px 0 24px;
text-align: left;
margin-right: 10px;
}
.center {
.composer-card {
width: min(900px, 100%);
margin: 0 auto;
}
.composer-title {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
align-items: baseline;
flex-wrap: wrap;
gap: 8px 12px;
}
.left {
.composer-title > h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.composer-title .n-text {
font-size: 13px;
}
.editor-panel {
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.24);
border-radius: 3px;
}
.editor-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 46px;
padding: 6px 10px 6px 14px;
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
}
.editor-controls {
display: flex;
align-items: center;
gap: 8px;
}
.required-mark {
color: #d03050;
}
.format-options {
display: flex;
}
.format-options :deep(.n-radio-button) {
min-width: 72px;
text-align: center;
}
.compose-preview {
min-height: 360px;
padding: 18px;
}
.rich-editor {
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
border-bottom: 1px solid #e5e7eb;
}
.rich-editor :deep(.w-e-text-container),
.rich-editor :deep(.w-e-scroll) {
min-height: 360px;
}
.compose-textarea :deep(.n-input__textarea-el),
.compose-textarea :deep(.n-input__placeholder) {
line-height: 1.7;
text-align: left;
place-items: left;
justify-content: left;
}
.composer-form :deep(.n-input__input-el) {
text-align: left;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(128, 128, 128, 0.18);
}
.draft-status {
font-size: 13px;
}
@media (max-width: 640px) {
.composer-page {
padding-top: 8px;
}
.editor-panel-header {
align-items: flex-start;
flex-wrap: wrap;
}
.editor-controls {
width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
}
.format-options {
max-width: 100%;
}
.format-options :deep(.n-radio-button) {
min-width: 0;
padding-right: 8px;
padding-left: 8px;
}
.rich-editor :deep(.w-e-toolbar) {
overflow-x: auto;
}
}
</style>
@@ -32,7 +32,7 @@ const fetchData = async () => {
const columns = [
{
title: t('name'),
title: t('emailAddress'),
key: "name"
},
{
+67 -12
View File
@@ -2,7 +2,7 @@
import { ref, onMounted, computed, watch } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { useRouter } from 'vue-router'
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
import { NewLabelOutlined, EmailOutlined, RedeemOutlined } from '@vicons/material'
import AdminContact from '../common/AdminContact.vue'
import Turnstile from '../../components/Turnstile.vue'
@@ -48,7 +48,8 @@ const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const enableRandomSubdomain = ref(false)
const subdomainMode = ref("normal")
const customSubdomain = ref("")
const loginCfToken = ref("")
const loginTurnstileRef = ref(null)
const loginMethod = ref('credential') // 'credential' or 'password'
@@ -167,11 +168,14 @@ const newEmail = async () => {
try {
// If custom names are disabled, send empty name to trigger backend auto-generation
const nameToSend = openSettings.value.disableCustomAddressName ? "" : emailName.value;
const domainToSend = subdomainMode.value === "custom"
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value;
const res = await props.newAddressPath(
nameToSend,
emailDomain.value,
domainToSend,
cfToken.value,
enableRandomSubdomain.value
subdomainMode.value === "random"
);
jwt.value = res["jwt"];
addressPassword.value = res["password"] || '';
@@ -188,6 +192,10 @@ const newEmail = async () => {
}
};
const openRedeemPage = async () => {
await router.push(getRouterPathWithLang('/redeem', locale.value));
};
const addressPrefix = computed(() => {
// if user has role, return role prefix
if (userSettings.value?.user_role) {
@@ -206,7 +214,7 @@ const canUseRandomSubdomain = computed(() => {
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false;
subdomainMode.value = "normal";
}
});
@@ -294,6 +302,16 @@ onMounted(async () => {
</template>
{{ t('getNewEmail') }}
</n-button>
<div v-if="openSettings.enableRedeemCode" class="redeem-entry-section">
<n-divider />
<n-text type="info" class="redeem-entry-tip">{{ t('redeemEntryTip') }}</n-text>
<n-button data-testid="redeem-entry" block secondary strong @click="openRedeemPage">
<template #icon>
<n-icon :component="RedeemOutlined" />
</template>
{{ t('useRedeemCode') }}
</n-button>
</div>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="showNewAddressTab" name="register" :tab="t('getNewEmail')">
@@ -321,20 +339,42 @@ onMounted(async () => {
:options="domainsOptions" />
</n-input-group>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<div style="width: 100%;">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</div>
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading"
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
{{ t('getNewEmail') }}
</n-button>
<div v-if="openSettings.enableRedeemCode" class="redeem-entry-section">
<n-divider />
<n-text type="info" class="redeem-entry-tip">{{ t('redeemEntryTip') }}</n-text>
<n-button data-testid="redeem-entry-register" block secondary strong
@click="openRedeemPage">
<template #icon>
<n-icon :component="RedeemOutlined" />
</template>
{{ t('useRedeemCode') }}
</n-button>
</div>
</n-form>
</n-spin>
</n-tab-pane>
@@ -360,6 +400,21 @@ onMounted(async () => {
margin-top: 10px;
}
.redeem-entry-section {
margin-top: 18px;
}
.redeem-entry-section :deep(.n-divider) {
margin: 0 0 14px;
}
.redeem-entry-tip {
display: block;
font-size: 13px;
line-height: 1.6;
text-align: center;
}
.switch-login-button {
display: flex;
justify-content: center;
+3 -3
View File
@@ -113,7 +113,7 @@ const changePassword = async () => {
<n-divider v-if="openSettings.enableUserDeleteEmail" />
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
block strong>
{{ t('deleteAccount') }}
{{ t('deleteMailbox') }}
</n-button>
</n-card>
@@ -125,11 +125,11 @@ const changePassword = async () => {
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')">
<p>{{ t('deleteAccountConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
{{ t('deleteAccount') }}
{{ t('deleteMailbox') }}
</n-button>
</template>
</n-modal>
+1 -1
View File
@@ -71,7 +71,7 @@ onMounted(async () => {
<template #icon>
<n-icon :component="User" />
</template>
{{ t('userLogin') }}
{{ t('userCenter') }}
</n-button>
</n-card>
</div>
+289 -78
View File
@@ -2,11 +2,15 @@
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useScopedI18n } from '@/i18n/app'
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
import { SendRound } from '@vicons/material'
import AdminContact from '../common/AdminContact.vue'
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { blockRemoteContent } from '../../utils/remote-content-policy'
import { sanitizeHtml } from '../../utils/sanitize-html'
const message = useMessage()
const isPreview = ref(false)
@@ -14,15 +18,25 @@ const editorRef = shallowRef()
const sending = ref(false)
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
const {
settings, sendMailModel, indexTab, userSettings,
autoLoadRemoteImages, isDark,
} = useGlobalState()
const { t } = useScopedI18n('views.index.SendMail')
const contentTypes = [
const contentTypes = computed(() => [
{ label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' },
]
])
const previewContent = computed(() => {
const content = `${sendMailModel.value.content ?? ''}`
return autoLoadRemoteImages.value
? sanitizeHtml(content)
: blockRemoteContent(content).html
})
const normalizeSendMailText = (content) => {
return content
@@ -116,7 +130,7 @@ const requestAccess = async () => {
body: JSON.stringify({})
}
)
message.success(t("success"))
message.success(t("requestSuccess"))
await api.getSettings();
} catch (error) {
message.error(error.message || "error");
@@ -157,95 +171,292 @@ onMounted(async () => {
</script>
<template>
<div class="center" v-if="settings.address">
<n-card :bordered="false" embedded>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" :show-icon="false" :bordered="false">
{{ t('requestAccessTip') }}
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
}}</n-button>
</n-alert>
<AdminContact />
</div>
<div v-else>
<n-alert type="info" :show-icon="false" :bordered="false" closable>
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<n-flex justify="end">
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
</n-flex>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.fromName" />
<n-input :value="settings.address" disabled />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.toName" />
<n-input v-model:value="sendMailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="sendMailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-radio-group v-model:value="sendMailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card :bordered="false" embedded v-if="isPreview">
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
</div>
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>
</n-form>
<div class="composer-page" v-if="settings.address">
<n-card class="composer-card" :bordered="false" embedded>
<template #header>
<div class="composer-title">
<h2>{{ t('composeMail') }}</h2>
<n-text depth="3">{{ settings.address }}</n-text>
</div>
</template>
<template #header-extra>
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
{{ t('send_balance') }} · {{ settings.send_balance }}
</n-tag>
</template>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<div class="access-state">
<div class="access-copy">
<h3>{{ t('balanceUnavailable') }}</h3>
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
</div>
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
</div>
<div class="admin-contact"><AdminContact /></div>
</div>
<template v-else>
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
<n-grid-item>
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
<n-input :value="settings.address" readonly
:input-props="{ id: 'send-mail-sender-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
<n-input v-model:value="sendMailModel.fromName"
:input-props="{ id: 'send-mail-sender-name' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('recipientAddress')" required
:label-props="{ for: 'send-mail-recipient-address' }">
<n-input v-model:value="sendMailModel.toMail"
:input-props="{ id: 'send-mail-recipient-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
<n-input v-model:value="sendMailModel.toName"
:input-props="{ id: 'send-mail-recipient-name' }" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
<n-input v-model:value="sendMailModel.subject"
:input-props="{ id: 'send-mail-subject' }" />
</n-form-item>
<div class="editor-panel">
<div class="editor-panel-header">
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
class="required-mark">*</span></n-text>
<div class="editor-controls">
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
size="small" aria-labelledby="send-mail-content-label">
<n-radio-button v-for="option in contentTypes" :key="option.value"
:value="option.value" :label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
@click="isPreview = !isPreview">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</div>
</div>
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
</div>
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
@onCreated="handleCreated" />
</div>
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
:input-props="{ 'aria-label': t('content') }"
:autosize="{ minRows: 14, maxRows: 24 }" />
</div>
<div class="composer-actions">
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
<template #icon><n-icon :component="SendRound" /></template>
{{ t('send') }}
</n-button>
</div>
</n-form>
</template>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-button {
.composer-page {
width: 100%;
padding: 14px 0 24px;
text-align: left;
margin-right: 10px;
}
.center {
.composer-card {
width: min(900px, 100%);
margin: 0 auto;
}
.composer-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px 12px;
}
.composer-title > h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.composer-title .n-text {
font-size: 13px;
word-break: break-all;
}
.access-state {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 6px 0 18px;
}
.access-copy h3 {
margin: 0;
font-size: 16px;
}
.access-copy p {
max-width: 640px;
margin: 8px 0 0;
line-height: 1.65;
opacity: 0.72;
}
.admin-contact {
margin-top: 4px;
}
.editor-panel {
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.24);
border-radius: 3px;
}
.editor-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 46px;
padding: 6px 10px 6px 14px;
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
}
.editor-controls {
display: flex;
align-items: center;
gap: 8px;
}
.required-mark {
color: #d03050;
}
.format-options {
display: flex;
}
.format-options :deep(.n-radio-button) {
min-width: 72px;
text-align: center;
place-items: center;
justify-content: center;
}
.left {
.compose-preview {
min-height: 360px;
padding: 18px;
}
.rich-editor {
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
border-bottom: 1px solid #e5e7eb;
}
.rich-editor :deep(.w-e-text-container) {
min-height: 360px;
}
.rich-editor :deep(.w-e-scroll) {
min-height: 360px;
}
.compose-textarea :deep(.n-input__textarea-el),
.compose-textarea :deep(.n-input__placeholder) {
line-height: 1.7;
text-align: left;
place-items: left;
justify-content: left;
}
.n-alert {
margin-bottom: 10px;
.composer-form :deep(.n-input__input-el) {
text-align: left;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(128, 128, 128, 0.18);
}
.draft-status {
font-size: 13px;
}
@media (max-width: 640px) {
.composer-page {
padding-top: 8px;
}
.access-state {
align-items: stretch;
flex-direction: column;
gap: 16px;
}
.composer-card :deep(.n-card-header) {
flex-wrap: wrap;
gap: 8px 12px;
}
.composer-card :deep(.n-card-header__main) {
flex: 1 1 180px;
min-width: 0;
}
.composer-card :deep(.n-card-header__extra) {
margin-left: auto;
}
.editor-panel-header {
align-items: flex-start;
flex-wrap: wrap;
}
.editor-controls {
width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
}
.format-options {
max-width: 100%;
}
.format-options :deep(.n-radio-button) {
min-width: 0;
padding-right: 8px;
padding-left: 8px;
}
.rich-editor :deep(.w-e-toolbar) {
overflow-x: auto;
}
}
</style>
+41 -3
View File
@@ -56,6 +56,24 @@ const fetchMails = async () => {
}
}
const toggleCurrentMailUnread = async () => {
if (!currentMail.value || !openSettings.value.enableMailReadStatus) return
const mail = currentMail.value
const previousValue = mail.is_unread
const isUnread = previousValue !== 1
mail.is_unread = isUnread ? 1 : 0
try {
await api.fetch(`/api/mails/${mail.id}/read`, {
method: 'PATCH',
body: JSON.stringify({ isUnread }),
showLoading: false
})
message.success(t('readStatusUpdated'))
} catch {
mail.is_unread = previousValue
}
}
//
const deleteMail = async () => {
if (!currentMail.value) return;
@@ -170,7 +188,7 @@ onBeforeUnmount(() => {
<SettingsFilled />
</n-icon>
</template>
{{ t('accountSettings') }}
{{ t('mailboxSettings') }}
</n-button>
</n-flex>
<div v-if="isFirstPage" style="text-align: center; margin-top: 12px;">
@@ -182,7 +200,7 @@ onBeforeUnmount(() => {
<!-- 账户设置卡片 -->
<n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable
@close="showAccountSettingsCard = false" :title="t('accountSettings')">
@close="showAccountSettingsCard = false" :title="t('mailboxSettings')">
<AccountSettings />
</n-card>
@@ -216,10 +234,15 @@ onBeforeUnmount(() => {
<n-empty :description="t('noMails')" />
</div>
<div v-else>
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
<h3 v-if="currentMail.subject"
:class="{ 'mail-title-unread': openSettings.enableMailReadStatus && currentMail.is_unread === 1 }">
{{ currentMail.subject }}
</h3>
<div style="margin-top: 16px;">
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:enableMailReadStatus="openSettings.enableMailReadStatus"
:onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" />
</div>
</div>
@@ -246,4 +269,19 @@ onBeforeUnmount(() => {
margin-top: 20px;
width: 100%;
}
.mail-title-unread {
font-weight: 700;
}
.mail-title-unread::before {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 8px;
border-radius: 50%;
background: #2080f0;
content: '';
vertical-align: middle;
}
</style>
+29 -4
View File
@@ -7,6 +7,7 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
import Login from '../common/Login.vue';
@@ -15,6 +16,7 @@ const message = useMessage()
const router = useRouter()
const { locale, t } = useScopedI18n('views.user.AddressManagement')
const { t: credentialT } = useScopedI18n('components.AddressCredentialModal')
const data = ref([])
const count = ref(0)
@@ -24,11 +26,24 @@ const showTranferAddress = ref(false)
const currentAddress = ref("")
const currentAddressId = ref(0)
const targetUserEmail = ref('')
const showAddressCredential = ref(false)
const currentAddressCredential = ref('')
const credentialAddress = ref('')
const showCredential = async (row) => {
try {
const { jwt: addressCredential } = await api.fetch(`/user_api/bind_address_jwt/${row.id}`)
currentAddressCredential.value = addressCredential
credentialAddress.value = row.name
showAddressCredential.value = true
} catch (error) {
message.error(error.message || "error")
}
}
const changeMailAddress = async (address_id) => {
try {
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
message.success(t('changeMailAddress') + " " + t('success'));
if (!res.jwt) {
message.error("jwt not found");
return;
@@ -114,7 +129,7 @@ const fetchData = async () => {
const columns = [
{
title: t('name'),
title: t('emailAddress'),
key: "name"
},
{
@@ -146,6 +161,14 @@ const columns = [
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => showCredential(row)
},
{ default: () => credentialT('addressCredential') }
),
h(NPopconfirm,
{
onPositiveClick: () => changeMailAddress(row.id)
@@ -156,9 +179,9 @@ const columns = [
tertiary: true,
type: "primary",
},
{ default: () => t('changeMailAddress') }
{ default: () => t('openMailbox') }
),
default: () => `${t('changeMailAddress')}?`
default: () => `${t('openMailbox')}?`
}
),
h(NButton,
@@ -204,6 +227,8 @@ watch([page, pageSize], async () => {
<template>
<div>
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
:jwt="currentAddressCredential" />
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
<span>
<p>{{ t("transferAddressTip") }}</p>
+491
View File
@@ -0,0 +1,491 @@
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useScopedI18n } from '@/i18n/app'
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
import { SendRound } from '@vicons/material'
import AdminContact from '../common/AdminContact.vue'
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { blockRemoteContent } from '../../utils/remote-content-policy'
import { sanitizeHtml } from '../../utils/sanitize-html'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const sending = ref(false)
const settings = ref({
address: '',
send_balance: 0,
})
const props = defineProps({
addressId: {
type: Number,
default: 0,
},
addressOptions: {
type: Array,
default: () => [],
},
addressLoading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['addressScroll', 'sent', 'update:addressId'])
const {
sendMailModel, userSettings, autoLoadRemoteImages, isDark,
} = useGlobalState()
const { t } = useScopedI18n('views.index.SendMail')
const getApiPath = (path) => `/user_api/address/${props.addressId}/${path}`
const refreshSettings = async () => {
settings.value = await api.fetch(getApiPath('settings'))
}
const contentTypes = computed(() => [
{ label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' },
])
const previewContent = computed(() => {
const content = `${sendMailModel.value.content ?? ''}`
return autoLoadRemoteImages.value
? sanitizeHtml(content)
: blockRemoteContent(content).html
})
const normalizeSendMailText = (content) => {
return content
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
const hasSendMailContent = (content, contentType) => {
if (typeof content !== 'string' || !content) {
return false
}
if (contentType === 'text') {
return normalizeSendMailText(content).length > 0
}
const container = document.createElement('div')
container.innerHTML = content
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
const plainContent = normalizeSendMailText(container.textContent ?? '')
if (plainContent.length > 0) {
return true
}
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
}
const send = async () => {
if (sending.value) {
return
}
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
const content = `${sendMailModel.value.content ?? ''}`
if (!subject) {
message.error(t('subjectEmpty'))
return
}
if (!toMail) {
message.error(t('toMailEmpty'))
return
}
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
message.error(t('contentEmpty'))
return
}
const payload = {
from_name: sendMailModel.value.fromName,
to_name: sendMailModel.value.toName,
to_mail: toMail,
subject,
is_html: sendMailModel.value.contentType != 'text',
content,
}
sending.value = true
try {
await api.fetch(getApiPath('send_mail'),
{
method: 'POST',
body: JSON.stringify(payload)
})
sendMailModel.value = {
fromName: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
}
isPreview.value = false
message.success(t("successSend"));
emit('sent')
} catch (error) {
message.error(error.message || "error");
} finally {
sending.value = false
}
}
const requestAccess = async () => {
try {
await api.fetch(getApiPath('request_send_mail_access'),
{
method: 'POST',
body: JSON.stringify({})
}
)
message.success(t("requestSuccess"))
await refreshSettings();
} catch (error) {
message.error(error.message || "error");
}
}
const toolbarConfig = {
excludeKeys: ["uploadVideo"]
}
const editorConfig = {
MENU_CONF: {
'uploadImage': {
async customUpload() {
message.error(t('tooLarge'))
},
maxFileSize: 1 * 1024 * 1024,
base64LimitSize: 1 * 1024 * 1024,
}
}
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor;
}
onMounted(async () => {
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
await refreshSettings();
})
</script>
<template>
<div class="composer-page" v-if="settings.address">
<n-card class="composer-card" :bordered="false" embedded>
<template #header>
<div class="composer-title">
<h2>{{ t('composeMail') }}</h2>
</div>
</template>
<template #header-extra>
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
{{ t('send_balance') }} · {{ settings.send_balance }}
</n-tag>
</template>
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
<n-grid-item>
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
<n-select class="address-picker-select" :value="addressId"
:options="addressOptions" :loading="addressLoading" filterable
@scroll="emit('addressScroll', $event)"
@update:value="emit('update:addressId', $event)" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
<n-input v-model:value="sendMailModel.fromName"
:input-props="{ id: 'send-mail-sender-name' }" />
</n-form-item>
</n-grid-item>
</n-grid>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<div class="access-state">
<div class="access-copy">
<h3>{{ t('balanceUnavailable') }}</h3>
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
</div>
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
</div>
<div class="admin-contact"><AdminContact /></div>
</div>
<template v-else>
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
<n-grid-item>
<n-form-item :label="t('recipientAddress')" required
:label-props="{ for: 'send-mail-recipient-address' }">
<n-input v-model:value="sendMailModel.toMail"
:input-props="{ id: 'send-mail-recipient-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
<n-input v-model:value="sendMailModel.toName"
:input-props="{ id: 'send-mail-recipient-name' }" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
<n-input v-model:value="sendMailModel.subject"
:input-props="{ id: 'send-mail-subject' }" />
</n-form-item>
<div class="editor-panel">
<div class="editor-panel-header">
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
class="required-mark">*</span></n-text>
<div class="editor-controls">
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
size="small" aria-labelledby="send-mail-content-label">
<n-radio-button v-for="option in contentTypes" :key="option.value"
:value="option.value" :label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
@click="isPreview = !isPreview">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</div>
</div>
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
</div>
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
@onCreated="handleCreated" />
</div>
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
:input-props="{ 'aria-label': t('content') }"
:autosize="{ minRows: 14, maxRows: 24 }" />
</div>
<div class="composer-actions">
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
<template #icon><n-icon :component="SendRound" /></template>
{{ t('send') }}
</n-button>
</div>
</template>
</n-form>
</n-card>
</div>
</template>
<style scoped>
.composer-page {
width: 100%;
padding: 14px 0 24px;
text-align: left;
}
.composer-card {
width: min(900px, 100%);
margin: 0 auto;
}
.composer-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px 12px;
}
.composer-title > h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.composer-title .n-text {
font-size: 13px;
word-break: break-all;
}
.access-state {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 6px 0 18px;
}
.access-copy h3 {
margin: 0;
font-size: 16px;
}
.access-copy p {
max-width: 640px;
margin: 8px 0 0;
line-height: 1.65;
opacity: 0.72;
}
.admin-contact {
margin-top: 4px;
}
.editor-panel {
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.24);
border-radius: 3px;
}
.editor-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 46px;
padding: 6px 10px 6px 14px;
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
}
.editor-controls {
display: flex;
align-items: center;
gap: 8px;
}
.required-mark {
color: #d03050;
}
.format-options {
display: flex;
}
.format-options :deep(.n-radio-button) {
min-width: 72px;
text-align: center;
}
.compose-preview {
min-height: 360px;
padding: 18px;
}
.rich-editor {
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
border-bottom: 1px solid #e5e7eb;
}
.rich-editor :deep(.w-e-text-container) {
min-height: 360px;
}
.rich-editor :deep(.w-e-scroll) {
min-height: 360px;
}
.compose-textarea :deep(.n-input__textarea-el),
.compose-textarea :deep(.n-input__placeholder) {
line-height: 1.7;
text-align: left;
}
.composer-form :deep(.n-input__input-el) {
text-align: left;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(128, 128, 128, 0.18);
}
.draft-status {
font-size: 13px;
}
@media (max-width: 640px) {
.composer-page {
padding-top: 8px;
}
.access-state {
align-items: stretch;
flex-direction: column;
gap: 16px;
}
.composer-card :deep(.n-card-header) {
flex-wrap: wrap;
gap: 8px 12px;
}
.composer-card :deep(.n-card-header__main) {
flex: 1 1 180px;
min-width: 0;
}
.composer-card :deep(.n-card-header__extra) {
margin-left: auto;
}
.editor-panel-header {
align-items: flex-start;
flex-wrap: wrap;
}
.editor-controls {
width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
}
.format-options {
max-width: 100%;
}
.format-options :deep(.n-radio-button) {
min-width: 0;
padding-right: 8px;
padding-left: 8px;
}
.rich-editor :deep(.w-e-toolbar) {
overflow-x: auto;
}
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup>
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { api } from '../../api'
import { useGlobalState } from '../../store'
import SendBox from '../../components/SendBox.vue'
const SendMail = defineAsyncComponent(() => import('./SendMail.vue'))
const ADDRESS_PAGE_SIZE = 100
const props = defineProps({
mode: {
type: String,
default: 'send_mail',
},
})
const emit = defineEmits(['sent'])
const message = useMessage()
const { openSettings } = useGlobalState()
const { t } = useScopedI18n('views.user.UserSendBox')
const { t: mailboxT } = useScopedI18n('views.user.UserMailBox')
const selectedAddressId = ref(null)
const addressFilter = ref(null)
const addressOptions = ref([])
const addressCount = ref(0)
const addressLoading = ref(false)
const sendboxKey = ref(0)
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
const addressFilterOptions = computed(() => addressOptions.value.map((address) => ({
label: address.label,
value: address.address,
})))
const fetchAddresses = async () => {
if (addressLoading.value || (!hasMoreAddresses.value && addressOptions.value.length > 0)) {
return
}
addressLoading.value = true
try {
const offset = addressOptions.value.length
const { results, count } = await api.fetch(
`/user_api/bind_address?limit=${ADDRESS_PAGE_SIZE}&offset=${offset}`
)
addressOptions.value.push(...results.map((address) => ({
label: address.name,
value: address.id,
address: address.name,
})))
if (offset === 0) {
addressCount.value = count
}
if (props.mode === 'send_mail' && !selectedAddressId.value && addressOptions.value.length > 0) {
selectedAddressId.value = addressOptions.value[0].value
}
} catch (error) {
message.error(error.message || 'error')
} finally {
addressLoading.value = false
}
}
const handleAddressScroll = async (event) => {
const target = event.currentTarget
if (!target || target.scrollTop + target.clientHeight < target.scrollHeight - 24) {
return
}
await fetchAddresses()
}
const fetchSendbox = async (limit, offset) => {
return await api.fetch(
`/user_api/sendbox?limit=${limit}&offset=${offset}`
+ (addressFilter.value ? `&address=${encodeURIComponent(addressFilter.value)}` : '')
)
}
const deleteSendboxMail = async (mailId) => {
await api.fetch(`/user_api/sendbox/${mailId}`, { method: 'DELETE' })
}
const querySendbox = () => {
sendboxKey.value = Date.now()
}
watch(addressFilter, querySendbox)
onMounted(fetchAddresses)
</script>
<template>
<div class="user-send-box">
<template v-if="mode === 'send_mail'">
<n-empty v-if="!selectedAddressId" class="address-empty" :description="t('noAddress')" />
<SendMail v-else :key="selectedAddressId" v-model:address-id="selectedAddressId"
:address-options="addressOptions" :address-loading="addressLoading"
@address-scroll="handleAddressScroll" @sent="emit('sent')" />
</template>
<template v-else>
<n-input-group>
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
:loading="addressLoading" :placeholder="mailboxT('addressQueryTip')"
@scroll="handleAddressScroll" />
<n-button @click="querySendbox" type="primary" tertiary>
{{ mailboxT('query') }}
</n-button>
</n-input-group>
<div class="filter-spacing"></div>
<SendBox :key="sendboxKey" :fetch-mail-data="fetchSendbox" show-e-mail-from
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
:delete-mail="deleteSendboxMail" />
</template>
</div>
</template>
<style scoped>
.user-send-box {
padding-top: 10px;
text-align: left;
}
.filter-spacing {
margin-top: 10px;
}
.address-empty {
padding: 72px 0;
}
</style>
+1
View File
@@ -5,6 +5,7 @@ const API_PATHS = [
"/admin/",
"/telegram/",
"/external/",
"/redeem_api/",
];
export async function onRequest(context) {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "temp-email-pages",
"version": "1.11.0",
"version": "1.12.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -11,7 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^4.119.0"
"wrangler": "^4.129.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+2
View File
@@ -140,6 +140,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
collapsed: false,
items: [
{ text: 'Worker Variables', link: 'worker-vars' },
{ text: 'Frontend Variables', link: 'frontend-vars' },
{ text: 'Configure Email Sending', link: 'config-send-mail' },
]
},
@@ -169,6 +170,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
{ text: 'Configure Redemption Codes', link: 'feature/redeem-code' },
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
]
},
+2
View File
@@ -140,6 +140,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
collapsed: false,
items: [
{ text: 'Worker 变量说明', link: 'worker-vars' },
{ text: '前端变量说明', link: 'frontend-vars' },
{ text: '配置发送邮件', link: 'config-send-mail' },
]
},
@@ -169,6 +170,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
{ text: '配置其他 worker 增强', link: 'feature/another-worker-enhanced' },
{ text: '配置兑换码', link: 'feature/redeem-code' },
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
]
},
@@ -44,7 +44,7 @@ Then go to the repository page `Settings` -> `Secrets and variables` -> `Actions
| Name | Description |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FRONTEND_ENV` | Frontend configuration file used by the `Deploy Frontend` workflow. Copy the content from `frontend/.env.example`, [and modify it according to this guide](/en/guide/cli/pages.html). For separate frontend/backend deployment that talks to Worker directly, `VITE_API_BASE` should be the backend Worker API root URL, must start with `https://`, and must not include a trailing `/`. When this address is configured incorrectly, common symptoms are the `map` error or `405` API responses |
| `FRONTEND_ENV` | Frontend configuration file used by the `Deploy Frontend` workflow. Copy the content from `frontend/.env.example` and modify it according to [Frontend Variables](/en/guide/frontend-vars) |
| `FRONTEND_NAME` | The project name you created in Cloudflare Pages, can be created via [UI](https://temp-mail-docs.awsl.uk/en/guide/ui/pages.html) or [Command Line](https://temp-mail-docs.awsl.uk/en/guide/cli/pages.html) |
| `FRONTEND_BRANCH` | (Optional) Branch for pages deployment, can be left unconfigured, defaults to `production` |
| `PAGE_TOML` | (Optional) Used only by the `Deploy Frontend with page function` workflow. Required when using page functions to forward backend requests. Please copy the content from `pages/wrangler.toml` and modify the `service` field to your worker backend name according to actual situation. This workflow builds the frontend in Pages mode and uses same-origin requests, so it does not read `FRONTEND_ENV` |
@@ -29,6 +29,12 @@ Change `VITE_API_BASE` to the `worker` `url` created in the previous step. Do no
For example: `VITE_API_BASE=https://xxx.xxx.workers.dev`
Set the frontend default language with `VITE_DEFAULT_LANG`. Supported values are `zh`, `en`, `es`, `pt-BR`, `ja`, and `de`; an unset or invalid value falls back to `zh`.
For example: `VITE_DEFAULT_LANG=en`
See [Frontend Variables](/en/guide/frontend-vars) for other settings.
```bash
pnpm build --emptyOutDir
# The first deployment will prompt you to create a project, for production branch enter production
@@ -47,6 +53,8 @@ The first deployment will prompt you to create a project. For the `production` b
If your worker backend name is not `cloudflare_temp_email`, please modify `pages/wrangler.toml`.
To set the default language, add `VITE_DEFAULT_LANG=en` to `frontend/.env.pages.local`. Same-origin requests do not require `VITE_API_BASE`; see [Frontend Variables](/en/guide/frontend-vars) for other settings.
```bash
cd frontend
pnpm install
@@ -67,6 +67,8 @@ JWT_SECRET = "xxx"
# Admin console password, if not configured, console access is not allowed
# ADMIN_PASSWORDS = ["123", "456"]
# Admin API IP whitelist; applies to admin-password and Admin user-token access
# ADMIN_API_IP_WHITELIST = ["203.0.113.10"]
# Whether to allow users to create emails, not allowed if not configured
ENABLE_USER_CREATE_EMAIL = true
@@ -150,12 +150,31 @@ cd worker
wrangler secret put SMTP_CONFIG
```
## User Accounts, Mailboxes, Email Addresses, and Send Permission
These concepts are related, but they identify different objects:
| Concept | Description |
|---------|-------------|
| User account | Signs in to the user center and can link and manage multiple mailboxes. The account email is not automatically a sending or receiving address |
| Mailbox | The user-facing object that contains an inbox, sent mail, sending capability, and mailbox settings |
| Email address | The concrete value that identifies a mailbox, such as `name@example.com`. The current mailbox is authenticated by its Address JWT |
| Send permission and balance | Permission and balance records in `address_sender` are managed independently **per email address**; user roles configured by `NO_LIMIT_SEND_ROLE` can bypass address balance checks |
Multiple email addresses bound to the same user account therefore do not share send permission or balance. To request permission for an address:
1. Switch to the mailbox that needs to send mail
2. Open the **Send Mail** page
3. Click **Request Access**
The request affects only the address selected at that time. After switching to another address, check that address's own balance and request permission separately if needed. An email address can also hold send permission without being bound to a user account.
## Send Balance Mechanism
Users need a send balance to send emails. The balance mechanism works as follows:
An email address needs its own send balance to send emails. The balance mechanism works as follows:
1. **Auto-initialize Default Quota**: When `DEFAULT_SEND_BALANCE > 0`, the system automatically initializes the default quota when the user opens the send page or calls the send-mail API for the first time
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can still click "Request Send Permission" in the frontend to create a pending send-access record for admins to review
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, switch to the target email address in the frontend and click **Request Access** to create a pending send-access record for the current address
3. **Unlimited Sending**: The following methods can bypass balance checks:
- Add the address to the "No Limit Send Address List" in the admin console
- Configure the `NO_LIMIT_SEND_ROLE` environment variable to specify roles that can send without limits
@@ -4,31 +4,31 @@
> You need to configure `ADMIN_PASSWORDS` or `ADMIN_USER_ROLE` to access the admin console
> Admin role configuration: if the user role equals ADMIN_USER_ROLE, they can access the admin console
After deploying the frontend application, click the upper-left logo 5 times or visit the `/admin` path to enter the management console.
After deploying the frontend application, click the upper-left logo 5 times or visit the `/admin` path to enter the Admin Console.
You need to configure `ADMIN_PASSWORDS` in the backend or ensure the current user role is `ADMIN_USER_ROLE`, otherwise access to the console will be denied.
## Admin Passwords vs User Accounts
`ADMIN_PASSWORDS` is the management password for the Admin console. It is not a site user account
and does not correspond to any mailbox address. Logging in with an admin password grants access to
`ADMIN_PASSWORDS` is the password for the Admin Console. It is not a site user account
and does not correspond to any mailbox. Logging in with an admin password grants access to
the console, but that login itself cannot receive mail.
Site user accounts are stored in the `users` table and use the user login flow. Whether a user can
receive mail depends on whether they created or bound a mailbox address. Creating a normal user
whose email looks like `admin@example.com` does not automatically grant admin permissions.
receive mail depends on whether they created or bound a mailbox. Creating a normal user
whose account email is `admin@example.com` does not automatically grant admin permissions.
If you want a user account to access the Admin console, configure `ADMIN_USER_ROLE` and assign the
same role to that user in user management.
![admin](/feature/admin.png)
## Account List Sorting
## Mailbox List Sorting
The Accounts tab in the admin console supports column sorting. Click the column header to toggle ascending/descending order for:
**Admin Console → Mailbox Management → Mailboxes** supports column sorting. Click a column header to toggle ascending/descending order for:
- ID
- Name
- Email Address
- Created At
- Updated At
- Mail Count
@@ -1,17 +1,17 @@
# Adding Google Ads to Your Website
## Command Line Deployment
Google Ads uses the following two variables. See [Google AdSense](https://www.google.com/adsense/start/) for the values:
Modify the `.env.prod` file
Add the following two variables, refer to [Google AdSense](https://www.google.com/adsense/start/) for specific values
```txt
```ini
VITE_GOOGLE_AD_CLIENT=ca-pub-123456
VITE_GOOGLE_AD_SLOT=123456
```
Then execute the following commands to redeploy pages.
Ads are not loaded when either variable is empty. See [Frontend Variables](/en/guide/frontend-vars) for their types and defaults.
## CLI Deployment
Add both variables to `frontend/.env.prod`, then rebuild and deploy:
```bash
pnpm build --emptyOutDir
@@ -19,11 +19,25 @@ pnpm build --emptyOutDir
pnpm run deploy
```
## GitHub Action Deployment
See [CLI Frontend Deployment](/en/guide/cli/pages) for the complete steps.
Modify `FRONTEND_ENV`, add the following two variables, refer to [Google AdSense](https://www.google.com/adsense/start/) for specific values, then redeploy pages.
## GitHub Actions Deployment
```txt
VITE_GOOGLE_AD_CLIENT=ca-pub-123456
VITE_GOOGLE_AD_SLOT=123456
Add both variables to the existing `FRONTEND_ENV` secret, then run the `Deploy Frontend` workflow again.
See [GitHub Actions Deployment](/en/guide/actions/github-action) for the complete steps.
## Manual ZIP Deployment
Edit `app-config` in the archive's `index.html` and use the field names without the `VITE_` prefix:
```html
<script id="app-config" type="application/json">
{
"GOOGLE_AD_CLIENT": "ca-pub-123456",
"GOOGLE_AD_SLOT": "123456"
}
</script>
```
See [Manual ZIP Deployment](/en/guide/ui/pages) for the complete steps.
@@ -49,6 +49,12 @@ print(response.json())
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
## Mail Read Status API
Enable `ENABLE_MAIL_READ_STATUS` and upgrade the database first. Historical mail has `is_unread = NULL` and is treated as read; new mail has `is_unread = 1`. Opening unread mail from the web mail list marks it as read, and its detail view can switch the state manually. Refreshing the page does not mark it as read:
- `PATCH /api/mails/:id/read`: set one mail belonging to the current address; use `{ "isUnread": true }` for unread or `{ "isUnread": false }` for read
## Admin Get Mail API
Fetch a single mail by mail ID without a mailbox JWT. Authenticate with `x-admin-auth`.
@@ -0,0 +1,64 @@
# Redemption Codes
Set `ENABLE_REDEEM_CODE = true` to enable the standalone `/redeem` page for code validation and benefit fulfillment. Payment, orders, inventory, and card issuing stay outside this project. Codes generated in Admin can be exported to an external sales platform. When disabled, the page entry, Admin menu, and related APIs are unavailable.
Optionally set `REDEEM_CODE_URL` to the external page where users obtain codes. The redemption page hides this link when the variable is unset.
## Database
Run the database migration from the Admin database page after upgrading. The migration files and `db/schema.sql` are the source of truth for the table structure.
When Admin creates codes, the Worker uses `crypto.randomUUID()` to generate UUID v4 codes without a fixed prefix. `code` stores the case-sensitive plaintext redemption code so Admin exports can contain usable codes. `redeem_type` selects the business handler, and `value` is a plain string parsed by that handler. The system maintains `redeemed`, `result`, and the redemption timestamp. `result` stores the JSON redemption result encrypted with AES-256-GCM using a key derived from `JWT_SECRET`. Every code requires a future `expires_at`; redemption and result retrieval stop after it expires. Changing `JWT_SECRET` invalidates both existing address credentials and stored redemption results.
The external platform only sells and distributes codes exported from Admin. This project does not handle payment, orders, or inventory.
## Supported benefits
### Role benefits
```text
redeem_type = role
value = vip
```
The role must exist in `USER_ROLES` and cannot equal `ADMIN_USER_ROLE`. Redemption applies all prefix, domain, address-count, and sending privileges configured for that role. Configure a role with `prefix: ""` for permanent prefix-free addresses.
Redemption refuses to overwrite an existing role that differs from both the default and target roles, leaving the code unused. This check also applies to concurrent redemptions of different roles. When signed-in users redeem for themselves, the page refreshes their role configuration and access token.
### Sending credits
```text
redeem_type = send_balance
value = 100
```
This adds to `address_sender.balance` for the target address entered on the redemption page. Mailboxes without a sender record first receive `DEFAULT_SEND_BALANCE`, then the redeemed credits; existing records do not receive default credits again. Credits belong to an email address. Redemption does not re-enable an address disabled by an administrator.
### Custom mailbox
```text
redeem_type = address_prefix_once
value = vip
```
`value` accepts lowercase letters and digits, or it may be empty, and must leave at least one character under `MAX_ADDRESS_LEN` for user input. The redemption module prepends it directly to the user's input, then creates the address with the system prefix disabled; the configured address-name regex and block list still apply. An empty value therefore creates a prefix-free address. The first redemption creates an address and writes the complete response to `result` in encrypted form. The page only displays the address and credential; it does not switch to or bind the mailbox automatically. Further redemptions before expiry return the same complete result. If temporary-address cleanup removes its row, the result is no longer available and the system does not recreate the address.
## Admin
The Admin redemption-code page filters by one type at a time and shows type-specific business columns. Batch generation and export both use the currently selected type. Codes in one batch share their benefit value, enabled state, and expiry, with a limit of 500 UUID v4 codes per batch. After generation, the page downloads a CSV containing only that batch.
CSV export is also limited to one type per operation and contains usable plaintext codes. The export dialog requires a row limit, capped at 10,000.
## API
| Endpoint | Purpose |
| --- | --- |
| `POST /redeem_api/query` | Query a code's type, value, and status |
| `POST /redeem_api/result` | Retrieve the result of an unexpired redeemed code |
| `POST /redeem_api/redeem` | Create an address, update a role, or add sending credits |
User-facing redemption endpoints bypass address, user, and Admin authentication; the code itself is the business credential. A configured site-access password still takes priority. The endpoints also use IP rate limiting and access controls. Role and credit business writes are atomically consumed once. The first successful special-address request fixes its complete result, which remains retrievable until expiry.
## Adding benefit types
To add a benefit, register a new `redeem_type` handler with strict `value` validation, a safe preview, and its business logic. The `redeem_codes` table does not need to change.
@@ -2,12 +2,13 @@
## Send Email via HTTP API
There are two HTTP API endpoints for sending emails:
There are three HTTP API endpoints for sending emails:
| Endpoint | Authentication | Use Case |
|----------|---------------|----------|
| `/api/send_mail` | `Authorization: Bearer <address_JWT>` header | Internal calls, requires cookie / header auth |
| `/external/api/send_mail` | `token` field in request body | External system integration, no header auth needed |
| `/user_api/address/:address_id/send_mail` | `x-user-token: <user_JWT>` header | Signed-in users sending from one of their bound addresses |
::: tip What is "Address JWT"?
The Address JWT is the `jwt` field returned when creating an email address via `/api/new_address` or `/admin/new_address`.
@@ -59,6 +60,44 @@ res = requests.post(
)
```
### Method 3: User JWT (`/user_api/address/:address_id/send_mail`)
Obtain `address_id` from the paginated `GET /user_api/bind_address` response. The backend verifies that the address belongs to the current user; clients cannot choose an arbitrary sender address.
If the site grants unlimited sending to the current user's role through `NO_LIMIT_SEND_ROLE`, also send the `access_token` returned by `GET /user_api/settings`. The frontend handles this token automatically.
```python
send_body = {
"from_name": "Sender Name",
"to_name": "Recipient Name",
"to_mail": "Recipient Address",
"subject": "Email Subject",
"is_html": False,
"content": "Email content",
}
res = requests.post(
"https://your_worker_domain/user_api/address/123/send_mail",
json=send_body,
headers={
"x-user-token": "<user_JWT>",
# "x-user-access-token": "<user_access_token>", # Required for role permissions
"Content-Type": "application/json",
},
)
```
The same user-address API group also provides:
| Method | Endpoint | Description |
| --- | --- | --- |
| `GET` | `/user_api/address/:address_id/settings` | Get the address and remaining send balance |
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | Request send access for the address |
| `GET` | `/user_api/sendbox?limit=20&offset=0&address=optional-address` | List the current user's sent items, optionally filtered by a bound address |
| `DELETE` | `/user_api/sendbox/:mail_id` | Delete one sent item owned by the current user |
All endpoints require a User JWT. Address-scoped endpoints verify that `address_id` is bound to the current user, while user-level sent-item endpoints only return or delete records for the user's bound addresses. The user access token is only used to apply optional role permissions.
## Send Email via SMTP
Please first refer to [Configure SMTP Proxy](/en/guide/feature/config-smtp-proxy.html).
@@ -27,7 +27,7 @@ RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
RANDOM_SUBDOMAIN_LENGTH = 8
```
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow optional random second-level subdomains
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow random or manually entered subdomains
- `RANDOM_SUBDOMAIN_LENGTH`: random string length, range `1-63`, default `8`
The create-address APIs only generate a random subdomain when the request explicitly passes
@@ -47,6 +47,10 @@ the request body:
If you want to create an address under a specific subdomain such as `team.abc.com`, do not pass
`enableRandomSubdomain: true`; use the direct-subdomain flow below instead.
For base domains in `RANDOM_SUBDOMAIN_DOMAINS`, the web and admin pages offer **Normal Domain**,
**Use Random Subdomain**, and **Use Custom Subdomain** as single-choice modes. In custom mode,
enter only `team`; the frontend combines it as `team.abc.com`.
> [!NOTE]
> This feature only appends a random second-level subdomain when the mailbox is created.
>
@@ -77,10 +81,10 @@ If you want to create an address under a specific subdomain such as `team.abc.co
>
> Reference issue: [#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035)
## Let APIs Specify Subdomains Directly
## Let APIs Specify Other Subdomains Directly
If you do not want the system to generate a random subdomain, and instead want the caller to
explicitly create addresses like `team.abc.com`, enable:
If a base domain is not in `RANDOM_SUBDOMAIN_DOMAINS`, but API callers still need to create
addresses like `team.abc.com` directly, enable:
```toml
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
@@ -93,7 +97,7 @@ addresses can be created through `/api/new_address` or `/admin/new_address`:
- `name@dev.team.abc.com`
> [!NOTE]
> This only relaxes the domain validation used by the create-address APIs. It does not change the
> default domain dropdown, and it does not create Cloudflare-side subdomain mail routes for you.
> This switch only relaxes create-address API domain validation. It does not change the frontend
> domain scope or create Cloudflare-side subdomain mail routes for you.
>
> If the admin panel has already saved an override once, you can switch it back to **Follow Environment Variable** to clear the override and return to env fallback behavior.
@@ -0,0 +1,44 @@
# Frontend Variables
Frontend configuration is public in the browser. Do not put passwords, API keys, or other secrets in these values.
## Configuration Methods
### ENV
For CLI deployment, add the variables to `frontend/.env.prod`. For Worker Assets or Page Functions deployment, use `frontend/.env.pages.local`. For the GitHub Actions `Deploy Frontend` workflow, put the same content in the `FRONTEND_ENV` repository secret. The `Deploy Frontend with page function` workflow does not read this secret.
```ini
VITE_API_BASE=https://temp-email-api.example.com
VITE_DEFAULT_LANG=en
```
See [CLI Deployment](/en/guide/cli/pages) and [GitHub Actions Deployment](/en/guide/actions/github-action) for complete steps.
### index.html
When using a prebuilt frontend ZIP, edit `app-config` in `index.html`. Field names do not use the `VITE_` prefix:
```html
<script id="app-config" type="application/json">
{
"API_BASE": "https://temp-email-api.example.com",
"DEFAULT_LANG": "en"
}
</script>
```
Fields set in `app-config` override ENV build values. Omitted fields, or a missing `app-config` tag, continue to use ENV build values. See [Manual ZIP Deployment](/en/guide/ui/pages) for complete steps.
## Variable Reference
No frontend variable is required for every deployment method. `VITE_API_BASE` is required only when the frontend and backend are deployed separately and the browser must request the Worker domain directly. Leave it empty for same-origin deployments such as Worker Assets or Page Functions.
| ENV Variable | `app-config` Field | Required | Type | Default | Description |
| --- | --- | --- | --- | --- | --- |
| `VITE_API_BASE` | `API_BASE` | Depends on deployment | Text | Empty | Backend API root URL beginning with `https://` and without a trailing `/`; an empty value uses the same-origin API |
| `VITE_DEFAULT_LANG` | `DEFAULT_LANG` | No | Text | `zh` | Default language: `zh`, `en`, `es`, `pt-BR`, `ja`, or `de` |
| `VITE_CF_WEB_ANALY_TOKEN` | `CF_WEB_ANALY_TOKEN` | No | Text | Empty | Cloudflare Web Analytics Token |
| `VITE_IS_TELEGRAM` | `IS_TELEGRAM` | No | Boolean | `false` | Whether to enable Telegram Mini App; see [Telegram Configuration](/en/guide/feature/telegram) |
| `VITE_GOOGLE_AD_CLIENT` | `GOOGLE_AD_CLIENT` | No | Text | Empty | Google AdSense Client ID; see [Google Ads Configuration](/en/guide/feature/google-ads) |
| `VITE_GOOGLE_AD_SLOT` | `GOOGLE_AD_SLOT` | No | Text | Empty | Google AdSense Slot ID; see [Google Ads Configuration](/en/guide/feature/google-ads) |
+38 -19
View File
@@ -68,24 +68,34 @@ const generate = async () => {
const arrayBuffer = await response.arrayBuffer();
var zip = new JSZip();
await zip.loadAsync(arrayBuffer);
let target_path = ""
const directory = zip.folder("assets");
if (directory) {
for (const [relativePath, zipEntry] of Object.entries(directory.files)) {
console.log(relativePath);
if (relativePath.startsWith("assets/index-") && relativePath.endsWith(".js")){
let content = await zipEntry.async("string");
content = content.replaceAll("https://temp-email-api.xxx.xxx", normalizedDomain);
target_path = relativePath;
zip.file(relativePath, content);
break;
}
}
}
if (!target_path) {
errorMessage.value = "Could not find the frontend entry file. Generation failed"
const indexEntry = zip.file("index.html");
if (!indexEntry) {
errorMessage.value = "Could not find index.html. Generation failed"
return
}
const content = await indexEntry.async("string");
const configPattern = /(<script id="app-config" type="application\/json">)([\s\S]*?)(<\/script>)/;
const configMatch = content.match(configPattern);
if (!configMatch) {
errorMessage.value = "Could not find app-config. Generation failed"
return
}
let appConfig = {};
try {
appConfig = JSON.parse(configMatch[2]);
} catch {
errorMessage.value = "Invalid app-config. Generation failed"
return
}
if (!appConfig || typeof appConfig !== "object" || Array.isArray(appConfig)) {
errorMessage.value = "Invalid app-config. Generation failed"
return
}
appConfig.API_BASE = normalizedDomain;
zip.file("index.html", content.replace(
configPattern,
(_, openTag, _config, closeTag) => `${openTag}${JSON.stringify(appConfig)}${closeTag}`
));
const blob = await zip.generateAsync({ type: "blob" });
const url = window.URL.createObjectURL(blob);
errorMessage.value = ""
@@ -133,12 +143,21 @@ const generate = async () => {
> [!NOTE]
> You can also deploy manually. Download the zip from here: [frontend.zip](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip)
>
> Modify the index-xxx.js file in the archive, where xx is a random string
>
> Search for `https://temp-email-api.xxx.xxx` and replace it with your worker's backend API root URL, then deploy the new zip file. If you replace it with the frontend Pages domain, common symptoms are the `map` error or `405` responses from API requests
> Extract the archive and edit `app-config` in `index.html`. When finished, compress all files again and upload the archive. Do not include the outer directory in the archive.
>
> If you entered the wrong address the first time and still see errors after redeploying, test in an incognito window or clear browser cache so the browser stops using the old frontend assets.
```html
<script id="app-config" type="application/json">
{
"API_BASE": "https://temp-email-api.example.com",
"DEFAULT_LANG": "en"
}
</script>
```
`API_BASE` is the backend API root URL without a trailing `/`; `DEFAULT_LANG` supports `zh`, `en`, `es`, `pt-BR`, `ja`, and `de`. You can also set `CF_WEB_ANALY_TOKEN`, `IS_TELEGRAM`, `GOOGLE_AD_CLIENT`, and `GOOGLE_AD_SLOT`; see [Frontend Variables](/en/guide/frontend-vars) for their purpose and values. Fields present in `app-config` override the corresponding settings built into JavaScript, while omitted fields keep their existing settings.
4. Select `Pages`, click `Create Pages`, modify the name, upload the downloaded zip package
> [!warning] Important: SPA Mode
+12 -6
View File
@@ -12,6 +12,9 @@
| `ADMIN_PASSWORDS` | JSON | Admin console passwords, console access disabled if not configured | `["123", "456"]` |
| `ENABLE_USER_CREATE_EMAIL` | Text/JSON | Whether to allow users to create mailboxes, disabled if not configured | `true` |
| `ENABLE_USER_DELETE_EMAIL` | Text/JSON | Whether to allow users to delete emails, disabled if not configured | `true` |
| `ENABLE_MAIL_READ_STATUS` | Text/JSON | Enables read/unread mail state. Upgrade the database schema before enabling | `true` |
| `ENABLE_REDEEM_CODE` | Text/JSON | Enables the redemption page, public redemption APIs, and Admin redemption management; disabled by default | `true` |
| `REDEEM_CODE_URL` | Text | External URL for obtaining redemption codes; the link is hidden when unset | `https://example.com/redeem-codes` |
> [!IMPORTANT] `DOMAINS` and `DEFAULT_DOMAINS` must already be set up in Cloudflare
> Every domain you put here (including `DEFAULT_DOMAINS`, `USER_ROLES.domains`, `RANDOM_SUBDOMAIN_DOMAINS` further below) **must already have Cloudflare Email Routing enabled and its email DNS records provisioned**. After the Worker is deployed, bind the domain's Catch-all rule to that Worker; otherwise inbound mail will never reach the Worker.
@@ -22,8 +25,11 @@
| Variable Name | Type | Description | Example |
| ------------------------------ | --------- | ------------------------------------------------------- | ---------------- |
| `PASSWORDS` | JSON | Website private passwords, required after configuration | `["123", "456"]` |
| `ADMIN_API_IP_WHITELIST` | JSON | Admin API IP whitelist; when configured, only listed IPs may access `/admin/*` | `["203.0.113.10"]` |
| `DISABLE_ADMIN_PASSWORD_CHECK` | Text/JSON | Warning: Admin console without password or user check | `false` |
When `ADMIN_API_IP_WHITELIST` is unset or empty, source IPs are not restricted. Once configured, it applies to both admin-password and Admin user-token access, trusts only Cloudflare's `CF-Connecting-IP` header, and denies requests without that header.
## Email Related Variables
| Variable Name | Type | Description | Example |
@@ -32,12 +38,13 @@
| `MIN_ADDRESS_LEN` | Number | Minimum length of `email address` name | `1` |
| `MAX_ADDRESS_LEN` | Number | Maximum length of `email address` name | `30` |
| `DISABLE_CUSTOM_ADDRESS_NAME` | Text/JSON | Disable custom email address names, if set to true, users cannot enter custom names and they will be auto-generated | `true` |
| `DISABLE_ADDRESS_UPDATED_AT` | Text/JSON | Defaults to `false`. Set to `true` to stop individual and user-wide address activity keep-alive updates and disable built-in manual and scheduled inactive-address cleanup. Initial address timestamps, password operations, and other cleanup rules remain unchanged | `true` |
| `ADDRESS_CHECK_REGEX` | Text | Regular expression for `email address` name, used for validation only | `^(?!.*admin).*` |
| `ADDRESS_REGEX` | Text | Regular expression to replace illegal symbols in `email address` name, symbols not in the regex will be replaced. Default is `[^a-z0-9]` if not set. Use with caution as some symbols may prevent email reception | `[^a-z0-9]` |
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | Text/JSON | Whether to allow create-address APIs to use base-domain suffix matching. When enabled, if `example.com` is allowed, `/api/new_address` and `/admin/new_address` can also accept `foo.example.com` or `a.b.example.com` | `true` |
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow optional random subdomain creation, so `name@abc.com` can become `name@<random>.abc.com` | `["abc.com"]` |
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow random or manual subdomains; random mode can turn `name@abc.com` into `name@<random>.abc.com` | `["abc.com"]` |
| `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` |
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
@@ -50,8 +57,8 @@
> [!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.
> `RANDOM_SUBDOMAIN_DOMAINS` defines the base-domain scope shared by the frontend random and manual
> subdomain modes. 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
@@ -65,9 +72,8 @@
> Subdomain addresses are usually best used for receiving only; for sending, prefer the main
> domain.
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is different from random subdomain generation: it lets
> API callers **directly specify** a subdomain such as `foo.example.com`, while random subdomain
> generation appends one automatically during creation.
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is independent from those frontend modes. It lets API
> callers **directly specify** subdomains such as `foo.example.com` under other allowed base domains.
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` precedence: if the env is explicitly set to `false`, the
> feature is globally forced off; otherwise the persisted admin setting takes precedence, and the env
@@ -44,7 +44,7 @@
| 名称 | 说明 |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FRONTEND_ENV` | `Deploy Frontend` workflow 使用的前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html)。如果是前后端分离直连 Worker,`VITE_API_BASE` 应填写后端 Worker API 根地址,并且以 `https://` 开头、末尾不要带 `/`。地址配置错误时,常见现象是前端报 `map` 错误或接口返回 `405` |
| `FRONTEND_ENV` | `Deploy Frontend` workflow 使用的前端配置文件,请复制 `frontend/.env.example` 的内容,并参考 [前端变量说明](/zh/guide/frontend-vars) 修改 |
| `FRONTEND_NAME` | 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建 |
| `FRONTEND_BRANCH` | (可选) pages 部署的分支,可不配置,默认 `production` |
| `PAGE_TOML` | (可选) 仅供 `Deploy Frontend with page function` workflow 使用。通过 page functions 转发后端请求时需要配置,请复制 `pages/wrangler.toml` 的内容,并根据实际情况修改 `service` 字段为你的 worker 后端名称。这个 workflow 会以 Pages 模式构建前端并走同域请求,因此不会读取 `FRONTEND_ENV` |
@@ -29,6 +29,12 @@ cp .env.example .env.prod
例如: `VITE_API_BASE=https://xxx.xxx.workers.dev`
可通过 `VITE_DEFAULT_LANG` 设置前端默认语言,支持 `zh``en``es``pt-BR``ja``de`,未配置或配置无效时使用 `zh`
例如: `VITE_DEFAULT_LANG=en`
其他配置项请查看 [前端变量说明](/zh/guide/frontend-vars)。
```bash
pnpm build --emptyOutDir
# 第一次部署会提示创建项目, production 分支请填写 production
@@ -47,6 +53,8 @@ pnpm run deploy
如果你的 worker 后端 名称不为 `cloudflare_temp_email` 请修改 `pages/wrangler.toml`
如需设置默认语言,在 `frontend/.env.pages.local` 中添加 `VITE_DEFAULT_LANG=en`。同域请求不需要设置 `VITE_API_BASE`,其他配置项请查看 [前端变量说明](/zh/guide/frontend-vars)。
```bash
cd frontend
pnpm install
@@ -67,6 +67,8 @@ JWT_SECRET = "xxx"
# admin 控制台密码, 不配置则不允许访问控制台
# ADMIN_PASSWORDS = ["123", "456"]
# Admin API IP 白名单,同时限制管理员密码和 Admin 用户令牌访问
# ADMIN_API_IP_WHITELIST = ["203.0.113.10"]
# 是否允许用户创建邮件, 不配置则不允许
ENABLE_USER_CREATE_EMAIL = true
@@ -150,14 +150,33 @@ cd worker
wrangler secret put SMTP_CONFIG
```
## 用户账号、邮箱、邮箱地址与发信权限
这些概念相互关联,但不是同一个对象:
| 概念 | 说明 |
|------|------|
| 用户账号 | 用于登录用户中心,可绑定和管理多个邮箱;用户账号的用户邮箱本身不等于用于收发信的邮箱地址 |
| 邮箱 | 包含收件箱、发件箱、发信能力和邮箱设置的业务对象 |
| 邮箱地址 | 标识邮箱的具体值,例如 `name@example.com`;当前邮箱由该地址的 Address JWT 认证 |
| 发信权限与额度 | `address_sender` 中的权限和额度以**邮箱地址**为单位独立管理;`NO_LIMIT_SEND_ROLE` 配置的用户角色可以绕过地址额度检查 |
因此,同一用户账号绑定的多个邮箱地址不会共享发信权限或余额。为某个地址申请发信权限时:
1. 先在前端切换到需要发信的邮箱
2. 打开「发送邮件」页
3. 点击「申请权限」
申请只作用于当时选中的邮箱地址。切换到另一个地址后,需要查看该地址自己的额度,并在需要时单独申请。邮箱地址不必绑定用户账号也可以独立持有发信权限。
## 发信余额机制
用户发送邮件需要有发信余额。余额机制如下:
邮箱地址发送邮件需要有该地址的发信余额。余额机制如下:
1. **自动初始化默认额度**:当 `DEFAULT_SEND_BALANCE > 0` 时,用户打开前端发信页或第一次调用发信接口时,系统会自动为该地址初始化默认额度
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`用户仍可以在前端界面点击「申请发信权限」按钮,创建待管理员处理的发信权限记录
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`使用者可以在前端切换到目标邮箱地址并点击「申请权限」,为当前地址创建待管理员处理的发信权限记录
3. **无限制发送**:以下方式可以跳过余额检查:
- 在 admin 后台将地址加入「无限制发送地址列表」
- 在管理后台将邮箱地址加入「无限制发送地址列表」
- 配置 `NO_LIMIT_SEND_ROLE` 环境变量,指定可以无限发送的用户角色
> [!NOTE]
@@ -175,4 +194,4 @@ wrangler secret put SMTP_CONFIG
适合未完成 Email Routing onboarding 的域名,或 Workers 免费版。
只有收件人在 admin 后台的 `已验证地址列表` 中时,才会通过 `SEND_MAIL` binding 发信。
只有收件人在管理后台的 `已验证地址列表` 中时,才会通过 `SEND_MAIL` binding 发信。
+13 -13
View File
@@ -1,29 +1,29 @@
# Admin 控制
# 管理后
> [!NOTE]
> 需要配置 `ADMIN_PASSWORDS` 或者 `ADMIN_USER_ROLE` 才可以访问 admin 控制
> admin 角色配置, 如果用户角色等于 ADMIN_USER_ROLE 则可以访问 admin 控制
> 需要配置 `ADMIN_PASSWORDS` 或者 `ADMIN_USER_ROLE` 才可以访问管理后
> 管理员角色配置如果用户角色等于 `ADMIN_USER_ROLE`则可以访问管理后
部署前端应用之后,点击 左上角 logo 5 次 或者访问 `/admin` 路径即可进入管理控制台。
部署前端应用之后,点击左上角 logo 5 次访问 `/admin` 路径即可进入管理台。
需要在后端配置 `ADMIN_PASSWORDS` 或者当前用户角色为 `ADMIN_USER_ROLE`,否则不允许访问控制台。
需要在后端配置 `ADMIN_PASSWORDS` 或者当前用户角色为 `ADMIN_USER_ROLE`,否则不允许访问管理后台。
## 管理口令和用户账号的区别
## 管理员密码和用户账号的区别
`ADMIN_PASSWORDS` Admin 控制台的管理口令,不是站点用户账号,也不对应某个邮箱地址。使用管理口令登录后可以进入后台,但它本身不能收信。
`ADMIN_PASSWORDS`管理后台的管理员密码,不是站点用户账号,也不对应某个邮箱地址。使用管理员密码登录后可以进入后台,但它本身不能收信。
站点用户账号存储在 `users` 表中,需要通过用户登录体系进入;用户是否能收信取决于是否创建或绑定了邮箱地址。即使你创建了一个邮箱为 `admin@example.com` 或用户名看起来像 `admin` 的普通用户,它也不会自动获得后台权限。
站点用户账号存储在 `users` 表中,需要通过用户登录体系进入;用户是否能收信取决于是否创建或绑定了邮箱。即使你创建了一个用户邮箱为 `admin@example.com` 或用户名看起来像 `admin` 的普通用户,它也不会自动获得后台权限。
如果希望某个用户也能进入 Admin 控制台,请配置 `ADMIN_USER_ROLE`,并在用户管理中给该用户设置相同的角色。
如果希望某个用户也能进入管理后台,请配置 `ADMIN_USER_ROLE`,并在用户管理中给该用户设置相同的角色。
![admin](/feature/admin.png)
## 账号列表排序
## 邮箱列表排序
管理后台的账号标签页支持按列排序,可点击表头对以下列进行升序/降序排列:
管理后台的「邮箱管理 → 邮箱列表」支持按列排序,可点击表头进行升序/降序排列:
- ID
- 名称
- 邮箱地址
- 创建时间
- 更新时间
- 邮件数量
@@ -37,7 +37,7 @@
## IP 黑名单 / 白名单
Admin 控制台 → **IP 黑名单设置** 页面可配置访问控制,作用于以下接口:创建邮箱地址、发送邮件、外部发送邮件 API、用户注册、验证码校验。
管理后台的 **IP 黑名单设置** 页面可配置访问控制,作用于以下接口:创建邮箱地址、发送邮件、外部发送邮件 API、用户注册、验证码校验。
### IP 白名单(严格模式)

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