Compare commits

..
Author SHA1 Message Date
dreamhunter2333 1572f4cd0f feat: upgrade version to v1.11.1 2026-08-22 16:18:52 +08:00
163 changed files with 2203 additions and 11057 deletions
+2 -20
View File
@@ -6,39 +6,21 @@
<a href="CHANGELOG_EN.md">English</a> <a href="CHANGELOG_EN.md">English</a>
</p> </p>
## v1.12.0(main) ## v1.11.1(main)
### Features ### Features
- feat: |Webhook| 新增首个邮件附件的 24 小时短签名路径 `${firstAttachmentPath}`,无需 S3 即可通过后端临时读取附件(issue #1142
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
- feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限 - feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126
- feat: |Admin API| 新增 `ADMIN_API_IP_WHITELIST`,可按来源 IP 限制所有管理接口访问
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
### Bug Fixes ### Bug Fixes
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload``data-onerror` 属性
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题 - fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
- fix: |用户发信| 用户地址发信接口支持角色无限额度
### Improvements ### Improvements
- fix: |Frontend| 统一邮箱、邮箱地址、用户账号与管理后台的核心界面术语,并补齐附加语言缺失的邮件状态与远程图片文案(issue #1129
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
### Testing ### Testing
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复 - test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
## v1.11.0 ## v1.11.0
@@ -244,7 +226,7 @@
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行 - test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护 - test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点 - test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
### Improvements ### Improvements
+2 -20
View File
@@ -6,39 +6,21 @@
<a href="CHANGELOG_EN.md">English</a> <a href="CHANGELOG_EN.md">English</a>
</p> </p>
## v1.12.0(main) ## v1.11.1(main)
### Features ### Features
- feat: |Webhook| Add the 24-hour signed `${firstAttachmentPath}` path for temporarily retrieving the first email attachment through the backend without S3 (issue #1142)
- 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 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 ### Bug Fixes
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish authentication errors to prompt for site and Admin login correctly; move E2E test endpoints out of production code
- 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: |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 ### 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 ### Testing
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads - 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 ## v1.11.0
@@ -244,7 +226,7 @@
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test` - test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization - test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
- test: |Worker| Add `/admin/test/seed_mail` test endpoint - test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
### Improvements ### Improvements
-1
View File
@@ -1 +0,0 @@
ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
-15
View File
@@ -1,15 +0,0 @@
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,7 +6,6 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT, raw TEXT,
raw_blob BLOB, raw_blob BLOB,
metadata TEXT, metadata TEXT,
is_unread INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -120,19 +119,3 @@ CREATE TABLE IF NOT EXISTS user_passkeys (
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id); 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 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 # Keep this version in sync with @playwright/test in package.json
FROM mcr.microsoft.com/playwright:v1.63.0-noble FROM mcr.microsoft.com/playwright:v1.62.1-noble
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
-1
View File
@@ -33,7 +33,6 @@ config.server = {\
"/admin": { target: workerTarget, changeOrigin: true },\ "/admin": { target: workerTarget, changeOrigin: true },\
"/user_api": { target: workerTarget, changeOrigin: true },\ "/user_api": { target: workerTarget, changeOrigin: true },\
"/open_api": { target: workerTarget, changeOrigin: true },\ "/open_api": { target: workerTarget, changeOrigin: true },\
"/redeem_api": { target: workerTarget, changeOrigin: true },\
"/external": { target: workerTarget, changeOrigin: true },\ "/external": { target: workerTarget, changeOrigin: true },\
"/health_check": { target: workerTarget, changeOrigin: true },\ "/health_check": { target: workerTarget, changeOrigin: true },\
},\ },\
+1 -3
View File
@@ -10,12 +10,10 @@ COPY worker/patches/ patches/
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install) RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
COPY worker/src/ src/ COPY worker/src/ src/
COPY e2e/fixtures/*.ts /app/e2e/fixtures/
RUN ln -s /app/worker/node_modules /app/e2e/node_modules
COPY worker/tsconfig.json ./ COPY worker/tsconfig.json ./
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
COPY ${WRANGLER_TOML} wrangler.toml COPY ${WRANGLER_TOML} wrangler.toml
EXPOSE 8787 EXPOSE 8787
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0", "--test-scheduled"] CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
+1 -2
View File
@@ -51,8 +51,7 @@ Test results and HTML reports are exported via volumes:
## Configuration ## Configuration
The E2E worker uses `fixtures/wrangler.toml.e2e` with: The E2E worker uses `fixtures/wrangler.toml.e2e` with:
- `E2E_TEST_MODE = true` — enables test seed endpoint
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls - `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval - `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
- SMTP pointed at Mailpit container (`mailpit:1025`) - SMTP pointed at Mailpit container (`mailpit:1025`)
Test-only endpoints under `/__test/*` are registered in `e2e/fixtures/worker.ts` and are excluded from the production Worker.
+1 -38
View File
@@ -42,7 +42,7 @@ services:
dockerfile: e2e/Dockerfile.worker dockerfile: e2e/Dockerfile.worker
ports: ports:
- "8790:8790" - "8790:8790"
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0", "--test-scheduled"] command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
volumes: volumes:
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro - ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
depends_on: depends_on:
@@ -90,24 +90,6 @@ services:
start_period: 10s start_period: 10s
retries: 20 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: frontend:
build: build:
context: .. context: ..
@@ -118,19 +100,6 @@ services:
worker: worker:
condition: service_healthy 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: smtp-proxy:
build: build:
context: ../smtp_proxy_server context: ../smtp_proxy_server
@@ -178,9 +147,7 @@ services:
WORKER_URL_ENV_OFF: http://worker-env-off:8790 WORKER_URL_ENV_OFF: http://worker-env-off:8790
WORKER_GZIP_URL: http://worker-gzip:8788 WORKER_GZIP_URL: http://worker-gzip:8788
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791 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: https://frontend:5173
FRONTEND_URL_ENV_OFF: https://frontend-env-off:5174
MAILPIT_API: http://mailpit:8025/api MAILPIT_API: http://mailpit:8025/api
SMTP_PROXY_HOST: smtp-proxy SMTP_PROXY_HOST: smtp-proxy
SMTP_PROXY_SMTP_PORT: "8025" SMTP_PROXY_SMTP_PORT: "8025"
@@ -200,12 +167,8 @@ services:
condition: service_healthy condition: service_healthy
worker-send-mail-domain: worker-send-mail-domain:
condition: service_healthy condition: service_healthy
worker-site-password:
condition: service_healthy
frontend: frontend:
condition: service_started condition: service_started
frontend-env-off:
condition: service_started
smtp-proxy: smtp-proxy:
condition: service_started condition: service_started
smtp-proxy-tls: smtp-proxy-tls:
-3
View File
@@ -1,3 +0,0 @@
export class EmailMessage {
constructor(public from: string, public to: string, public raw: string) {}
}
+11 -20
View File
@@ -7,9 +7,7 @@ export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || ''; export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || ''; export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || ''; export const 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 = 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 MAILPIT_API = process.env.MAILPIT_API!;
export const TEST_DOMAIN = 'test.example.com'; export const TEST_DOMAIN = 'test.example.com';
@@ -29,11 +27,10 @@ export function hashPassword(password: string): string {
export async function createTestAddress( export async function createTestAddress(
ctx: APIRequestContext, ctx: APIRequestContext,
name: string, name: string,
domain: string = TEST_DOMAIN, domain: string = TEST_DOMAIN
workerUrl: string = WORKER_URL,
): Promise<{ jwt: string; address: string; address_id: number }> { ): Promise<{ jwt: string; address: string; address_id: number }> {
const uniqueName = `${name}${Date.now()}`; const uniqueName = `${name}${Date.now()}`;
const res = await ctx.post(`${workerUrl}/api/new_address`, { const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
data: { name: uniqueName, domain }, data: { name: uniqueName, domain },
}); });
if (!res.ok()) { if (!res.ok()) {
@@ -78,7 +75,7 @@ export async function seedTestMail(
`--${boundary}--`, `--${boundary}--`,
].join('\r\n'); ].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, { const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw }, data: { from, to: address, raw },
}); });
if (!res.ok()) { if (!res.ok()) {
@@ -167,20 +164,14 @@ export function onMailpitMessage(
ws.on('open', () => readyResolve()); ws.on('open', () => readyResolve());
ws.on('message', (data: WebSocket.Data) => { ws.on('message', (data: WebSocket.Data) => {
// Mailpit may batch multiple newline-separated events in one message. try {
for (const line of data.toString().split('\n')) { const event = JSON.parse(data.toString());
if (settled) return; if (event.Type === 'new' && predicate(event.Data)) {
try { clearTimeout(timer);
const event = JSON.parse(line); ws.close();
if (event.Type === 'new' && predicate(event.Data)) { if (!settled) { settled = true; resolve(event.Data); }
clearTimeout(timer); }
settled = true; } catch { /* ignore parse errors */ }
ws.close();
resolve(event.Data);
return;
}
} catch { /* ignore malformed events without dropping the remaining ones */ }
}
}); });
ws.on('close', () => { ws.on('close', () => {
-19
View File
@@ -1,19 +0,0 @@
import { Hono } from 'hono';
import worker from '../../worker/src/worker';
import { CONSTANTS } from '../../worker/src/constants';
import mailApi from './mail-api';
const testApi = new Hono<HonoCustomType>();
testApi.post('/seed_mail', c => mailApi.seedMail(c.req.raw, c.env));
testApi.post('/receive_mail', c => mailApi.receiveMail(c.req.raw, c.env, c.executionCtx as ExecutionContext));
testApi.get('/telegram_binding', async c => {
const address = c.req.query('address');
if (!address) return c.text('address is required', 400);
return c.json(await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`));
});
const app = new Hono<HonoCustomType>();
app.route('/__test', testApi);
app.all('*', c => worker.fetch(c.req.raw, c.env, c.executionCtx));
export default { ...worker, fetch: app.fetch };
+2 -10
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email" name = "cloudflare_temp_email"
main = "../e2e/fixtures/worker.ts" main = "src/worker.ts"
compatibility_date = "2025-04-01" compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ] compatibility_flags = [ "nodejs_compat" ]
keep_vars = true keep_vars = true
@@ -15,21 +15,16 @@ USER_ROLES = [
{ domains = [], role = "empty-role", prefix = "EMPTY" }, { domains = [], role = "empty-role", prefix = "EMPTY" },
] ]
JWT_SECRET = "e2e-test-secret-key" JWT_SECRET = "e2e-test-secret-key"
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
BLACK_LIST = "" BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_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 ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10 DEFAULT_SEND_BALANCE = 10
NO_LIMIT_SEND_ROLE = "case-role"
ENABLE_ADDRESS_PASSWORD = true ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_USER_ROLE = "admin"
ADMIN_PASSWORDS = '["e2e-admin-pass"]' ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
CLEANUP_BATCH_SIZE = 10 CLEANUP_BATCH_SIZE = 10
SMTP_CONFIG = """ SMTP_CONFIG = """
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}} {"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
@@ -43,6 +38,3 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
binding = "DB" binding = "DB"
database_name = "e2e-temp-email" database_name = "e2e-temp-email"
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000" database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+2 -6
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_env_off" name = "cloudflare_temp_email_env_off"
main = "../e2e/fixtures/worker.ts" main = "src/worker.ts"
compatibility_date = "2025-04-01" compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ] compatibility_flags = [ "nodejs_compat" ]
keep_vars = true keep_vars = true
@@ -13,14 +13,13 @@ JWT_SECRET = "e2e-test-secret-key-env-off"
BLACK_LIST = "" BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = false ENABLE_USER_DELETE_EMAIL = false
DISABLE_ADDRESS_UPDATED_AT = true
ENABLE_REDEEM_CODE = false
ENABLE_AUTO_REPLY = true ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10 DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]' ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """ SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}} {"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
""" """
@@ -33,6 +32,3 @@ id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
binding = "DB" binding = "DB"
database_name = "e2e-temp-email-env-off" database_name = "e2e-temp-email-env-off"
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000" database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+2 -4
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_gzip" name = "cloudflare_temp_email_gzip"
main = "../e2e/fixtures/worker.ts" main = "src/worker.ts"
compatibility_date = "2025-04-01" compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ] compatibility_flags = [ "nodejs_compat" ]
keep_vars = true keep_vars = true
@@ -18,6 +18,7 @@ ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]' ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
ENABLE_MAIL_GZIP = true ENABLE_MAIL_GZIP = true
SMTP_CONFIG = """ SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}} {"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
@@ -31,6 +32,3 @@ id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
binding = "DB" binding = "DB"
database_name = "e2e-temp-email-gzip" database_name = "e2e-temp-email-gzip"
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000" database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email" name = "cloudflare_temp_email"
main = "../e2e/fixtures/worker.ts" main = "src/worker.ts"
compatibility_date = "2025-04-01" compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ] compatibility_flags = [ "nodejs_compat" ]
keep_vars = true keep_vars = true
@@ -23,6 +23,7 @@ ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]' ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """ SMTP_CONFIG = """
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}} {"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
""" """
@@ -35,6 +36,3 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
binding = "DB" binding = "DB"
database_name = "e2e-temp-email" database_name = "e2e-temp-email"
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000" database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
@@ -1,34 +0,0 @@
name = "cloudflare_temp_email_site_password"
main = "../e2e/fixtures/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"]'
[[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"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+60 -42
View File
@@ -6,13 +6,13 @@
"": { "": {
"name": "cloudflare-temp-email-e2e", "name": "cloudflare-temp-email-e2e",
"dependencies": { "dependencies": {
"imapflow": "^1.7.8", "imapflow": "^1.7.1",
"nodemailer": "^10.0.0" "nodemailer": "^9.0.5"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.63.0", "@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"hono": "^4.13.7",
"ws": "^8.21.3" "ws": "^8.21.3"
} }
}, },
@@ -23,13 +23,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.63.0", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.63.0" "playwright": "1.62.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -48,6 +48,16 @@
"undici-types": "~8.3.0" "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": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -59,13 +69,13 @@
} }
}, },
"node_modules/@zone-eu/mailsplit": { "node_modules/@zone-eu/mailsplit": {
"version": "5.4.16", "version": "5.4.15",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz", "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.15.tgz",
"integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==", "integrity": "sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==",
"license": "(MIT OR EUPL-1.1+)", "license": "(MIT OR EUPL-1.1+)",
"dependencies": { "dependencies": {
"libbase64": "1.3.0", "libbase64": "1.3.0",
"libmime": "5.4.3", "libmime": "5.4.2",
"libqp": "2.1.1" "libqp": "2.1.1"
} }
}, },
@@ -79,22 +89,27 @@
} }
}, },
"node_modules/encoding-japanese": { "node_modules/encoding-japanese": {
"version": "2.3.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz", "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==", "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=8.10.0"
} }
}, },
"node_modules/hono": { "node_modules/fsevents": {
"version": "4.13.7", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true, "dev": true,
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": { "engines": {
"node": ">=16.9.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
@@ -114,16 +129,16 @@
} }
}, },
"node_modules/imapflow": { "node_modules/imapflow": {
"version": "1.7.8", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz", "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.1.tgz",
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==", "integrity": "sha512-etpyRH7wOPQGawjl/5ChgaaX5NRK2fxJAi9EFFNwv0N3FXsvjsL3Qu5dJ4i/myH61Cq2RSTb6vI5T9rXDbXD+g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@zone-eu/mailsplit": "5.4.16", "@zone-eu/mailsplit": "5.4.15",
"encoding-japanese": "2.3.0", "encoding-japanese": "2.2.0",
"iconv-lite": "0.7.3", "iconv-lite": "0.7.3",
"libbase64": "1.3.0", "libbase64": "1.3.0",
"libmime": "5.4.3", "libmime": "5.4.2",
"libqp": "2.1.1", "libqp": "2.1.1",
"pino": "10.3.1", "pino": "10.3.1",
"socks": "2.8.9" "socks": "2.8.9"
@@ -145,12 +160,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/libmime": { "node_modules/libmime": {
"version": "5.4.3", "version": "5.4.2",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz", "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.2.tgz",
"integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==", "integrity": "sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"encoding-japanese": "2.3.0", "encoding-japanese": "2.2.0",
"iconv-lite": "0.7.3", "iconv-lite": "0.7.3",
"libbase64": "1.3.0", "libbase64": "1.3.0",
"libqp": "2.1.1" "libqp": "2.1.1"
@@ -163,12 +178,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "10.0.0", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==", "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/on-exit-leak-free": { "node_modules/on-exit-leak-free": {
@@ -218,25 +233,28 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.63.0", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.63.0" "playwright-core": "1.62.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.63.0", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
+4 -5
View File
@@ -3,18 +3,17 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
"test:down": "docker compose down -v" "test:down": "docker compose down -v"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.63.0", "@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"hono": "^4.13.7",
"ws": "^8.21.3" "ws": "^8.21.3"
}, },
"dependencies": { "dependencies": {
"imapflow": "^1.7.8", "imapflow": "^1.7.1",
"nodemailer": "^10.0.0" "nodemailer": "^9.0.5"
} }
} }
-38
View File
@@ -59,21 +59,6 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
done done
fi 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 ..." echo "==> Waiting for frontend at $FRONTEND_URL ..."
for i in $(seq 1 60); do for i in $(seq 1 60); do
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
@@ -87,21 +72,6 @@ for i in $(seq 1 60); do
sleep 1 sleep 1
done 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 ..." echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
for i in $(seq 1 30); do for i in $(seq 1 30); do
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
@@ -140,13 +110,5 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
echo " Gzip worker database initialized" echo " Gzip worker database initialized"
fi 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" echo "==> Running Playwright tests"
npm run test:unit
exec npx playwright test "$@" exec npx playwright test "$@"
+2 -2
View File
@@ -48,7 +48,7 @@ async function receiveGzipMail(
`--${boundary}--`, `--${boundary}--`,
].join('\r\n'); ].join('\r\n');
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/receive_mail`, { const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw }, data: { from, to: address, raw },
}); });
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`); if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
@@ -74,7 +74,7 @@ async function seedPlaintextMail(
opts.text || 'Hello plaintext from E2E', opts.text || 'Hello plaintext from E2E',
].join('\r\n'); ].join('\r\n');
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/seed_mail`, { const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
data: { address, source: from, raw, message_id: messageId }, data: { address, source: from, raw, message_id: messageId },
}); });
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`); if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
+1 -325
View File
@@ -1,8 +1,6 @@
import { test, expect, type APIRequestContext } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { import {
WORKER_URL, WORKER_URL,
WORKER_URL_ENV_OFF,
onMailpitMessage,
createTestAddress, createTestAddress,
deleteAddress, deleteAddress,
hashPassword, hashPassword,
@@ -79,325 +77,3 @@ 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, '/__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, '/__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, '/__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);
}
});
});
}
-124
View File
@@ -1,124 +0,0 @@
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`, {
headers: { 'x-lang': 'en' },
data: { token: jwt },
});
expect(send.status()).toBe(400);
expect(await send.text()).toBe('Failed to send mail Invalid address credential');
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);
}
});
+1 -1
View File
@@ -175,7 +175,7 @@ async function seedTestMailWithReply(
text, text,
].join('\r\n'); ].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, { const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw }, data: { from, to: address, raw },
}); });
if (!res.ok()) { if (!res.ok()) {
+3 -3
View File
@@ -13,7 +13,7 @@ test.describe('Bounded cleanup', () => {
test('cleans at most one batch and continues on the next run', async ({ request }) => { test('cleans at most one batch and continues on the next run', async ({ request }) => {
const address = `cleanup-batch-${Date.now()}@test.example.com`; const address = `cleanup-batch-${Date.now()}@test.example.com`;
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) => const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
request.post(`${WORKER_URL}/__test/seed_mail`, { request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: { data: {
address, address,
raw: 'old cleanup mail', raw: 'old cleanup mail',
@@ -40,7 +40,7 @@ test.describe('Bounded cleanup', () => {
const afterSecondCleanup = await listMails(request, address); const afterSecondCleanup = await listMails(request, address);
expect(afterSecondCleanup.count).toBe(0); expect(afterSecondCleanup.count).toBe(0);
const recentMailResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, { const recentMailResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: { data: {
address, address,
raw: 'recent cleanup mail', raw: 'recent cleanup mail',
@@ -63,7 +63,7 @@ test.describe('Bounded cleanup', () => {
test('deletes one address batch and its related data', async ({ request }) => { test('deletes one address batch and its related data', async ({ request }) => {
const oldAddress = await createTestAddress(request, 'cleanup-old'); const oldAddress = await createTestAddress(request, 'cleanup-old');
const seedResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, { const seedResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: { data: {
address: oldAddress.address, address: oldAddress.address,
raw: 'address cleanup mail', raw: 'address cleanup mail',
@@ -65,7 +65,7 @@ test.describe('Email forward domain normalization', () => {
`Forward domain normalization test`, `Forward domain normalization test`,
].join('\r\n'); ].join('\r\n');
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to, raw }, data: { from: 'sender@test.example.com', to, raw },
}); });
expect(res.ok()).toBe(true); expect(res.ok()).toBe(true);
@@ -118,7 +118,7 @@ test.describe('Email forward domain normalization', () => {
`Forward domain boundary test`, `Forward domain boundary test`,
].join('\r\n'); ].join('\r\n');
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to, raw }, data: { from: 'sender@test.example.com', to, raw },
}); });
expect(res.ok()).toBe(true); expect(res.ok()).toBe(true);
@@ -161,7 +161,7 @@ test.describe('Email forward domain normalization', () => {
`Forward catch-all domain test`, `Forward catch-all domain test`,
].join('\r\n'); ].join('\r\n');
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw }, data: { from: 'sender@test.example.com', to: address, raw },
}); });
expect(res.ok()).toBe(true); expect(res.ok()).toBe(true);
-3
View File
@@ -19,7 +19,6 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
} }
}); });
expect(res.status()).toBe(401); expect(res.status()).toBe(401);
expect(await res.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID', message: expect.any(String) });
}); });
}); });
@@ -44,7 +43,6 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
} }
}); });
expect(res.status()).toBe(401); expect(res.status()).toBe(401);
expect(await res.json()).toMatchObject({ code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: expect.any(String) });
}); });
test('empty password returns 401', async ({ request }) => { test('empty password returns 401', async ({ request }) => {
@@ -55,7 +53,6 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
} }
}); });
expect(res.status()).toBe(401); expect(res.status()).toBe(401);
expect(await res.json()).toMatchObject({ code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: expect.any(String) });
}); });
}); });
+1 -1
View File
@@ -70,7 +70,7 @@ test.describe('Mail Deletion', () => {
`--${boundary}--`, `--${boundary}--`,
].join('\r\n'); ].join('\r\n');
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/__test/receive_mail`, { const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
data: { from, to: address, raw }, data: { from, to: address, raw },
}); });
expect(seedRes.ok()).toBe(true); expect(seedRes.ok()).toBe(true);
-108
View File
@@ -1,108 +0,0 @@
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}/__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);
}
});
});
-312
View File
@@ -1,312 +0,0 @@
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);
expect(await blockedAdminResponse.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
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);
expect(await missingPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
const wrongPassword = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
{ headers: { 'x-custom-auth': 'wrong' }, data: { code } },
);
expect(wrongPassword.status()).toBe(401);
expect(await wrongPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
}
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.json()).toMatchObject({
code: credentials.name === 'expired Admin role token'
? 'AUTH_USER_ACCESS_TOKEN_EXPIRED' : 'AUTH_ADMIN_CREDENTIAL_INVALID',
});
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
@@ -1,336 +0,0 @@
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
@@ -1,628 +0,0 @@
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,33 +51,4 @@ test.describe('Send Mail via SMTP', () => {
// Cleanup // Cleanup
await deleteAddress(request, jwt); 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);
}
});
}); });
+1 -1
View File
@@ -18,7 +18,7 @@ test.describe('Telegram AI extraction rendering', () => {
'Telegram AI extraction realtime body', 'Telegram AI extraction realtime body',
].join('\r\n'); ].join('\r\n');
const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, { const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { data: {
from: 'sender@test.example.com', from: 'sender@test.example.com',
to: address, to: address,
-147
View File
@@ -1,147 +0,0 @@
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();
}
async function expectPushOwner(request: APIRequestContext, address: string, userId: number | null) {
const response = await request.get(`${WORKER_URL}/__test/telegram_binding`, {
params: { address },
});
expect(response.ok(), await response.text()).toBe(true);
expect(await response.json()).toBe(userId === null ? null : String(userId));
}
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 expectPushOwner(request, mailbox.address, owner);
await unbind(request, owner, mailbox.address);
await expectPushOwner(request, mailbox.address, null);
await bind(request, owner, mailbox.jwt);
await bind(request, other, mailbox.jwt);
await expectPushOwner(request, mailbox.address, other);
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 expectPushOwner(request, mailbox.address, other);
await unbind(request, other, mailbox.address);
expect(await addressList(request, other)).toEqual([]);
await expectPushOwner(request, mailbox.address, null);
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 expectPushOwner(request, mailbox.address, owner);
await unbind(request, other, mailbox.address);
await expectPushOwner(request, mailbox.address, owner);
await unbind(request, owner, mailbox.address);
expect(await addressList(request, owner)).toEqual([]);
await expectPushOwner(request, mailbox.address, null);
} 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);
await expectPushOwner(request, recreated.address, owner);
const response = await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${recreated.jwt}` },
});
expect(response.ok(), await response.text()).toBe(true);
await expectPushOwner(request, recreated.address, null);
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}`);
}
});
test('Telegram unbind accepts a current credential after a stale credential for the same address', async ({ request }) => {
const original = await createTestAddress(request, 'tg-recreated');
const unrelated = await createTestAddress(request, 'tg-retained');
const owner = Date.now();
await bind(request, owner, original.jwt);
await bind(request, owner, unrelated.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 bind(request, owner, recreated.jwt);
await unbind(request, owner, recreated.address);
await expectPushOwner(request, recreated.address, null);
expect(await addressList(request, owner)).toEqual([{ address: unrelated.address, jwt: unrelated.jwt }]);
await expectPushOwner(request, unrelated.address, owner);
} finally {
for (const mailbox of [recreated, unrelated]) {
await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` },
});
}
}
});
-65
View File
@@ -1,65 +0,0 @@
import { test, expect } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { WORKER_URL, WORKER_URL_SITE_PASSWORD } from '../../fixtures/test-helpers';
for (const scenario of ['expired role', 'valid role', 'invalid signature', 'missing expiry', 'admin password', 'wrong password'] as const) {
test(`Admin role token errors remain distinct from password errors: ${scenario}`, async ({ request }) => {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({
user_id: 1, user_role: 'admin',
exp: scenario === 'missing expiry' ? undefined
: Math.floor(Date.now() / 1000) + (scenario === 'valid role' || scenario === 'invalid signature' ? 3600 : -60),
})).toString('base64url');
const signature = createHmac('sha256', scenario === 'invalid signature' ? 'wrong-secret' : 'e2e-site-password-secret')
.update(`${header}.${payload}`).digest('base64url');
const headers: Record<string, string> = { 'x-custom-auth': 'e2e-site-pass', 'x-lang': 'en' };
if (scenario !== 'wrong password') headers['x-user-access-token'] = `${header}.${payload}.${signature}`;
if (scenario === 'admin password') headers['x-admin-auth'] = 'e2e-admin-pass';
if (scenario === 'wrong password') headers['x-admin-auth'] = 'wrong-password';
const response = await request.get(`${WORKER_URL_SITE_PASSWORD}/admin/db_version`, { headers });
if (scenario === 'expired role') {
expect(response.status()).toBe(401);
expect(await response.json()).toEqual({
code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
message: 'Your access token has expired, please refresh the page',
});
} else if (scenario === 'missing expiry') {
expect(response.status()).toBe(401);
expect(await response.json()).toEqual({
code: 'AUTH_ADMIN_CREDENTIAL_INVALID',
message: 'Your access token has expired, please refresh the page',
});
} else if (scenario === 'valid role' || scenario === 'admin password') {
expect(response.ok()).toBe(true);
} else {
expect(response.status()).toBe(401);
expect(await response.json()).toEqual({
code: 'AUTH_ADMIN_CREDENTIAL_INVALID',
message: 'You need to provide the admin password to access this page',
});
}
});
}
test('generic authentication errors preserve their text messages and CORS headers', async ({ request }) => {
for (const { path, message } of [
{ path: '/api/settings', message: 'Invalid address credential' },
{ path: '/user_api/settings', message: 'Your token has expired, please login again' },
]) {
const response = await request.get(`${WORKER_URL}${path}`, { headers: { 'x-lang': 'en' } });
expect(response.status()).toBe(401);
expect(response.headers()['content-type']).toContain('text/plain');
expect(response.headers()['access-control-allow-origin']).toBe('*');
expect(await response.text()).toBe(message);
}
});
test('uncaught server errors return JSON with the original error detail', async ({ request }) => {
const response = await request.post(`${WORKER_URL}/open_api/admin_login`, {
headers: { 'content-type': 'application/json' },
data: Buffer.from('{invalid-json'),
});
expect(response.status()).toBe(500);
expect(response.headers()['content-type']).toContain('application/json');
expect(await response.json()).toEqual({ code: 'INTERNAL_SERVER_ERROR', message: expect.stringContaining('SyntaxError') });
});
-415
View File
@@ -1,415 +0,0 @@
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,
});
}
}
});
});
+2 -69
View File
@@ -95,7 +95,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
`Webhook trigger test body`, `Webhook trigger test body`,
].join('\r\n'); ].join('\r\n');
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { data: {
from, from,
to: address, to: address,
@@ -126,73 +126,6 @@ test.describe('Webhook — triggered on incoming mail', () => {
} }
}); });
test('signed first attachment path serves only the matching attachment', async ({ request }) => {
const { server, firstRequest, url } = await startWebhookReceiver();
try {
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
enabled: true,
url,
method: 'POST',
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ firstAttachmentPath: '${firstAttachmentPath}' }),
},
});
expect(saveRes.ok()).toBe(true);
const attachment = Buffer.from('89504e470d0a1a0a', 'hex');
const boundary = `webhook-attachment-${Date.now()}`;
const raw = [
`From: attachment-sender@test.example.com`,
`To: ${address}`,
`Subject: Webhook Attachment ${Date.now()}`,
`Message-ID: <webhook-attachment-${Date.now()}@test>`,
`MIME-Version: 1.0`,
`Content-Type: multipart/mixed; boundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
``,
`Attachment test`,
`--${boundary}`,
`Content-Type: image/png`,
`Content-Disposition: attachment; filename="test.png"`,
`Content-Transfer-Encoding: base64`,
``,
attachment.toString('base64'),
`--${boundary}--`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: {
from: 'attachment-sender@test.example.com',
to: address,
raw,
},
});
expect(res.ok()).toBe(true);
const payload = JSON.parse((await firstRequest).body);
expect(payload.firstAttachmentPath).toMatch(/^\/open_api\/a\/\d+\/\d+\/[A-Za-z0-9_-]{43}$/);
const attachmentRes = await request.get(`${WORKER_URL}${payload.firstAttachmentPath}`);
expect(attachmentRes.ok()).toBe(true);
expect(attachmentRes.headers()['content-type']).toBe('image/png');
expect(attachmentRes.headers()['content-disposition']).toBe('inline');
expect(attachmentRes.headers()['x-content-type-options']).toBe('nosniff');
expect(Buffer.from(await attachmentRes.body())).toEqual(attachment);
const lastCharacter = payload.firstAttachmentPath.slice(-1);
const tamperedPath = `${payload.firstAttachmentPath.slice(0, -1)}${lastCharacter === 'A' ? 'B' : 'A'}`;
const tamperedRes = await request.get(`${WORKER_URL}${tamperedPath}`);
expect(tamperedRes.status()).toBe(404);
} finally {
server.close();
}
});
test('webhook is NOT called when disabled', async ({ request }) => { test('webhook is NOT called when disabled', async ({ request }) => {
const { server, firstRequest, url } = await startWebhookReceiver(); const { server, firstRequest, url } = await startWebhookReceiver();
@@ -224,7 +157,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
`Should not trigger webhook`, `Should not trigger webhook`,
].join('\r\n'); ].join('\r\n');
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw }, data: { from: 'sender@test.example.com', to: address, raw },
}); });
expect(res.ok()).toBe(true); expect(res.ok()).toBe(true);
@@ -1,56 +0,0 @@
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);
});
+2 -2
View File
@@ -6,7 +6,7 @@ test('create an address with a custom subdomain from the UI', async ({ page, req
try { try {
await page.goto(`${FRONTEND_URL}/en/`); await page.goto(`${FRONTEND_URL}/en/`);
await page.getByRole('button', { name: 'Create Email Address' }).click(); await page.getByRole('button', { name: 'Create New Email' }).click();
const name = `subui${Date.now()}`; const name = `subui${Date.now()}`;
const createForm = page.locator('.n-tab-pane:visible form'); const createForm = page.locator('.n-tab-pane:visible form');
@@ -36,7 +36,7 @@ test('create an address with a custom subdomain from the UI', async ({ page, req
await createForm.getByText('Use Custom Subdomain', { exact: true }).click(); await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team'); await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
await createForm.getByRole('button', { name: 'Create Email Address' }).click(); await createForm.getByRole('button', { name: 'Create New Email' }).click();
const domain = `team.${TEST_DOMAIN}`; const domain = `team.${TEST_DOMAIN}`;
const address = `tmp${name}@${domain}`; const address = `tmp${name}@${domain}`;
-79
View File
@@ -1,79 +0,0 @@
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();
}
}
});
-387
View File
@@ -1,387 +0,0 @@
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
@@ -1,134 +0,0 @@
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);
});
});
-417
View File
@@ -1,417 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { FRONTEND_URL, WORKER_URL, createTestAddress, deleteAddress, hashPassword, getAddressSender, onMailpitMessage } from '../../fixtures/test-helpers';
const accessToken = (expiresIn: number) => {
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expiresIn })).toString('base64url');
return `e30.${payload}.signature`;
};
const openApiTestPage = async (page: Page) => {
// Isolate API calls from the application's automatic settings requests.
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
}));
await page.goto(`${FRONTEND_URL}/api-test`);
};
const expiredTokenResponse = { status: 401, json: { code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', message: 'Access token expired' } };
for (const scenario of [
{ name: 'mailbox credential', path: '/api/settings', site: false, admin: false, needAuth: true },
{ name: 'account credential', path: '/user_api/settings', site: false, admin: false, needAuth: true },
{ name: 'site password', path: '/api/settings', site: true, admin: false, needAuth: true },
{ name: 'site password on admin API', path: '/admin/db_version', site: true, admin: false, needAuth: true },
{ name: 'admin password', path: '/admin/db_version', site: false, admin: true, needAuth: true },
{ name: 'admin login', path: '/open_api/admin_login', site: false, admin: true, needAuth: false },
{ name: 'site password after authentication', path: '/api/settings', site: true, admin: false, needAuth: false },
{ name: 'site password on admin API after authentication', path: '/admin/db_version', site: true, admin: false, needAuth: false },
]) {
test(`Authentication dialog distinguishes ${scenario.name}`, async ({ page }) => {
await openApiTestPage(page);
await page.route(`**${scenario.path}`, route => route.fulfill(scenario.site
? { status: 401, json: { code: 'AUTH_SITE_PASSWORD_INVALID', message: 'Site password required' } }
: scenario.admin
? { status: 401, json: { code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: 'Admin password required' } }
: { status: 401, body: 'Invalid credential' }));
const result = await page.evaluate(async ({ path, needAuth }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.openSettings.value.needAuth = needAuth;
state.showAuth.value = false;
state.showAdminAuth.value = false;
try {
await api.fetch(path);
} catch {
return { site: state.showAuth.value, admin: state.showAdminAuth.value };
}
}, { path: scenario.path, needAuth: scenario.needAuth });
expect(result).toEqual({ site: scenario.site, admin: scenario.admin });
});
}
for (const scenario of ['expired', 'expiring', 'valid', 'no account', 'login expired', 'wrong password', 'text zh', 'text en', 'retry expired', 'retry unauthorized', 'unmatched path', 'server error', 'json client error', 'json server error'] as const) {
test(`Access token response handling: ${scenario}`, async ({ page }) => {
await openApiTestPage(page);
const initialToken = accessToken(scenario === 'valid' ? 3600 : scenario === 'expiring' ? 20 : -60);
const freshToken = accessToken(7200);
const path = scenario === 'unmatched path' ? '/open_api/settings' : '/admin/db_version';
let refreshCount = 0;
const attempts: string[] = [];
await page.route('**/user_api/settings', async (route) => {
refreshCount++;
expect(route.request().headers()['x-user-token']).toBe('account-token');
await route.fulfill(scenario === 'login expired'
? { status: 401, contentType: 'text/plain', body: 'Please login again' }
: { json: { access_token: freshToken } });
});
await page.route(`**${path}`, async (route) => {
attempts.push(route.request().headers()['x-user-access-token']);
if (scenario.startsWith('json')) {
await route.fulfill({
status: scenario === 'json client error' ? 403 : 503,
json: { code: 'OPERATION_FAILED', message: 'Operation failed' },
});
return;
}
if (scenario === 'server error') {
await route.fulfill({ status: 500, body: 'Server error' });
return;
}
if (!['valid', 'expiring', 'wrong password'].includes(scenario)
&& (attempts.length === 1 || scenario === 'retry expired')) {
await route.fulfill(scenario.startsWith('text')
? { status: 401, body: scenario === 'text zh' ? '您的访问令牌已过期, 请刷新页面' : 'Your access token has expired, please refresh the page' }
: expiredTokenResponse);
return;
}
await route.fulfill(['wrong password', 'retry unauthorized'].includes(scenario)
? { status: 401, json: { code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: 'Admin password required' } }
: { json: { current_db_version: 'test-version' } });
});
const result = await page.evaluate(async ({ initialToken, scenario, path }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = scenario === 'no account' ? '' : 'account-token';
state.userSettings.value.access_token = initialToken;
state.adminAuth.value = scenario === 'wrong password' ? 'wrong-password' : '';
state.showAdminAuth.value = false;
try {
return { data: await api.fetch(path), error: null, showAdminAuth: state.showAdminAuth.value };
} catch (error) {
return { data: null, error: String(error), showAdminAuth: state.showAdminAuth.value };
}
}, { initialToken, scenario, path });
const needsRefresh = !['valid', 'expiring', 'no account', 'wrong password', 'text zh', 'text en', 'unmatched path', 'server error', 'json client error', 'json server error'].includes(scenario);
expect(refreshCount).toBe(needsRefresh ? 1 : 0);
expect(attempts).toEqual(needsRefresh && scenario !== 'login expired' ? [initialToken, freshToken] : [initialToken]);
expect(result.showAdminAuth).toBe(['wrong password', 'retry unauthorized'].includes(scenario));
if (scenario === 'login expired') expect(result.error).toContain('Please login again');
else if (['wrong password', 'retry unauthorized'].includes(scenario)) expect(result.error).toContain('Admin password required');
else if (['retry expired', 'no account', 'unmatched path'].includes(scenario)) expect(result.error).toContain('Access token expired');
else if (scenario === 'server error') expect(result.error).toContain('Server error');
else if (scenario.startsWith('text')) {
expect(result.error).toContain(scenario === 'text zh'
? '您的访问令牌已过期, 请刷新页面'
: 'Your access token has expired, please refresh the page');
}
else if (scenario.startsWith('json')) {
expect(result.error).toContain('Operation failed');
expect(result.error).not.toContain('[object Object]');
}
else expect(result.data).toEqual({ current_db_version: 'test-version' });
});
}
test('concurrent and late responses share one access token refresh', async ({ page }) => {
await openApiTestPage(page);
const initialToken = accessToken(-60);
const freshToken = accessToken(7200);
const paths = ['/admin/db_version', '/api/settings', '/api/send_mail',
'/user_api/bind_address', '/user_api/address/1/settings'];
let refreshCount = 0;
const initialPaths: string[] = [];
const attempts: string[] = [];
let release!: () => void;
let releaseLate!: () => void;
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
const lateResponse = new Promise<void>((resolve) => { releaseLate = resolve; });
await page.route('**/user_api/settings', async (route) => {
refreshCount++;
await refreshPending;
await route.fulfill({ json: { access_token: freshToken } });
});
await page.route((url) => paths.includes(url.pathname), async (route) => {
if (route.request().headers()['x-user-access-token'] === initialToken) {
const requestPath = new URL(route.request().url()).pathname;
initialPaths.push(requestPath);
if (requestPath === paths.at(-1)) await lateResponse;
await route.fulfill(expiredTokenResponse);
return;
}
attempts.push(route.request().headers()['x-user-access-token']);
await route.fulfill({ json: { success: true } });
});
const pending = page.evaluate(async ({ initialToken, paths }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = 'account-token';
state.userSettings.value.access_token = initialToken;
return Promise.all(paths.map((path) => api.fetch(path, {
method: path === '/api/send_mail' ? 'POST' : 'GET',
})));
}, { initialToken, paths });
try {
await expect.poll(() => refreshCount).toBe(1);
await expect.poll(() => initialPaths.length).toBe(paths.length);
expect(attempts).toEqual([]);
release();
await expect.poll(() => attempts.length).toBe(paths.length - 1);
} finally {
release();
releaseLate();
}
expect(await pending).toEqual(paths.map(() => ({ success: true })));
expect(refreshCount).toBe(1);
expect(attempts).toEqual(paths.map(() => freshToken));
});
for (const failure of [400, 401, 500, 'network'] as const) {
test(`failed shared refresh preserves independent mailbox requests: ${failure}`, async ({ page }) => {
await openApiTestPage(page);
const paths = ['/admin/db_version', '/user_api/bind_address', '/user_api/address/1/settings',
'/api/settings', '/api/send_mail'];
const attempts: string[] = [];
let initialResponses = 0;
page.on('response', async (response) => {
if (paths.includes(new URL(response.url()).pathname) && response.status() === 401) {
await response.finished();
initialResponses++;
}
});
let refreshCount = 0;
let release!: () => void;
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
const freshToken = accessToken(7200);
await page.route('**/user_api/settings', async (route) => {
refreshCount++;
if (refreshCount > 1) {
await route.fulfill({ json: { access_token: freshToken } });
return;
}
await refreshPending;
if (failure === 'network') await route.abort('failed');
else await route.fulfill({ status: failure, body: 'Refresh failed' });
});
await page.route((url) => paths.includes(url.pathname), async (route) => {
const path = new URL(route.request().url()).pathname;
if (route.request().headers()['x-user-access-token'] && route.request().headers()['x-user-access-token'] !== freshToken) {
await route.fulfill(expiredTokenResponse);
return;
}
attempts.push(path);
await route.fulfill(path === '/api/send_mail'
? { status: 403, body: 'No send balance' }
: { json: { success: true } });
});
const pending = page.evaluate(async ({ paths, initialToken }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = 'account-token';
state.userSettings.value.access_token = initialToken;
state.adminAuth.value = '';
state.openSettings.value.needAuth = true;
state.showAuth.value = false;
state.showAdminAuth.value = false;
const results = await Promise.all(paths.map(async (path) => {
try {
return { data: await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' }) };
} catch (error) {
return { error: String(error) };
}
}));
const flags = { showAuth: state.showAuth.value, showAdminAuth: state.showAdminAuth.value, loading: state.loading.value };
await api.fetch('/admin/db_version');
return { results, flags };
}, { paths, initialToken: accessToken(-60) });
try {
await expect.poll(() => initialResponses).toBe(paths.length);
await expect.poll(() => refreshCount).toBe(1);
expect(attempts).toEqual([]);
} finally {
release();
}
const { results, flags } = await pending;
for (const result of results.slice(0, 3)) {
expect(result.error).toContain(failure === 'network' ? 'Network Error' : 'Refresh failed');
}
expect(results[3].data).toEqual({ success: true });
expect(results[4].error).toContain('[403]: No send balance');
expect(flags).toEqual({ showAuth: false, showAdminAuth: false, loading: false });
expect(refreshCount).toBe(2);
expect(attempts.slice(0, 2).sort()).toEqual(['/api/send_mail', '/api/settings']);
expect(attempts.slice(2)).toEqual(['/admin/db_version']);
});
}
for (const changedCredential of ['account', 'mailbox'] as const) {
test(`a changed ${changedCredential} stops the waiting request after refresh`, async ({ page }) => {
await openApiTestPage(page);
const path = changedCredential === 'mailbox' ? '/api/send_mail' : '/admin/db_version';
const freshToken = accessToken(7200);
let attempts = 0;
await page.route(`**${path}`, async (route) => {
attempts++;
await route.fulfill(attempts === 1 ? expiredTokenResponse : { json: { success: true } });
});
await page.route('**/user_api/settings', async (route) => {
await page.evaluate(async (changedCredential) => {
const storeModule = '/src/store/index.js';
const state = (await import(storeModule)).useGlobalState();
if (changedCredential === 'account') {
state.userJwt.value = 'other-account';
state.userSettings.value = { access_token: 'other-access-token' };
} else {
state.jwt.value = 'other-mailbox';
}
}, changedCredential);
await route.fulfill({ json: { access_token: freshToken } });
});
const result = await page.evaluate(async ({ initialToken, path }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = 'account-token';
state.jwt.value = 'mailbox-token';
state.userSettings.value.access_token = initialToken;
state.adminAuth.value = '';
try {
await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' });
return null;
} catch (error) {
return { error: String(error), token: state.userSettings.value.access_token, loading: state.loading.value };
}
}, { initialToken: accessToken(-60), path });
expect(result).toEqual({
error: 'Error: User session changed, please retry',
token: changedCredential === 'account' ? 'other-access-token' : freshToken,
loading: false,
});
expect(attempts).toBe(1);
});
}
for (const [scenario, query] of [
['expired account', ''],
['deleted account', ''],
['valid account', ''],
['missing account', ''],
['missing expiry', ''],
['expired account', '?refresh=1&source=e2e'],
['deleted account', '?refresh=1&source=e2e'],
['valid account', '?refresh=1&source=e2e'],
['missing account', '?refresh=1&source=e2e'],
['missing expiry', '?refresh=1&source=e2e'],
] as const) {
test(`mailbox operations use the real Worker when ${scenario}, query: ${query || 'none'}`, async ({ page, request }) => {
const address = await createTestAddress(request, 'refresh-mailbox-');
let userId: number | undefined;
try {
const email = `refresh-account-${Date.now()}@test.example.com`;
const created = await request.post(`${WORKER_URL}/admin/users`, {
data: { email, password: hashPassword('test-password-123') },
});
expect(created.ok()).toBe(true);
const users = await request.get(`${WORKER_URL}/admin/users`, {
params: { limit: 10, offset: 0, query: email },
});
expect(users.ok()).toBe(true);
userId = (await users.json()).results.find((user: any) => user.user_email === email).id;
const sign = (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}`;
};
const now = Math.floor(Date.now() / 1000);
const claims = { user_id: userId, user_email: email, iat: now - 3600 };
const userJwt = scenario === 'missing account' ? ''
: sign({ ...claims, exp: now + (scenario === 'expired account' ? -60 : 3600) });
const expiredAccessToken = sign({ ...claims, exp: scenario === 'missing expiry' ? undefined : now - 60, user_role: 'case-role' });
if (scenario === 'deleted account') {
expect((await request.delete(`${WORKER_URL}/admin/users/${userId}`)).ok()).toBe(true);
}
// Exercise the browser API module without the UI's automatic settings requests.
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
}));
await page.goto(`${FRONTEND_URL}/api-test`);
const refreshStatuses: number[] = [];
let sendCount = 0;
page.on('response', (response) => {
if (new URL(response.url()).pathname === '/user_api/settings') refreshStatuses.push(response.status());
});
page.on('request', (req) => {
const url = new URL(req.url());
if (['/api/settings', '/api/send_mail'].includes(url.pathname)) expect(url.search).toBe(query);
if (url.pathname === '/api/send_mail') sendCount++;
});
const settings = await page.evaluate(async ({ userJwt, expiredAccessToken, mailboxJwt, query }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = userJwt;
state.userSettings.value.access_token = expiredAccessToken;
state.jwt.value = mailboxJwt;
state.adminAuth.value = '';
return api.fetch(`/api/settings${query}`);
}, { userJwt, expiredAccessToken, mailboxJwt: address.jwt, query });
expect(settings.address).toBe(address.address);
expect(settings.send_balance).toBe(10);
const subject = `Mailbox refresh regression ${scenario} ${Date.now()}`;
const listener = onMailpitMessage((mail) => mail.Subject === subject);
await listener.ready;
const [mail] = await Promise.all([
listener.message,
page.evaluate(async ({ subject, query }) => {
const apiModule = '/src/api/index.js';
const { api } = await import(apiModule);
await api.fetch(`/api/send_mail${query}`, {
method: 'POST',
body: { to_mail: 'recipient@test.example.com', subject, content: 'Independent mailbox credential', is_html: false },
});
}, { subject, query }),
]);
expect(mail.From.Address).toBe(address.address);
expect(sendCount).toBe(scenario === 'valid account' || scenario === 'missing expiry' ? 1 : 2);
expect((await getAddressSender(request, address.address)).balance).toBe(9);
const sent = await request.get(`${WORKER_URL}/admin/sendbox`, {
params: { address: address.address, limit: 10, offset: 0 },
});
expect(sent.ok()).toBe(true);
expect((await sent.json()).results).toHaveLength(1);
expect(refreshStatuses).toEqual(scenario === 'valid account' ? [200]
: scenario === 'missing account' || scenario === 'missing expiry' ? []
: scenario === 'expired account' ? [401, 401] : [400, 400]);
} finally {
await deleteAddress(request, address.jwt);
if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`);
}
});
}
@@ -100,7 +100,7 @@ test.describe('User address pagination browser flow', () => {
return url.pathname === '/user_api/bind_address' return url.pathname === '/user_api/bind_address'
&& url.searchParams.get('limit') === '100'; && url.searchParams.get('limit') === '100';
}); });
await page.getByText('Inbox', { exact: true }).click(); await page.getByText('Mail Box', { exact: true }).click();
const initialMailboxResponse = await initialMailboxAddressesResponse; const initialMailboxResponse = await initialMailboxAddressesResponse;
expect(initialMailboxResponse.ok()).toBe(true); expect(initialMailboxResponse.ok()).toBe(true);
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first(); const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
-137
View File
@@ -1,137 +0,0 @@
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
@@ -1,49 +0,0 @@
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,4 +1,3 @@
VITE_API_BASE=https://temp-email-api.xxx.xxx VITE_API_BASE=https://temp-email-api.xxx.xxx
VITE_DEFAULT_LANG=zh
VITE_CF_WEB_ANALY_TOKEN= VITE_CF_WEB_ANALY_TOKEN=
VITE_IS_TELEGRAM=false VITE_IS_TELEGRAM=false
-1
View File
@@ -14,7 +14,6 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/logo.png" sizes="any"> <link rel="icon" href="/logo.png" sizes="any">
<link rel="apple-touch-icon" href="/logo.png"> <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> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
</head> </head>
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.12.0", "version": "1.11.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -28,16 +28,16 @@
"@vueuse/core": "^14.4.0", "@vueuse/core": "^14.4.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.20.0", "axios": "^1.19.0",
"dompurify": "^3.4.15", "dompurify": "^3.4.13",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2", "mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.45.3", "naive-ui": "^2.45.0",
"postal-mime": "^2.7.6", "postal-mime": "^2.7.6",
"vooks": "^0.2.12", "vooks": "^0.2.12",
"vue": "^3.5.42", "vue": "^3.5.41",
"vue-clipboard3": "^2.0.0", "vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.4.10", "vue-i18n": "^11.4.8",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
@@ -53,7 +53,7 @@
"vitest": "^4.1.11", "vitest": "^4.1.11",
"workbox-build": "^7.4.1", "workbox-build": "^7.4.1",
"workbox-window": "^7.4.1", "workbox-window": "^7.4.1",
"wrangler": "^4.129.0" "wrangler": "^4.124.0"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+420 -426
View File
File diff suppressed because it is too large Load Diff
+10 -12
View File
@@ -3,7 +3,7 @@ import {
darkTheme, darkTheme,
} from 'naive-ui' } from 'naive-ui'
import { computed, onMounted, watchEffect } from 'vue' import { computed, onMounted, watchEffect } from 'vue'
import { useHead } from '@unhead/vue' import { useScript } from '@unhead/vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store' import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables' import { useIsMobile } from './utils/composables'
@@ -12,13 +12,12 @@ import Footer from './views/Footer.vue';
import { api } from './api' import { api } from './api'
import { getNaiveLocaleConfig } from './i18n/naive-locale' import { getNaiveLocaleConfig } from './i18n/naive-locale'
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils' import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
import { APP_CONFIG } from './config'
const { const {
isDark, loading, useSideMargin, telegramApp, isTelegram isDark, loading, useSideMargin, telegramApp, isTelegram
} = useGlobalState() } = useGlobalState()
const adClient = APP_CONFIG.GOOGLE_AD_CLIENT; const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
const adSlot = APP_CONFIG.GOOGLE_AD_SLOT; const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
const { locale } = useI18n({ useScope: 'global' }); const { locale } = useI18n({ useScope: 'global' });
const theme = computed(() => isDark.value ? darkTheme : null) const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE)) const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
@@ -32,13 +31,12 @@ watchEffect(() => {
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
}) })
// Load Google Ad script at top level (not inside onMounted)
if (showAd.value) { if (showAd.value) {
useHead({ useScript({
script: [{ src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`, async: true,
async: true, crossorigin: "anonymous",
crossorigin: 'anonymous',
}],
}) })
} }
@@ -49,7 +47,7 @@ onMounted(async () => {
console.error(error); console.error(error);
} }
const token = APP_CONFIG.CF_WEB_ANALY_TOKEN; const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
if (token && !exist) { if (token && !exist) {
@@ -68,7 +66,7 @@ onMounted(async () => {
// check if telegram is enabled // check if telegram is enabled
const enableTelegram = APP_CONFIG.IS_TELEGRAM; const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
if ( if (
(typeof enableTelegram === 'boolean' && enableTelegram === true) (typeof enableTelegram === 'boolean' && enableTelegram === true)
|| ||
-71
View File
@@ -1,71 +0,0 @@
// @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')
})
})
-15
View File
@@ -1,15 +0,0 @@
export const ErrorCode = {
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
AUTH_SITE_PASSWORD_INVALID: 'AUTH_SITE_PASSWORD_INVALID',
AUTH_ADMIN_CREDENTIAL_INVALID: 'AUTH_ADMIN_CREDENTIAL_INVALID',
AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
isSiteAuthError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_SITE_PASSWORD_INVALID,
isUserAccessTokenError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED,
isAdminAuthError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_ADMIN_CREDENTIAL_INVALID,
};
+8 -25
View File
@@ -6,11 +6,8 @@ import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint' import { getFingerprint } from '../utils/fingerprint'
import { safeBearerHeader, safeHeaderValue } from '../utils/headers' import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
import { sanitizeHtml } from '../utils/sanitize-html' import { sanitizeHtml } from '../utils/sanitize-html'
import { APP_CONFIG } from '../config'
import { createUserAccessTokenInterceptor } from './user-access-token-interceptor'
import { ErrorCode } from './error-codes'
const API_BASE = APP_CONFIG.API_BASE || ""; const API_BASE = import.meta.env.VITE_API_BASE || "";
const { const {
loading, auth, jwt, settings, openSettings, loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings, announcement, userOpenSettings, userSettings, announcement,
@@ -23,18 +20,8 @@ const instance = axios.create({
validateStatus: (status) => status >= 200 && status <= 500 validateStatus: (status) => status >= 200 && status <= 500
}); });
const responseInterceptors = [createUserAccessTokenInterceptor(instance)];
const interceptResponse = async (path, response) => {
for (const { matches, handle } of responseInterceptors) {
if (matches(path, response)) return await handle(response);
}
return response;
};
const apiFetch = async (path, options = {}) => { const apiFetch = async (path, options = {}) => {
const showLoading = options.showLoading !== false; loading.value = true;
if (showLoading) loading.value = true;
try { try {
// Get browser fingerprint for request tracking // Get browser fingerprint for request tracking
const fingerprint = await getFingerprint(); const fingerprint = await getFingerprint();
@@ -58,30 +45,29 @@ const apiFetch = async (path, options = {}) => {
const authorizationHeader = safeBearerHeader(jwt.value); const authorizationHeader = safeBearerHeader(jwt.value);
if (authorizationHeader) headers['Authorization'] = authorizationHeader; if (authorizationHeader) headers['Authorization'] = authorizationHeader;
const initialResponse = await instance.request(path, { const response = await instance.request(path, {
method: options.method || 'GET', method: options.method || 'GET',
data: options.body || null, data: options.body || null,
headers, headers,
}); });
const response = await interceptResponse(path, initialResponse); if (response.status === 401 && path.startsWith("/admin")) {
if (ErrorCode.isAdminAuthError(response)) {
showAdminAuth.value = true; showAdminAuth.value = true;
} }
if (ErrorCode.isSiteAuthError(response)) { if (response.status === 401 && openSettings.value.needAuth) {
showAuth.value = true; showAuth.value = true;
} }
if (response.status >= 300) { if (response.status >= 300) {
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`); throw new Error(`[${response.status}]: ${response.data}` || "error");
} }
const data = response.data; const data = response.data;
return data; return data;
} catch (error) { } catch (error) {
if (error.response) { if (error.response) {
throw new Error(`Code ${error.response.status}: ${error.response.data?.message || error.response.data}`); throw new Error(`Code ${error.response.status}: ${error.response.data}` || "error");
} }
throw error; throw error;
} finally { } finally {
if (showLoading) loading.value = false; loading.value = false;
} }
} }
@@ -113,7 +99,6 @@ const getOpenSettings = async (message, notification) => {
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false, disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false, disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false, enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableMailReadStatus: res["enableMailReadStatus"] === true,
enableAutoReply: res["enableAutoReply"] || false, enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false, enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright, copyright: res["copyright"] || openSettings.value.copyright,
@@ -123,8 +108,6 @@ const getOpenSettings = async (message, notification) => {
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser, showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
enableAddressPassword: res["enableAddressPassword"] || false, enableAddressPassword: res["enableAddressPassword"] || false,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false, enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
enableRedeemCode: res["enableRedeemCode"] || false,
redeemCodeUrl: res["redeemCodeUrl"] || "",
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig, smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
statusUrl: res["statusUrl"] || "", statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false, enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
@@ -1,77 +0,0 @@
import { AxiosHeaders } from 'axios';
import { useGlobalState } from '../store';
import { safeBearerHeader, safeHeaderValue } from '../utils/headers';
import { ErrorCode } from './error-codes';
const { userJwt, userSettings, jwt } = useGlobalState();
const mailboxPaths = new Set(['/api/settings', '/api/send_mail']);
const paths = ['/admin/', ...mailboxPaths, '/user_api/bind_address', '/user_api/address/'];
const getPathname = (url) => new URL(url, window.location.origin).pathname;
const isCurrentSession = ({ headers }) =>
safeHeaderValue(headers.get('x-user-token')) === safeHeaderValue(userJwt.value)
&& safeHeaderValue(headers.get('Authorization')) === safeBearerHeader(jwt.value);
const matches = (path, response) => {
if (!ErrorCode.isUserAccessTokenError(response)) return false;
const pathname = getPathname(path);
if (!paths.some(prefix => pathname.startsWith(prefix))) return false;
const { config } = response;
if (!isCurrentSession(config)) return false;
if (!safeHeaderValue(config.headers.get('x-user-access-token'))) return false;
const token = safeHeaderValue(config.headers.get('x-user-token'));
return Boolean(token) || mailboxPaths.has(pathname);
};
async function loadUserSettings(token, client, headers) {
const response = await client.get('/user_api/settings', { headers });
if (response.status >= 300) {
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
}
if (safeHeaderValue(userJwt.value) === token) {
Object.assign(userSettings.value, response.data);
}
}
export const createUserAccessTokenInterceptor = (client) => {
const pendingRefreshes = new Map();
async function refreshUserSettings(token, headers) {
if (pendingRefreshes.has(token)) return await pendingRefreshes.get(token);
const request = loadUserSettings(token, client, headers);
pendingRefreshes.set(token, request);
try {
await request;
} finally {
pendingRefreshes.delete(token);
}
}
async function resolveAccessToken(config) {
const token = safeHeaderValue(config.headers.get('x-user-token'));
if (!token) return;
const currentToken = safeHeaderValue(userSettings.value.access_token);
if (currentToken !== safeHeaderValue(config.headers.get('x-user-access-token'))) return currentToken;
try {
await refreshUserSettings(token, config.headers);
} catch (error) {
if (!mailboxPaths.has(getPathname(config.url))) throw error;
return;
}
return safeHeaderValue(userSettings.value.access_token);
}
return {
matches,
handle: async ({ config }) => {
const accessToken = await resolveAccessToken(config);
if (!isCurrentSession(config)) throw new Error('User session changed, please retry');
const headers = new AxiosHeaders(config.headers);
headers.delete('x-user-access-token');
if (accessToken) headers.set('x-user-access-token', accessToken);
return await client.request({ ...config, headers });
},
};
};
@@ -1,311 +0,0 @@
<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,9 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useScopedI18n } from '@/i18n/app' import { useScopedI18n } from '@/i18n/app'
import AddressCredentialContent from './AddressCredentialContent.vue' import { useGlobalState } from '../store'
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -25,17 +24,299 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:show']) const emit = defineEmits(['update:show'])
const { t } = useScopedI18n('components.AddressCredentialModal')
const { openSettings, auth } = useGlobalState()
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
const message = useMessage()
const modalShow = computed({ const modalShow = computed({
get: () => props.show, get: () => props.show,
set: (value) => emit('update:show', value), 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> </script>
<template> <template>
<n-modal v-model:show="modalShow" preset="card" :title="t('title')" <n-modal v-model:show="modalShow" preset="card" :title="t('title')"
style="width: min(760px, calc(100vw - 32px));"> style="width: min(760px, calc(100vw - 32px));">
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" /> <n-alert type="info" :show-icon="false" :bordered="false">
{{ t('tip') }}
</n-alert>
<section class="credential-panel">
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
<div class="credential-section">
<div class="credential-field" v-if="address">
<span class="credential-label">{{ t('currentAddress') }}</span>
<div class="credential-copy-row">
<code class="credential-code">{{ address }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
<div class="credential-copy-row">
<code class="credential-code">{{ jwt }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div class="credential-field" v-if="addressPassword">
<span class="credential-label">{{ t('addressPassword') }}</span>
<code class="credential-code">{{ addressPassword }}</code>
</div>
</div>
</section>
<n-collapse accordion class="credential-collapse">
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
<div class="credential-field">
<span class="credential-label">{{ t('apiBase') }}</span>
<code class="credential-code">{{ apiBaseUrl }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('agentSkill') }}</span>
<code class="credential-code">
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('agentConfig') }}</span>
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
</div>
<div class="credential-actions">
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
{{ t('docs') }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
<div class="credential-grid">
<div class="credential-field">
<span class="credential-label">{{ t('smtpHost') }}</span>
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('smtpPort') }}</span>
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('imapHost') }}</span>
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('imapPort') }}</span>
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
</div>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('security') }}</span>
<code class="credential-code">{{ securityLabel }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('username') }}</span>
<code class="credential-code">{{ address }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('password') }}</span>
<code class="credential-code">{{ jwt }}</code>
</div>
<div class="credential-actions">
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
{{ t('docs') }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<div class="credential-field">
<code class="credential-code">{{ autoLoginUrl }}</code>
</div>
</div>
</n-collapse-item>
</n-collapse>
</n-modal> </n-modal>
</template> </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) { if (userJwt.value) {
const userChildren = await buildUserOptions(); const userChildren = await buildUserOptions();
if (userChildren.length > 0) { if (userChildren.length > 0) {
groups.push({ type: 'group', label: t('linkedMailboxes'), children: userChildren }); groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
} }
const userAddressSet = new Set(userChildren.map((item) => item.address)); const userAddressSet = new Set(userChildren.map((item) => item.address));
const localChildren = buildLocalOptions(userAddressSet); const localChildren = buildLocalOptions(userAddressSet);
@@ -215,7 +215,7 @@ watch([userJwt, isTelegram, () => settings.value.address], async () => {
<template> <template>
<n-flex class="address-row" align="center" justify="center" :wrap="true"> <n-flex class="address-row" align="center" justify="center" :wrap="true">
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable <n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
:loading="addressLoading" :placeholder="t('selectMailbox')" @update:value="onAddressChange" :loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
class="address-select" /> class="address-select" />
<slot name="actions" /> <slot name="actions" />
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary"> <n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
+9 -58
View File
@@ -55,14 +55,6 @@ const props = defineProps({
default: false, default: false,
required: false required: false
}, },
enableMailReadStatus: {
type: Boolean,
default: false
},
updateMailReadStatus: {
type: Function,
default: () => { }
},
}) })
const localFilterKeyword = ref('') const localFilterKeyword = ref('')
@@ -102,29 +94,6 @@ 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(() => { const canGoPrevMail = computed(() => {
if (!curMail.value) return false if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
@@ -142,12 +111,12 @@ const prevMail = async () => {
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex > 0) { if (currentIndex > 0) {
openMail(data.value[currentIndex - 1]) curMail.value = data.value[currentIndex - 1]
} else if (page.value > 1) { } else if (page.value > 1) {
page.value-- page.value--
await refresh() await refresh()
if (data.value.length > 0) { if (data.value.length > 0) {
openMail(data.value[data.value.length - 1]) curMail.value = data.value[data.value.length - 1]
} }
} }
} }
@@ -157,12 +126,12 @@ const nextMail = async () => {
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id) const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex < data.value.length - 1) { if (currentIndex < data.value.length - 1) {
openMail(data.value[currentIndex + 1]) curMail.value = data.value[currentIndex + 1]
} else if (count.value > page.value * pageSize.value) { } else if (count.value > page.value * pageSize.value) {
page.value++ page.value++
await refresh() await refresh()
if (data.value.length > 0) { if (data.value.length > 0) {
openMail(data.value[0]) curMail.value = data.value[0]
} }
} }
} }
@@ -236,7 +205,7 @@ const backFirstPageAndRefresh = async () => {
await refresh(); await refresh();
} }
const clickRow = (row) => { const clickRow = async (row) => {
if (multiActionMode.value) { if (multiActionMode.value) {
row.checked = !row.checked; row.checked = !row.checked;
curMail.value = row; curMail.value = row;
@@ -246,7 +215,7 @@ const clickRow = (row) => {
curMail.value = null; curMail.value = null;
return; return;
} }
openMail(row); curMail.value = row;
}; };
@@ -428,7 +397,7 @@ onBeforeUnmount(() => {
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;"> <div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable> <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="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]"> :class="mailItemClass(row)">
<template #prefix v-if="multiActionMode"> <template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" /> <n-checkbox v-model:checked="row.checked" />
</template> </template>
@@ -492,7 +461,6 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;"> style="overflow: auto; max-height: 100vh;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo" <MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3" :enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" /> :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card> </n-card>
<n-card :bordered="false" embedded class="mail-item" v-else> <n-card :bordered="false" embedded class="mail-item" v-else>
@@ -507,7 +475,7 @@ onBeforeUnmount(() => {
<div v-else class="mail-list-scroll"> <div v-else class="mail-list-scroll">
<n-list hoverable clickable> <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="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]"> :class="mailItemClass(row)">
<template #prefix v-if="multiActionMode"> <template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" /> <n-checkbox v-model:checked="row.checked" />
</template> </template>
@@ -568,8 +536,7 @@ onBeforeUnmount(() => {
</div> </div>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;"> <div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable> <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"> <n-thing :title="row.subject">
<template #description> <template #description>
<n-tag type="info"> <n-tag type="info">
@@ -600,7 +567,6 @@ onBeforeUnmount(() => {
<n-card :bordered="false" embedded style="overflow: auto;"> <n-card :bordered="false" embedded style="overflow: auto;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo" <MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3" :enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:onSaveToS3="saveToS3Proxy" /> :onSaveToS3="saveToS3Proxy" />
</n-card> </n-card>
@@ -710,21 +676,6 @@ onBeforeUnmount(() => {
min-width: 0; 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 { pre {
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
@@ -34,10 +34,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
enableMailReadStatus: {
type: Boolean,
default: false
},
// props // props
onDelete: { onDelete: {
type: Function, type: Function,
@@ -54,10 +50,6 @@ const props = defineProps({
onSaveToS3: { onSaveToS3: {
type: Function, type: Function,
default: () => { } default: () => { }
},
onUpdateMailReadStatus: {
type: Function,
default: () => { }
} }
}); });
@@ -65,7 +57,6 @@ const showTextMail = ref(preferShowTextMail.value);
const showAttachments = ref(false); const showAttachments = ref(false);
const curAttachments = ref([]); const curAttachments = ref([]);
const attachmentLoding = ref(false); const attachmentLoding = ref(false);
const readStatusUpdating = ref(false);
const showFullscreen = ref(false); const showFullscreen = ref(false);
// Per-mail consent, deliberately independent of the global setting: it only // Per-mail consent, deliberately independent of the global setting: it only
@@ -105,15 +96,6 @@ const handleForward = () => {
props.onForward(); props.onForward();
}; };
const handleUpdateMailReadStatus = async () => {
readStatusUpdating.value = true;
try {
await props.onUpdateMailReadStatus();
} finally {
readStatusUpdating.value = false;
}
};
const handleSaveToS3 = async (filename, blob) => { const handleSaveToS3 = async (filename, blob) => {
attachmentLoding.value = true; attachmentLoding.value = true;
@@ -156,11 +138,6 @@ const handleSaveToS3 = async (filename, blob) => {
{{ t('attachments') }} {{ t('attachments') }}
</n-button> </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'" <n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
:href="getDownloadEmlUrl(mail.raw)"> :href="getDownloadEmlUrl(mail.raw)">
<template #icon> <template #icon>
-39
View File
@@ -1,39 +0,0 @@
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
+21 -50
View File
@@ -1,20 +1,4 @@
export const deMessages = { 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.current_database_size": "Aktuelle Datenbankgröße",
"views.admin.DatabaseManager.free_plan": "Free", "views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid", "views.admin.DatabaseManager.paid_plan": "Workers Paid",
@@ -33,12 +17,10 @@ export const deMessages = {
"views.Index.about": "Über", "views.Index.about": "Über",
"views.Admin.about": "Über", "views.Admin.about": "Über",
"views.Header.accessHeader": "Zugangspasswort", "views.Header.accessHeader": "Zugangspasswort",
"views.Header.admin": "Admin-Konsole", "views.Admin.account": "Konto",
"views.Admin.mailbox_management": "Postfachverwaltung", "views.Index.accountSettings": "E-Mail-Einstellungen",
"views.Admin.mailbox_list": "Postfächer", "views.Admin.account_settings": "Kontoeinstellungen",
"views.Index.mailboxSettings": "Postfacheinstellungen", "views.index.SimpleIndex.accountSettings": "E-Mail-Einstellungen",
"views.Admin.mailbox_settings": "Globale Postfacheinstellungen",
"views.index.SimpleIndex.mailboxSettings": "Postfacheinstellungen",
"views.index.Attachment.action": "Aktion", "views.index.Attachment.action": "Aktion",
"views.admin.SenderAccess.action": "Aktion", "views.admin.SenderAccess.action": "Aktion",
"views.user.UserSettings.actions": "Aktionen", "views.user.UserSettings.actions": "Aktionen",
@@ -52,7 +34,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.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.Maintenance.addCustomSql": "Benutzerdefinierte SQL hinzufügen",
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen", "views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen",
"components.AddressSelect.selectMailbox": "Postfach auswählen", "components.AddressSelect.address": "Adresse",
"views.user.AddressManagement.address": "Adresse", "views.user.AddressManagement.address": "Adresse",
"views.index.TelegramAddress.address": "Adresse", "views.index.TelegramAddress.address": "Adresse",
"views.admin.SenderAccess.address": "Adresse", "views.admin.SenderAccess.address": "Adresse",
@@ -88,7 +70,7 @@ export const deMessages = {
"views.admin.Account.clearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente dieser E-Mail-Adresse leeren?", "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.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.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
"views.admin.Account.deleteTip": "Möchtest du wirklich dieses Postfach löschen?", "views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?", "views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
"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.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?", "views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich von der aktuellen E-Mail-Adresse abmelden?",
@@ -125,7 +107,7 @@ export const deMessages = {
"views.admin.AccountSettings.cancel": "Abbrechen", "views.admin.AccountSettings.cancel": "Abbrechen",
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen", "components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen", "components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
"views.user.AddressManagement.openMailbox": "Postfach öffnen", "views.user.AddressManagement.changeMailAddress": "Adresse wechseln",
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern", "views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern", "views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
"views.index.AccountSettings.changePassword": "Adresspasswort ändern", "views.index.AccountSettings.changePassword": "Adresspasswort ändern",
@@ -164,8 +146,8 @@ export const deMessages = {
"views.index.SimpleIndex.copyAddress": "Kopieren", "views.index.SimpleIndex.copyAddress": "Kopieren",
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen", "components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
"views.Footer.copyright": "Urheberrecht", "views.Footer.copyright": "Urheberrecht",
"views.Admin.mailbox_create": "E-Mail-Adresse erstellen", "views.Admin.account_create": "Konto erstellen",
"views.admin.CreateAccount.createEmailAddress": "E-Mail-Adresse erstellen", "views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
"views.common.Login.getNewEmail": "Neue E-Mail erstellen", "views.common.Login.getNewEmail": "Neue E-Mail erstellen",
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen", "views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen", "views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
@@ -200,8 +182,8 @@ export const deMessages = {
"views.admin.UserManagement.delete": "Löschen", "views.admin.UserManagement.delete": "Löschen",
"views.admin.AccountSettings.delete_rule": "Löschen", "views.admin.AccountSettings.delete_rule": "Löschen",
"views.admin.Maintenance.deleteCustomSql": "Löschen", "views.admin.Maintenance.deleteCustomSql": "Löschen",
"views.index.AccountSettings.deleteMailbox": "Postfach löschen", "views.index.AccountSettings.deleteAccount": "E-Mail-Adresse löschen",
"views.admin.Account.deleteMailbox": "Postfach löschen", "views.admin.Account.deleteAccount": "Konto löschen",
"views.user.UserSettings.deletePasskey": "Passkey löschen", "views.user.UserSettings.deletePasskey": "Passkey löschen",
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht", "views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
"views.admin.UserManagement.deleteUser": "Benutzer löschen", "views.admin.UserManagement.deleteUser": "Benutzer löschen",
@@ -272,7 +254,7 @@ export const deMessages = {
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste", "views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
"views.common.Appearance.globalTabplacement": "Globale Tab-Position", "views.common.Appearance.globalTabplacement": "Globale Tab-Position",
"views.common.Login.help": "Hilfe", "views.common.Login.help": "Hilfe",
"views.Header.mailbox": "Postfach", "views.Header.home": "Startseite",
"views.index.SendMail.html": "HTML", "views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML", "views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)", "views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)",
@@ -321,7 +303,7 @@ export const deMessages = {
"views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten", "views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten",
"views.admin.CreateAccount.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.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.inbox": "Posteingang", "views.Index.mailbox": "Posteingang",
"views.User.user_mail_box_tab": "Posteingang", "views.User.user_mail_box_tab": "Posteingang",
"views.user.AddressManagement.mail_count": "E-Mail-Anzahl", "views.user.AddressManagement.mail_count": "E-Mail-Anzahl",
"views.admin.Account.mail_count": "E-Mail-Anzahl", "views.admin.Account.mail_count": "E-Mail-Anzahl",
@@ -351,11 +333,11 @@ export const deMessages = {
"views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren", "views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren",
"views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren", "views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren",
"views.admin.Account.multiDelete": "Mehrfach löschen", "views.admin.Account.multiDelete": "Mehrfach löschen",
"views.user.AddressManagement.emailAddress": "E-Mail-Adresse", "views.user.AddressManagement.name": "Name",
"views.index.AutoReply.name": "Name", "views.index.AutoReply.name": "Name",
"views.admin.Account.emailAddress": "E-Mail-Adresse", "views.admin.Account.name": "Name",
"views.admin.UserOauth2Settings.name": "Name", "views.admin.UserOauth2Settings.name": "Name",
"views.admin.UserAddressManagement.emailAddress": "E-Mail-Adresse", "views.admin.UserAddressManagement.name": "Name",
"views.admin.Maintenance.sqlName": "Name", "views.admin.Maintenance.sqlName": "Name",
"views.index.AccountSettings.newPassword": "Neues Passwort", "views.index.AccountSettings.newPassword": "Neues Passwort",
"views.admin.Account.newPassword": "Neues Passwort", "views.admin.Account.newPassword": "Neues Passwort",
@@ -404,7 +386,6 @@ export const deMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben", "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.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.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.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben", "views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben", "views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
@@ -587,13 +568,13 @@ export const deMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "Regex verwenden, um die E-Mail umzuwandeln. Beispiel: ^(.+)@old\\.com$ mit $1@new.com", "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.useSimpleIndex": "Einfachen Index verwenden",
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden", "views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
"views.Header.userCenter": "Benutzerbereich", "views.Header.user": "Benutzer",
"views.Admin.user": "Benutzer", "views.Admin.user": "Benutzer",
"components.AddressSelect.linkedMailboxes": "Verknüpfte Postfächer", "components.AddressSelect.userAddresses": "Benutzeradressen",
"views.Admin.loginViaUserAdmin": "Benutzerkonto (Administratorrolle)", "views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
"views.admin.Statistics.userCount": "Benutzeranzahl", "views.admin.Statistics.userCount": "Benutzeranzahl",
"views.admin.UserManagement.user_email": "Benutzer-E-Mail", "views.admin.UserManagement.user_email": "Benutzer-E-Mail",
"views.index.AddressBar.userCenter": "Benutzerbereich", "views.index.AddressBar.userLogin": "Benutzeranmeldung",
"views.Admin.user_management": "Benutzerverwaltung", "views.Admin.user_management": "Benutzerverwaltung",
"views.Admin.user_settings": "Benutzereinstellungen", "views.Admin.user_settings": "Benutzereinstellungen",
"views.User.user_settings": "Benutzereinstellungen", "views.User.user_settings": "Benutzereinstellungen",
@@ -653,15 +634,5 @@ export const deMessages = {
"components.AddressCredentialModal.starttls": "STARTTLS", "components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.", "components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden", "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"
} }
+21 -50
View File
@@ -1,20 +1,4 @@
export const esMessages = { 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.current_database_size": "Tamaño actual de la base de datos",
"views.admin.DatabaseManager.free_plan": "Free", "views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid", "views.admin.DatabaseManager.paid_plan": "Workers Paid",
@@ -33,12 +17,10 @@ export const esMessages = {
"views.Index.about": "Acerca de", "views.Index.about": "Acerca de",
"views.Admin.about": "Acerca de", "views.Admin.about": "Acerca de",
"views.Header.accessHeader": "Contraseña de acceso", "views.Header.accessHeader": "Contraseña de acceso",
"views.Header.admin": "Consola de administración", "views.Admin.account": "Cuenta",
"views.Admin.mailbox_management": "Gestión de buzones", "views.Index.accountSettings": "Configuración del correo",
"views.Admin.mailbox_list": "Buzones", "views.Admin.account_settings": "Configuración de la cuenta",
"views.Index.mailboxSettings": "Configuración del buzón", "views.index.SimpleIndex.accountSettings": "Configuración del correo",
"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.index.Attachment.action": "Acción",
"views.admin.SenderAccess.action": "Acción", "views.admin.SenderAccess.action": "Acción",
"views.user.UserSettings.actions": "Acciones", "views.user.UserSettings.actions": "Acciones",
@@ -52,7 +34,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.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.Maintenance.addCustomSql": "Añadir SQL personalizada",
"views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2", "views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2",
"components.AddressSelect.selectMailbox": "Seleccionar buzón", "components.AddressSelect.address": "Dirección",
"views.user.AddressManagement.address": "Dirección", "views.user.AddressManagement.address": "Dirección",
"views.index.TelegramAddress.address": "Dirección", "views.index.TelegramAddress.address": "Dirección",
"views.admin.SenderAccess.address": "Dirección", "views.admin.SenderAccess.address": "Dirección",
@@ -88,7 +70,7 @@ export const esMessages = {
"views.admin.Account.clearSentItemsTip": "¿Seguro que quieres vaciar los enviados de este correo?", "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.admin.Account.multiDeleteTip": "¿Seguro que quieres eliminar las direcciones seleccionadas?",
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?", "views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este buzón?", "views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?", "views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
"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.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?", "views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar la sesión de la dirección de correo actual?",
@@ -125,7 +107,7 @@ export const esMessages = {
"views.admin.AccountSettings.cancel": "Cancelar", "views.admin.AccountSettings.cancel": "Cancelar",
"components.MailBox.cancelMultiAction": "Cancelar selección múltiple", "components.MailBox.cancelMultiAction": "Cancelar selección múltiple",
"components.SendBox.cancelMultiAction": "Cancelar selección múltiple", "components.SendBox.cancelMultiAction": "Cancelar selección múltiple",
"views.user.AddressManagement.openMailbox": "Abrir buzón", "views.user.AddressManagement.changeMailAddress": "Cambiar dirección",
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo", "views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo", "views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección", "views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
@@ -164,8 +146,8 @@ export const esMessages = {
"views.index.SimpleIndex.copyAddress": "Copiar", "views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Error al copiar", "components.AiExtractInfo.copyFailed": "Error al copiar",
"views.Footer.copyright": "Derechos de autor", "views.Footer.copyright": "Derechos de autor",
"views.Admin.mailbox_create": "Crear dirección de correo", "views.Admin.account_create": "Crear cuenta",
"views.admin.CreateAccount.createEmailAddress": "Crear dirección de correo", "views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
"views.common.Login.getNewEmail": "Crear nuevo correo", "views.common.Login.getNewEmail": "Crear nuevo correo",
"views.user.AddressManagement.create_or_bind": "Crear o vincular", "views.user.AddressManagement.create_or_bind": "Crear o vincular",
"views.index.LocalAddress.create_or_bind": "Crear o vincular", "views.index.LocalAddress.create_or_bind": "Crear o vincular",
@@ -200,8 +182,8 @@ export const esMessages = {
"views.admin.UserManagement.delete": "Eliminar", "views.admin.UserManagement.delete": "Eliminar",
"views.admin.AccountSettings.delete_rule": "Eliminar", "views.admin.AccountSettings.delete_rule": "Eliminar",
"views.admin.Maintenance.deleteCustomSql": "Eliminar", "views.admin.Maintenance.deleteCustomSql": "Eliminar",
"views.index.AccountSettings.deleteMailbox": "Eliminar buzón", "views.index.AccountSettings.deleteAccount": "Eliminar dirección de correo",
"views.admin.Account.deleteMailbox": "Eliminar buzón", "views.admin.Account.deleteAccount": "Eliminar cuenta",
"views.user.UserSettings.deletePasskey": "Eliminar passkey", "views.user.UserSettings.deletePasskey": "Eliminar passkey",
"views.admin.AccountSettings.delete_success": "Eliminado correctamente", "views.admin.AccountSettings.delete_success": "Eliminado correctamente",
"views.admin.UserManagement.deleteUser": "Eliminar usuario", "views.admin.UserManagement.deleteUser": "Eliminar usuario",
@@ -272,7 +254,7 @@ export const esMessages = {
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos", "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.Appearance.globalTabplacement": "Posición global de pestañas",
"views.common.Login.help": "Ayuda", "views.common.Login.help": "Ayuda",
"views.Header.mailbox": "Buzón", "views.Header.home": "Inicio",
"views.index.SendMail.html": "HTML", "views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML", "views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)", "views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)",
@@ -321,7 +303,7 @@ export const esMessages = {
"views.admin.Account.addressCredential": "Credencial de dirección de correo", "views.admin.Account.addressCredential": "Credencial de dirección de correo",
"views.admin.CreateAccount.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.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.inbox": "Bandeja de entrada", "views.Index.mailbox": "Bandeja de entrada",
"views.User.user_mail_box_tab": "Bandeja de entrada", "views.User.user_mail_box_tab": "Bandeja de entrada",
"views.user.AddressManagement.mail_count": "Cantidad de correos", "views.user.AddressManagement.mail_count": "Cantidad de correos",
"views.admin.Account.mail_count": "Cantidad de correos", "views.admin.Account.mail_count": "Cantidad de correos",
@@ -351,11 +333,11 @@ export const esMessages = {
"views.admin.Account.multiClearInbox": "Vaciar varias bandejas", "views.admin.Account.multiClearInbox": "Vaciar varias bandejas",
"views.admin.Account.multiClearSentItems": "Vaciar varios enviados", "views.admin.Account.multiClearSentItems": "Vaciar varios enviados",
"views.admin.Account.multiDelete": "Eliminar en lote", "views.admin.Account.multiDelete": "Eliminar en lote",
"views.user.AddressManagement.emailAddress": "Dirección de correo", "views.user.AddressManagement.name": "Nombre",
"views.index.AutoReply.name": "Nombre", "views.index.AutoReply.name": "Nombre",
"views.admin.Account.emailAddress": "Dirección de correo", "views.admin.Account.name": "Nombre",
"views.admin.UserOauth2Settings.name": "Nombre", "views.admin.UserOauth2Settings.name": "Nombre",
"views.admin.UserAddressManagement.emailAddress": "Dirección de correo", "views.admin.UserAddressManagement.name": "Nombre",
"views.admin.Maintenance.sqlName": "Nombre", "views.admin.Maintenance.sqlName": "Nombre",
"views.index.AccountSettings.newPassword": "Nueva contraseña", "views.index.AccountSettings.newPassword": "Nueva contraseña",
"views.admin.Account.newPassword": "Nueva contraseña", "views.admin.Account.newPassword": "Nueva contraseña",
@@ -404,7 +386,6 @@ export const esMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey", "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.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.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.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
"views.user.UserLogin.pleaseInputCode": "Introduce el código", "views.user.UserLogin.pleaseInputCode": "Introduce el código",
"views.admin.UserManagement.pleaseInput": "Introduce la información completa", "views.admin.UserManagement.pleaseInput": "Introduce la información completa",
@@ -587,13 +568,13 @@ export const esMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "Usa regex para transformar el correo. Ejemplo: ^(.+)@old\\.com$ con $1@new.com", "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.useSimpleIndex": "Usar índice simple",
"views.common.Appearance.useUTCDate": "Usar fecha UTC", "views.common.Appearance.useUTCDate": "Usar fecha UTC",
"views.Header.userCenter": "Centro de usuario", "views.Header.user": "Usuario",
"views.Admin.user": "Usuario", "views.Admin.user": "Usuario",
"components.AddressSelect.linkedMailboxes": "Buzones vinculados", "components.AddressSelect.userAddresses": "Direcciones del usuario",
"views.Admin.loginViaUserAdmin": "Cuenta de usuario (rol de administrador)", "views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
"views.admin.Statistics.userCount": "Cantidad de usuarios", "views.admin.Statistics.userCount": "Cantidad de usuarios",
"views.admin.UserManagement.user_email": "Correo del usuario", "views.admin.UserManagement.user_email": "Correo del usuario",
"views.index.AddressBar.userCenter": "Centro de usuario", "views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
"views.Admin.user_management": "Gestión de usuarios", "views.Admin.user_management": "Gestión de usuarios",
"views.Admin.user_settings": "Configuración de usuario", "views.Admin.user_settings": "Configuración de usuario",
"views.User.user_settings": "Configuración de usuario", "views.User.user_settings": "Configuración de usuario",
@@ -653,15 +634,5 @@ export const esMessages = {
"components.AddressCredentialModal.starttls": "STARTTLS", "components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.", "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.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"
} }
+21 -50
View File
@@ -1,20 +1,4 @@
export const jaMessages = { 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.current_database_size": "現在のデータベースサイズ",
"views.admin.DatabaseManager.free_plan": "Free", "views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid", "views.admin.DatabaseManager.paid_plan": "Workers Paid",
@@ -33,12 +17,10 @@ export const jaMessages = {
"views.Index.about": "概要", "views.Index.about": "概要",
"views.Admin.about": "概要", "views.Admin.about": "概要",
"views.Header.accessHeader": "アクセス用パスワード", "views.Header.accessHeader": "アクセス用パスワード",
"views.Header.admin": "管理画面", "views.Admin.account": "アカウント",
"views.Admin.mailbox_management": "メールボックス管理", "views.Index.accountSettings": "メール設定",
"views.Admin.mailbox_list": "メールボックス一覧", "views.Admin.account_settings": "アカウント設定",
"views.Index.mailboxSettings": "メールボックス設定", "views.index.SimpleIndex.accountSettings": "メール設定",
"views.Admin.mailbox_settings": "メールボックスのグローバル設定",
"views.index.SimpleIndex.mailboxSettings": "メールボックス設定",
"views.index.Attachment.action": "操作", "views.index.Attachment.action": "操作",
"views.admin.SenderAccess.action": "操作", "views.admin.SenderAccess.action": "操作",
"views.user.UserSettings.actions": "操作", "views.user.UserSettings.actions": "操作",
@@ -52,7 +34,7 @@ export const jaMessages = {
"views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。", "views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。",
"views.admin.Maintenance.addCustomSql": "カスタム SQL を追加", "views.admin.Maintenance.addCustomSql": "カスタム SQL を追加",
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加", "views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加",
"components.AddressSelect.selectMailbox": "メールボックスを選択", "components.AddressSelect.address": "アドレス",
"views.user.AddressManagement.address": "アドレス", "views.user.AddressManagement.address": "アドレス",
"views.index.TelegramAddress.address": "アドレス", "views.index.TelegramAddress.address": "アドレス",
"views.admin.SenderAccess.address": "アドレス", "views.admin.SenderAccess.address": "アドレス",
@@ -88,7 +70,7 @@ export const jaMessages = {
"views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?", "views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?",
"views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?", "views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?",
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?", "views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールボックスを削除してもよろしいですか?", "views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?", "views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。", "views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?", "views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
@@ -125,7 +107,7 @@ export const jaMessages = {
"views.admin.AccountSettings.cancel": "キャンセル", "views.admin.AccountSettings.cancel": "キャンセル",
"components.MailBox.cancelMultiAction": "複数選択をキャンセル", "components.MailBox.cancelMultiAction": "複数選択をキャンセル",
"components.SendBox.cancelMultiAction": "複数選択をキャンセル", "components.SendBox.cancelMultiAction": "複数選択をキャンセル",
"views.user.AddressManagement.openMailbox": "メールボックスを開く", "views.user.AddressManagement.changeMailAddress": "アドレスを変更",
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更", "views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更", "views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更", "views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
@@ -164,8 +146,8 @@ export const jaMessages = {
"views.index.SimpleIndex.copyAddress": "コピー", "views.index.SimpleIndex.copyAddress": "コピー",
"components.AiExtractInfo.copyFailed": "コピーに失敗しました", "components.AiExtractInfo.copyFailed": "コピーに失敗しました",
"views.Footer.copyright": "著作権", "views.Footer.copyright": "著作権",
"views.Admin.mailbox_create": "メールアドレスを作成", "views.Admin.account_create": "アカウントを作成",
"views.admin.CreateAccount.createEmailAddress": "メールアドレスを作成", "views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
"views.common.Login.getNewEmail": "新しいメールを作成", "views.common.Login.getNewEmail": "新しいメールを作成",
"views.user.AddressManagement.create_or_bind": "作成または紐付け", "views.user.AddressManagement.create_or_bind": "作成または紐付け",
"views.index.LocalAddress.create_or_bind": "作成または紐付け", "views.index.LocalAddress.create_or_bind": "作成または紐付け",
@@ -200,8 +182,8 @@ export const jaMessages = {
"views.admin.UserManagement.delete": "削除", "views.admin.UserManagement.delete": "削除",
"views.admin.AccountSettings.delete_rule": "削除", "views.admin.AccountSettings.delete_rule": "削除",
"views.admin.Maintenance.deleteCustomSql": "削除", "views.admin.Maintenance.deleteCustomSql": "削除",
"views.index.AccountSettings.deleteMailbox": "メールボックスを削除", "views.index.AccountSettings.deleteAccount": "メールアドレスを削除",
"views.admin.Account.deleteMailbox": "メールボックスを削除", "views.admin.Account.deleteAccount": "アカウントを削除",
"views.user.UserSettings.deletePasskey": "Passkeyを削除", "views.user.UserSettings.deletePasskey": "Passkeyを削除",
"views.admin.AccountSettings.delete_success": "削除しました", "views.admin.AccountSettings.delete_success": "削除しました",
"views.admin.UserManagement.deleteUser": "ユーザーを削除", "views.admin.UserManagement.deleteUser": "ユーザーを削除",
@@ -272,7 +254,7 @@ export const jaMessages = {
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧", "views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
"views.common.Appearance.globalTabplacement": "全体タブ位置", "views.common.Appearance.globalTabplacement": "全体タブ位置",
"views.common.Login.help": "ヘルプ", "views.common.Login.help": "ヘルプ",
"views.Header.mailbox": "メールボックス", "views.Header.home": "ホーム",
"views.index.SendMail.html": "HTML", "views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML", "views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)", "views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)",
@@ -321,7 +303,7 @@ export const jaMessages = {
"views.admin.Account.addressCredential": "メールアドレス資格情報", "views.admin.Account.addressCredential": "メールアドレス資格情報",
"views.admin.CreateAccount.addressCredential": "メールアドレス資格情報", "views.admin.CreateAccount.addressCredential": "メールアドレス資格情報",
"views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。", "views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。",
"views.Index.inbox": "受信箱", "views.Index.mailbox": "受信箱",
"views.User.user_mail_box_tab": "受信箱", "views.User.user_mail_box_tab": "受信箱",
"views.user.AddressManagement.mail_count": "メール数", "views.user.AddressManagement.mail_count": "メール数",
"views.admin.Account.mail_count": "メール数", "views.admin.Account.mail_count": "メール数",
@@ -351,11 +333,11 @@ export const jaMessages = {
"views.admin.Account.multiClearInbox": "受信箱を一括削除", "views.admin.Account.multiClearInbox": "受信箱を一括削除",
"views.admin.Account.multiClearSentItems": "送信済みを一括削除", "views.admin.Account.multiClearSentItems": "送信済みを一括削除",
"views.admin.Account.multiDelete": "一括削除", "views.admin.Account.multiDelete": "一括削除",
"views.user.AddressManagement.emailAddress": "メールアドレス", "views.user.AddressManagement.name": "名前",
"views.index.AutoReply.name": "名前", "views.index.AutoReply.name": "名前",
"views.admin.Account.emailAddress": "メールアドレス", "views.admin.Account.name": "名前",
"views.admin.UserOauth2Settings.name": "名前", "views.admin.UserOauth2Settings.name": "名前",
"views.admin.UserAddressManagement.emailAddress": "メールアドレス", "views.admin.UserAddressManagement.name": "名前",
"views.admin.Maintenance.sqlName": "名前", "views.admin.Maintenance.sqlName": "名前",
"views.index.AccountSettings.newPassword": "新しいパスワード", "views.index.AccountSettings.newPassword": "新しいパスワード",
"views.admin.Account.newPassword": "新しいパスワード", "views.admin.Account.newPassword": "新しいパスワード",
@@ -404,7 +386,6 @@ export const jaMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください", "views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください", "views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください", "views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
"views.admin.CreateAccount.generateName": "ランダム名を生成",
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します", "views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
"views.user.UserLogin.pleaseInputCode": "コードを入力してください", "views.user.UserLogin.pleaseInputCode": "コードを入力してください",
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください", "views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
@@ -587,13 +568,13 @@ export const jaMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com", "views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com",
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う", "views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
"views.common.Appearance.useUTCDate": "UTC 日時を使う", "views.common.Appearance.useUTCDate": "UTC 日時を使う",
"views.Header.userCenter": "ユーザーセンター", "views.Header.user": "ユーザー",
"views.Admin.user": "ユーザー", "views.Admin.user": "ユーザー",
"components.AddressSelect.linkedMailboxes": "連携済みメールボックス", "components.AddressSelect.userAddresses": "ユーザーのアドレス",
"views.Admin.loginViaUserAdmin": "ユーザーアカウント(管理者ロール)", "views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
"views.admin.Statistics.userCount": "ユーザー数", "views.admin.Statistics.userCount": "ユーザー数",
"views.admin.UserManagement.user_email": "ユーザーメール", "views.admin.UserManagement.user_email": "ユーザーメール",
"views.index.AddressBar.userCenter": "ユーザーセンター", "views.index.AddressBar.userLogin": "ユーザーログイン",
"views.Admin.user_management": "ユーザー管理", "views.Admin.user_management": "ユーザー管理",
"views.Admin.user_settings": "ユーザー設定", "views.Admin.user_settings": "ユーザー設定",
"views.User.user_settings": "ユーザー設定", "views.User.user_settings": "ユーザー設定",
@@ -653,15 +634,5 @@ export const jaMessages = {
"components.AddressCredentialModal.starttls": "STARTTLS", "components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。", "components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法", "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": "メール本文の外部画像を自動的に読み込む"
} }
+21 -50
View File
@@ -1,20 +1,4 @@
export const ptBRMessages = { 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.current_database_size": "Tamanho atual do banco de dados",
"views.admin.DatabaseManager.free_plan": "Free", "views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid", "views.admin.DatabaseManager.paid_plan": "Workers Paid",
@@ -33,12 +17,10 @@ export const ptBRMessages = {
"views.Index.about": "Sobre", "views.Index.about": "Sobre",
"views.Admin.about": "Sobre", "views.Admin.about": "Sobre",
"views.Header.accessHeader": "Senha de acesso", "views.Header.accessHeader": "Senha de acesso",
"views.Header.admin": "Painel administrativo", "views.Admin.account": "Conta",
"views.Admin.mailbox_management": "Gerenciamento de caixas de correio", "views.Index.accountSettings": "Configurações de e-mail",
"views.Admin.mailbox_list": "Caixas de correio", "views.Admin.account_settings": "Configurações da conta",
"views.Index.mailboxSettings": "Configurações da caixa de correio", "views.index.SimpleIndex.accountSettings": "Configurações de e-mail",
"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.index.Attachment.action": "Ação",
"views.admin.SenderAccess.action": "Ação", "views.admin.SenderAccess.action": "Ação",
"views.user.UserSettings.actions": "Ações", "views.user.UserSettings.actions": "Ações",
@@ -52,7 +34,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.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.Maintenance.addCustomSql": "Adicionar SQL personalizada",
"views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2", "views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2",
"components.AddressSelect.selectMailbox": "Selecionar caixa de correio", "components.AddressSelect.address": "Endereço",
"views.user.AddressManagement.address": "Endereço", "views.user.AddressManagement.address": "Endereço",
"views.index.TelegramAddress.address": "Endereço", "views.index.TelegramAddress.address": "Endereço",
"views.admin.SenderAccess.address": "Endereço", "views.admin.SenderAccess.address": "Endereço",
@@ -88,7 +70,7 @@ export const ptBRMessages = {
"views.admin.Account.clearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados deste e-mail?", "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.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.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir esta caixa de correio?", "views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?", "views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
"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.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?", "views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair do endereço de e-mail atual?",
@@ -125,7 +107,7 @@ export const ptBRMessages = {
"views.admin.AccountSettings.cancel": "Cancelar", "views.admin.AccountSettings.cancel": "Cancelar",
"components.MailBox.cancelMultiAction": "Cancelar múltipla seleção", "components.MailBox.cancelMultiAction": "Cancelar múltipla seleção",
"components.SendBox.cancelMultiAction": "Cancelar múltipla seleção", "components.SendBox.cancelMultiAction": "Cancelar múltipla seleção",
"views.user.AddressManagement.openMailbox": "Abrir caixa de correio", "views.user.AddressManagement.changeMailAddress": "Alterar endereço",
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail", "views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail", "views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
"views.index.AccountSettings.changePassword": "Alterar senha do endereço", "views.index.AccountSettings.changePassword": "Alterar senha do endereço",
@@ -164,8 +146,8 @@ export const ptBRMessages = {
"views.index.SimpleIndex.copyAddress": "Copiar", "views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Falha ao copiar", "components.AiExtractInfo.copyFailed": "Falha ao copiar",
"views.Footer.copyright": "Direitos autorais", "views.Footer.copyright": "Direitos autorais",
"views.Admin.mailbox_create": "Criar endereço de e-mail", "views.Admin.account_create": "Criar conta",
"views.admin.CreateAccount.createEmailAddress": "Criar endereço de e-mail", "views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
"views.common.Login.getNewEmail": "Criar novo e-mail", "views.common.Login.getNewEmail": "Criar novo e-mail",
"views.user.AddressManagement.create_or_bind": "Criar ou vincular", "views.user.AddressManagement.create_or_bind": "Criar ou vincular",
"views.index.LocalAddress.create_or_bind": "Criar ou vincular", "views.index.LocalAddress.create_or_bind": "Criar ou vincular",
@@ -200,8 +182,8 @@ export const ptBRMessages = {
"views.admin.UserManagement.delete": "Excluir", "views.admin.UserManagement.delete": "Excluir",
"views.admin.AccountSettings.delete_rule": "Excluir", "views.admin.AccountSettings.delete_rule": "Excluir",
"views.admin.Maintenance.deleteCustomSql": "Excluir", "views.admin.Maintenance.deleteCustomSql": "Excluir",
"views.index.AccountSettings.deleteMailbox": "Excluir caixa de correio", "views.index.AccountSettings.deleteAccount": "Excluir endereço de e-mail",
"views.admin.Account.deleteMailbox": "Excluir caixa de correio", "views.admin.Account.deleteAccount": "Excluir conta",
"views.user.UserSettings.deletePasskey": "Excluir passkey", "views.user.UserSettings.deletePasskey": "Excluir passkey",
"views.admin.AccountSettings.delete_success": "Excluído com sucesso", "views.admin.AccountSettings.delete_success": "Excluído com sucesso",
"views.admin.UserManagement.deleteUser": "Excluir usuário", "views.admin.UserManagement.deleteUser": "Excluir usuário",
@@ -272,7 +254,7 @@ export const ptBRMessages = {
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails", "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.Appearance.globalTabplacement": "Posição global das abas",
"views.common.Login.help": "Ajuda", "views.common.Login.help": "Ajuda",
"views.Header.mailbox": "Caixa de correio", "views.Header.home": "Início",
"views.index.SendMail.html": "HTML", "views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML", "views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)", "views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)",
@@ -321,7 +303,7 @@ export const ptBRMessages = {
"views.admin.Account.addressCredential": "Credencial do endereço de e-mail", "views.admin.Account.addressCredential": "Credencial do endereço de e-mail",
"views.admin.CreateAccount.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.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.inbox": "Caixa de entrada", "views.Index.mailbox": "Caixa de entrada",
"views.User.user_mail_box_tab": "Caixa de entrada", "views.User.user_mail_box_tab": "Caixa de entrada",
"views.user.AddressManagement.mail_count": "Quantidade de e-mails", "views.user.AddressManagement.mail_count": "Quantidade de e-mails",
"views.admin.Account.mail_count": "Quantidade de e-mails", "views.admin.Account.mail_count": "Quantidade de e-mails",
@@ -351,11 +333,11 @@ export const ptBRMessages = {
"views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada", "views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada",
"views.admin.Account.multiClearSentItems": "Limpar vários itens enviados", "views.admin.Account.multiClearSentItems": "Limpar vários itens enviados",
"views.admin.Account.multiDelete": "Excluir em lote", "views.admin.Account.multiDelete": "Excluir em lote",
"views.user.AddressManagement.emailAddress": "Endereço de e-mail", "views.user.AddressManagement.name": "Nome",
"views.index.AutoReply.name": "Nome", "views.index.AutoReply.name": "Nome",
"views.admin.Account.emailAddress": "Endereço de e-mail", "views.admin.Account.name": "Nome",
"views.admin.UserOauth2Settings.name": "Nome", "views.admin.UserOauth2Settings.name": "Nome",
"views.admin.UserAddressManagement.emailAddress": "Endereço de e-mail", "views.admin.UserAddressManagement.name": "Nome",
"views.admin.Maintenance.sqlName": "Nome", "views.admin.Maintenance.sqlName": "Nome",
"views.index.AccountSettings.newPassword": "Nova senha", "views.index.AccountSettings.newPassword": "Nova senha",
"views.admin.Account.newPassword": "Nova senha", "views.admin.Account.newPassword": "Nova senha",
@@ -404,7 +386,6 @@ export const ptBRMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey", "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.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.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.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
"views.user.UserLogin.pleaseInputCode": "Informe o código", "views.user.UserLogin.pleaseInputCode": "Informe o código",
"views.admin.UserManagement.pleaseInput": "Informe todas as informações", "views.admin.UserManagement.pleaseInput": "Informe todas as informações",
@@ -587,13 +568,13 @@ export const ptBRMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "Use regex para transformar o e-mail. Exemplo: ^(.+)@old\\.com$ com $1@new.com", "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.useSimpleIndex": "Usar índice simples",
"views.common.Appearance.useUTCDate": "Usar data UTC", "views.common.Appearance.useUTCDate": "Usar data UTC",
"views.Header.userCenter": "Central do usuário", "views.Header.user": "Usuário",
"views.Admin.user": "Usuário", "views.Admin.user": "Usuário",
"components.AddressSelect.linkedMailboxes": "Caixas de correio vinculadas", "components.AddressSelect.userAddresses": "Endereços do usuário",
"views.Admin.loginViaUserAdmin": "Conta de usuário (função de administrador)", "views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
"views.admin.Statistics.userCount": "Quantidade de usuários", "views.admin.Statistics.userCount": "Quantidade de usuários",
"views.admin.UserManagement.user_email": "E-mail do usuário", "views.admin.UserManagement.user_email": "E-mail do usuário",
"views.index.AddressBar.userCenter": "Central do usuário", "views.index.AddressBar.userLogin": "Login do usuário",
"views.Admin.user_management": "Gerenciamento de usuários", "views.Admin.user_management": "Gerenciamento de usuários",
"views.Admin.user_settings": "Configurações do usuário", "views.Admin.user_settings": "Configurações do usuário",
"views.User.user_settings": "Configurações do usuário", "views.User.user_settings": "Configurações do usuário",
@@ -653,15 +634,5 @@ export const ptBRMessages = {
"components.AddressCredentialModal.starttls": "STARTTLS", "components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.", "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.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
+2 -5
View File
@@ -1,11 +1,11 @@
import { LOCALE_REGISTRY, SUPPORTED_LOCALES } from './locale-registry' import { LOCALE_REGISTRY, SUPPORTED_LOCALES } from './locale-registry'
import { APP_CONFIG } from '../config'
export { SUPPORTED_LOCALES } from './locale-registry' export { SUPPORTED_LOCALES } from './locale-registry'
export type { SupportedLocale } from './locale-registry' export type { SupportedLocale } from './locale-registry'
import 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 FALLBACK_LOCALE: SupportedLocale = 'zh'
export const PREFERRED_LOCALE_STORAGE_KEY = 'preferredLocale' export const PREFERRED_LOCALE_STORAGE_KEY = 'preferredLocale'
export const EMPTY_LOCALE_MESSAGES = Object.fromEntries( export const EMPTY_LOCALE_MESSAGES = Object.fromEntries(
@@ -29,9 +29,6 @@ export const resolveSupportedLocale = (locale: string | null | undefined): Suppo
return null return null
} }
export const DEFAULT_LOCALE: SupportedLocale = resolveSupportedLocale(APP_CONFIG.DEFAULT_LANG)
|| FALLBACK_LOCALE
export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => { export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => {
if (!locale) return null if (!locale) return null
const normalizedLocale = locale.trim().toLowerCase() const normalizedLocale = locale.trim().toLowerCase()
@@ -138,4 +135,4 @@ const getLocaleAliasPath = (path: string, locale: SupportedLocale): string => {
} }
return getPathWithLocale(basePath, locale) return getPathWithLocale(basePath, locale)
} }
-5
View File
@@ -27,11 +27,6 @@ const router = createRouter({
alias: '/:lang/user', alias: '/:lang/user',
component: User component: User
}, },
{
path: '/redeem',
alias: '/:lang/redeem',
component: () => import('../views/Redeem.vue')
},
{ {
path: '/user/oauth2/callback', path: '/user/oauth2/callback',
alias: '/:lang/user/oauth2/callback', alias: '/:lang/user/oauth2/callback',
-3
View File
@@ -24,7 +24,6 @@ export const useGlobalState = createGlobalState(
disableAnonymousUserCreateEmail: false, disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false, disableCustomAddressName: false,
enableUserDeleteEmail: false, enableUserDeleteEmail: false,
enableMailReadStatus: false,
enableAutoReply: false, enableAutoReply: false,
enableIndexAbout: false, enableIndexAbout: false,
/** @type {string[]} */ /** @type {string[]} */
@@ -43,8 +42,6 @@ export const useGlobalState = createGlobalState(
disableAdminPasswordCheck: false, disableAdminPasswordCheck: false,
enableAddressPassword: false, enableAddressPassword: false,
enableAgentEmailInfo: false, enableAgentEmailInfo: false,
enableRedeemCode: false,
redeemCodeUrl: '',
smtpImapProxyConfig: { smtpImapProxyConfig: {
smtp: { smtp: {
host: '', host: '',
+1 -1
View File
@@ -1,7 +1,7 @@
import { useBreakpoint, useMemo } from 'vooks' import { useBreakpoint, useMemo } from 'vooks'
export function useIsMobile() { export function useIsMobile() {
const breakpointRef = useBreakpoint({ xs: 0, s: 768 }) const breakpointRef = useBreakpoint()
return useMemo(() => { return useMemo(() => {
return breakpointRef.value === 'xs' return breakpointRef.value === 'xs'
}) })
+6 -10
View File
@@ -30,7 +30,6 @@ import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue'; import WorkerConfig from './admin/WorkerConfig.vue';
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue'; import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
import AiExtractSettings from './admin/AiExtractSettings.vue'; import AiExtractSettings from './admin/AiExtractSettings.vue';
import RedeemCodes from './admin/RedeemCodes.vue';
const { const {
adminAuth, showAdminAuth, adminTab, loading, adminAuth, showAdminAuth, adminTab, loading,
@@ -107,7 +106,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div v-if="openSettings.fetched && userSettings.fetched"> <div v-if="userSettings.fetched">
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false" <n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')"> preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p> <p>{{ t('accessTip') }}</p>
@@ -125,7 +124,7 @@ onMounted(async () => {
<n-tab-pane name="database" :tab="t('database')"> <n-tab-pane name="database" :tab="t('database')">
<DatabaseManager /> <DatabaseManager />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')"> <n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings /> <AccountSettings />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')"> <n-tab-pane name="user_settings" :tab="t('user_settings')">
@@ -136,15 +135,15 @@ onMounted(async () => {
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="account" :tab="t('mailbox_management')"> <n-tab-pane name="account" :tab="t('account')">
<n-tabs key="account-tabs" type="bar" justify-content="center" animated> <n-tabs key="account-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="account" :tab="t('mailbox_list')"> <n-tab-pane name="account" :tab="t('account')">
<Account /> <Account />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="account_create" :tab="t('mailbox_create')"> <n-tab-pane name="account_create" :tab="t('account_create')">
<CreateAccount /> <CreateAccount />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')"> <n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings /> <AccountSettings />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')"> <n-tab-pane name="senderAccess" :tab="t('senderAccess')">
@@ -202,9 +201,6 @@ onMounted(async () => {
<n-tab-pane name="statistics" :tab="t('statistics')"> <n-tab-pane name="statistics" :tab="t('statistics')">
<Statistics /> <Statistics />
</n-tab-pane> </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-tab-pane name="maintenance" :tab="t('maintenance')">
<n-tabs key="maintenance-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')"> <n-tab-pane name="database" :tab="t('database')">
+10 -37
View File
@@ -9,7 +9,7 @@ import {
AdminPanelSettingsFilled, MonitorHeartFilled, AdminPanelSettingsFilled, MonitorHeartFilled,
KeyboardArrowDownOutlined, OpenInNewOutlined KeyboardArrowDownOutlined, OpenInNewOutlined
} from '@vicons/material' } from '@vicons/material'
import { Envelope, GithubAlt, Language, User } from '@vicons/fa' import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
import { api } from '../api' import { api } from '../api'
@@ -123,8 +123,8 @@ const menuOptions = computed(() => [
} }
}, },
{ {
default: () => t('mailbox'), default: () => t('home'),
icon: () => h(NIcon, { component: Envelope }) icon: () => h(NIcon, { component: Home })
}), }),
key: "home" key: "home"
}, },
@@ -142,7 +142,7 @@ const menuOptions = computed(() => [
} }
}, },
{ {
default: () => t('userCenter'), default: () => t('user'),
icon: () => h(NIcon, { component: User }), icon: () => h(NIcon, { component: User }),
} }
), ),
@@ -165,7 +165,7 @@ const menuOptions = computed(() => [
} }
}, },
{ {
default: () => t('admin'), default: () => "Admin",
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }), icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
} }
), ),
@@ -331,32 +331,6 @@ onMounted(async () => {
</template> </template>
<style scoped> <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 { .n-layout-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -462,7 +436,7 @@ onMounted(async () => {
@media (max-width: 640px) { @media (max-width: 640px) {
:deep(.n-page-header) { :deep(.n-page-header) {
padding: 10px 12px; padding: 10px;
} }
:deep(.n-page-header__title) { :deep(.n-page-header__title) {
@@ -470,11 +444,10 @@ onMounted(async () => {
} }
:deep(.n-page-header__title h3) { :deep(.n-page-header__title h3) {
max-width: calc(100vw - 104px); max-width: calc(100vw - 136px);
margin: 0; overflow: hidden;
font-size: clamp(16px, 5vw, 20px); text-overflow: ellipsis;
line-height: 1.2; white-space: nowrap;
} }
} }
</style> </style>
+3 -12
View File
@@ -45,14 +45,6 @@ const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' }); 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) => { const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' }); await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
}; };
@@ -124,7 +116,7 @@ onMounted(() => {
{{ t('enterSimpleMode') }} {{ t('enterSimpleMode') }}
</n-button> </n-button>
</template> </template>
<n-tab-pane name="mailbox" :tab="t('inbox')"> <n-tab-pane name="mailbox" :tab="t('mailbox')">
<div v-if="showMailIdQuery" style="margin-bottom: 10px;"> <div v-if="showMailIdQuery" style="margin-bottom: 10px;">
<n-input-group> <n-input-group>
<n-input v-model:value="mailIdQuery" /> <n-input v-model:value="mailIdQuery" />
@@ -135,8 +127,7 @@ onMounted(() => {
</div> </div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled" <MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :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>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')"> <n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" <SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
@@ -145,7 +136,7 @@ onMounted(() => {
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')"> <n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
<SendMail /> <SendMail />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('mailboxSettings')"> <n-tab-pane name="accountSettings" :tab="t('accountSettings')">
<AccountSettings /> <AccountSettings />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')"> <n-tab-pane name="appearance" :tab="t('appearance')">
-627
View File
@@ -1,627 +0,0 @@
<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>
+1 -9
View File
@@ -8,14 +8,12 @@ import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue'; import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue'; import BindAddress from './user/BindAddress.vue';
import UserMailBox from './user/UserMailBox.vue'; import UserMailBox from './user/UserMailBox.vue';
import UserSendBox from './user/UserSendBox.vue';
const { const {
userTab, globalTabplacement, userSettings, openSettings userTab, globalTabplacement, userSettings
} = useGlobalState() } = useGlobalState()
const { t } = useScopedI18n('views.User') const { t } = useScopedI18n('views.User')
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
</script> </script>
@@ -29,12 +27,6 @@ const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')"> <n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
<UserMailBox /> <UserMailBox />
</n-tab-pane> </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')"> <n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage /> <UserSettingsPage />
</n-tab-pane> </n-tab-pane>
+3 -3
View File
@@ -265,7 +265,7 @@ const columns = computed(() => [
sortOrder: sortBy.value === 'id' ? sortOrder.value : false sortOrder: sortBy.value === 'id' ? sortOrder.value : false
}, },
{ {
title: t('emailAddress'), title: t('name'),
key: "name", key: "name",
sorter: true, sorter: true,
sortOrder: sortBy.value === 'name' ? sortOrder.value : false sortOrder: sortBy.value === 'name' ? sortOrder.value : false
@@ -479,11 +479,11 @@ onMounted(async () => {
<div style="margin-top: 10px;"> <div style="margin-top: 10px;">
<AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress" <AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
:jwt="curEmailCredential" /> :jwt="curEmailCredential" />
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')"> <n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('deleteTip') }}</p> <p>{{ t('deleteTip') }}</p>
<template #action> <template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error"> <n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('deleteMailbox') }} {{ t('deleteAccount') }}
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
+10 -51
View File
@@ -23,40 +23,6 @@ const result = ref("")
const addressPassword = ref("") const addressPassword = ref("")
const createdAddress = 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(() => { const canUseRandomSubdomain = computed(() => {
if (!emailDomain.value) { if (!emailDomain.value) {
return false return false
@@ -115,22 +81,15 @@ onMounted(async () => {
<n-switch v-model:value="enablePrefix" :round="false" /> <n-switch v-model:value="enablePrefix" :round="false" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('address')"> <n-form-item-row :label="t('address')">
<n-spin :show="generateNameLoading" style="width: 100%;"> <n-input-group>
<div> <n-input-group-label v-if="enablePrefix && openSettings.prefix">
<n-button @click="generateName" style="margin-bottom: 10px;"> {{ openSettings.prefix }}
{{ t('generateName') }} </n-input-group-label>
</n-button> <n-input v-model:value="emailName" />
<n-input-group> <n-input-group-label>@</n-input-group-label>
<n-input-group-label v-if="enablePrefix && openSettings.prefix"> <n-select v-model:value="emailDomain" :consistent-menu-width="false"
{{ openSettings.prefix }} :options="openSettings.domains" />
</n-input-group-label> </n-input-group>
<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>
<n-form-item-row v-if="canUseRandomSubdomain"> <n-form-item-row v-if="canUseRandomSubdomain">
<div style="width: 100%;"> <div style="width: 100%;">
@@ -152,7 +111,7 @@ onMounted(async () => {
</n-form-item-row> </n-form-item-row>
<n-button @click="newEmail" type="primary" block :loading="loading" <n-button @click="newEmail" type="primary" block :loading="loading"
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()"> :disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
{{ t('createEmailAddress') }} {{ t('creatNewEmail') }}
</n-button> </n-button>
</n-card> </n-card>
</div> </div>
-493
View File
@@ -1,493 +0,0 @@
<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>
+62 -228
View File
@@ -2,20 +2,14 @@
import '@wangeditor/editor/dist/css/style.css' import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useScopedI18n } from '@/i18n/app' import { useScopedI18n } from '@/i18n/app'
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue' import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { useSessionStorage } from '@vueuse/core' import { useSessionStorage } from '@vueuse/core'
import { SendRound } from '@vicons/material'
import { api } from '../../api' 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 message = useMessage()
const isPreview = ref(false) const isPreview = ref(false)
const editorRef = shallowRef() const editorRef = shallowRef()
const sending = ref(false) const sending = ref(false)
const { autoLoadRemoteImages, isDark } = useGlobalState()
const sendMailModel = useSessionStorage('sendMailByAdminModel', { const sendMailModel = useSessionStorage('sendMailByAdminModel', {
fromName: "", fromName: "",
@@ -29,18 +23,11 @@ const sendMailModel = useSessionStorage('sendMailByAdminModel', {
const { t } = useScopedI18n('views.admin.SendMail') const { t } = useScopedI18n('views.admin.SendMail')
const contentTypes = computed(() => [ const contentTypes = [
{ label: t('text'), value: 'text' }, { label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' }, { label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' }, { 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) => { const normalizeSendMailText = (content) => {
return content return content
@@ -123,7 +110,6 @@ const send = async () => {
contentType: 'text', contentType: 'text',
content: "", content: "",
} }
isPreview.value = false
message.success(t("successSend")); message.success(t("successSend"));
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
@@ -160,230 +146,78 @@ const handleCreated = (editor) => {
</script> </script>
<template> <template>
<div class="composer-page"> <div class="center">
<n-card class="composer-card" :bordered="false" embedded> <n-card :bordered="false" embedded>
<template #header> <n-flex justify="end">
<div class="composer-title"> <n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
<h2>{{ t('composeMail') }}</h2> </n-flex>
<n-text depth="3">{{ t('adminComposeTip') }}</n-text> <div class="left">
</div> <n-form :model="sendMailModel">
</template> <n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-form class="composer-form" :model="sendMailModel" label-placement="top"> <n-input v-model:value="sendMailModel.fromName" />
<n-grid cols="1 m:2" responsive="screen" :x-gap="16"> <n-input v-model:value="sendMailModel.fromMail" />
<n-grid-item> </n-input-group>
<n-form-item :label="t('senderAddress')" required </n-form-item>
:label-props="{ for: 'admin-send-mail-sender-address' }"> <n-form-item :label="t('toName')" label-placement="top">
<n-input v-model:value="sendMailModel.fromMail" <n-input-group>
:input-props="{ id: 'admin-send-mail-sender-address' }" /> <n-input v-model:value="sendMailModel.toName" />
</n-form-item> <n-input v-model:value="sendMailModel.toMail" />
</n-grid-item> </n-input-group>
<n-grid-item> </n-form-item>
<n-form-item :label="t('senderName')" <n-form-item :label="t('subject')" label-placement="top">
:label-props="{ for: 'admin-send-mail-sender-name' }"> <n-input v-model:value="sendMailModel.subject" />
<n-input v-model:value="sendMailModel.fromName" </n-form-item>
:input-props="{ id: 'admin-send-mail-sender-name' }" /> <n-form-item :label="t('options')" label-placement="top">
</n-form-item> <n-radio-group v-model:value="sendMailModel.contentType">
</n-grid-item> <n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
<n-grid-item> :label="option.label" />
<n-form-item :label="t('recipientAddress')" required </n-radio-group>
:label-props="{ for: 'admin-send-mail-recipient-address' }"> <n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
<n-input v-model:value="sendMailModel.toMail" style="margin-left: 10px;">
:input-props="{ id: 'admin-send-mail-recipient-address' }" /> {{ isPreview ? t('edit') : t('preview') }}
</n-form-item> </n-button>
</n-grid-item> </n-form-item>
<n-grid-item> <n-form-item :label="t('content')" label-placement="top">
<n-form-item :label="t('recipientName')" <n-card :bordered="false" embedded v-if="isPreview">
:label-props="{ for: 'admin-send-mail-recipient-name' }"> <div v-html="sendMailModel.content" />
<n-input v-model:value="sendMailModel.toName" </n-card>
:input-props="{ id: 'admin-send-mail-recipient-name' }" /> <div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
</n-form-item> <Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
</n-grid-item> :editor="editorRef" mode="default" />
</n-grid> <Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
<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> </div>
</div> <n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview"> }" />
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" /> </n-form-item>
</div> </n-form>
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor"> </div>
<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> </n-card>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.composer-page { .n-card {
width: 100%; max-width: 800px;
padding: 14px 0 24px; }
.n-button {
text-align: left; text-align: left;
margin-right: 10px;
} }
.composer-card { .center {
width: min(900px, 100%);
margin: 0 auto;
}
.composer-title {
display: flex; 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;
}
.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; text-align: center;
place-items: center;
justify-content: center;
} }
.compose-preview { .left {
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; 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> </style>
@@ -32,7 +32,7 @@ const fetchData = async () => {
const columns = [ const columns = [
{ {
title: t('emailAddress'), title: t('name'),
key: "name" key: "name"
}, },
{ {
+1 -41
View File
@@ -2,7 +2,7 @@
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useScopedI18n } from '@/i18n/app' import { useScopedI18n } from '@/i18n/app'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { NewLabelOutlined, EmailOutlined, RedeemOutlined } from '@vicons/material' import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
import AdminContact from '../common/AdminContact.vue' import AdminContact from '../common/AdminContact.vue'
import Turnstile from '../../components/Turnstile.vue' import Turnstile from '../../components/Turnstile.vue'
@@ -192,10 +192,6 @@ const newEmail = async () => {
} }
}; };
const openRedeemPage = async () => {
await router.push(getRouterPathWithLang('/redeem', locale.value));
};
const addressPrefix = computed(() => { const addressPrefix = computed(() => {
// if user has role, return role prefix // if user has role, return role prefix
if (userSettings.value?.user_role) { if (userSettings.value?.user_role) {
@@ -302,16 +298,6 @@ onMounted(async () => {
</template> </template>
{{ t('getNewEmail') }} {{ t('getNewEmail') }}
</n-button> </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-form>
</n-tab-pane> </n-tab-pane>
<n-tab-pane v-if="showNewAddressTab" name="register" :tab="t('getNewEmail')"> <n-tab-pane v-if="showNewAddressTab" name="register" :tab="t('getNewEmail')">
@@ -364,17 +350,6 @@ onMounted(async () => {
</template> </template>
{{ t('getNewEmail') }} {{ t('getNewEmail') }}
</n-button> </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-form>
</n-spin> </n-spin>
</n-tab-pane> </n-tab-pane>
@@ -400,21 +375,6 @@ onMounted(async () => {
margin-top: 10px; 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 { .switch-login-button {
display: flex; display: flex;
justify-content: center; justify-content: center;
+3 -3
View File
@@ -113,7 +113,7 @@ const changePassword = async () => {
<n-divider v-if="openSettings.enableUserDeleteEmail" /> <n-divider v-if="openSettings.enableUserDeleteEmail" />
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary <n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
block strong> block strong>
{{ t('deleteMailbox') }} {{ t('deleteAccount') }}
</n-button> </n-button>
</n-card> </n-card>
@@ -125,11 +125,11 @@ const changePassword = async () => {
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')"> <n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('deleteAccountConfirm') }}</p> <p>{{ t('deleteAccountConfirm') }}</p>
<template #action> <template #action>
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error"> <n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
{{ t('deleteMailbox') }} {{ t('deleteAccount') }}
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
+1 -1
View File
@@ -71,7 +71,7 @@ onMounted(async () => {
<template #icon> <template #icon>
<n-icon :component="User" /> <n-icon :component="User" />
</template> </template>
{{ t('userCenter') }} {{ t('userLogin') }}
</n-button> </n-button>
</n-card> </n-card>
</div> </div>
+76 -287
View File
@@ -2,15 +2,11 @@
import '@wangeditor/editor/dist/css/style.css' import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useScopedI18n } from '@/i18n/app' import { useScopedI18n } from '@/i18n/app'
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue' import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
import { SendRound } from '@vicons/material'
import AdminContact from '../common/AdminContact.vue' import AdminContact from '../common/AdminContact.vue'
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import { blockRemoteContent } from '../../utils/remote-content-policy'
import { sanitizeHtml } from '../../utils/sanitize-html'
const message = useMessage() const message = useMessage()
const isPreview = ref(false) const isPreview = ref(false)
@@ -18,25 +14,15 @@ const editorRef = shallowRef()
const sending = ref(false) const sending = ref(false)
const { const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
settings, sendMailModel, indexTab, userSettings,
autoLoadRemoteImages, isDark,
} = useGlobalState()
const { t } = useScopedI18n('views.index.SendMail') const { t } = useScopedI18n('views.index.SendMail')
const contentTypes = computed(() => [ const contentTypes = [
{ label: t('text'), value: 'text' }, { label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' }, { label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' }, { 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) => { const normalizeSendMailText = (content) => {
return content return content
@@ -171,292 +157,95 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="composer-page" v-if="settings.address"> <div class="center" v-if="settings.address">
<n-card class="composer-card" :bordered="false" embedded> <n-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 v-if="!settings.send_balance || settings.send_balance <= 0">
<div class="access-state"> <n-alert type="warning" :show-icon="false" :bordered="false">
<div class="access-copy"> {{ t('requestAccessTip', { address: settings.address }) }}
<h3>{{ t('balanceUnavailable') }}</h3> <n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p> }}</n-button>
</div> </n-alert>
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button> <AdminContact />
</div>
<div class="admin-contact"><AdminContact /></div>
</div> </div>
<div v-else>
<template v-else> <n-alert type="info" :show-icon="false" :bordered="false" closable>
<n-form class="composer-form" :model="sendMailModel" label-placement="top"> {{ t('send_balance') }}: {{ settings.send_balance }}
<n-grid cols="1 m:2" responsive="screen" :x-gap="16"> </n-alert>
<n-grid-item> <n-flex justify="end">
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }"> <n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
<n-input :value="settings.address" readonly </n-flex>
:input-props="{ id: 'send-mail-sender-address' }" /> <div class="left">
</n-form-item> <n-form :model="sendMailModel">
</n-grid-item> <n-form-item :label="t('fromName')" label-placement="top">
<n-grid-item> <n-input-group>
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }"> <n-input v-model:value="sendMailModel.fromName" />
<n-input v-model:value="sendMailModel.fromName" <n-input :value="settings.address" disabled />
:input-props="{ id: 'send-mail-sender-name' }" /> </n-input-group>
</n-form-item> </n-form-item>
</n-grid-item> <n-form-item :label="t('toName')" label-placement="top">
<n-grid-item> <n-input-group>
<n-form-item :label="t('recipientAddress')" required <n-input v-model:value="sendMailModel.toName" />
:label-props="{ for: 'send-mail-recipient-address' }"> <n-input v-model:value="sendMailModel.toMail" />
<n-input v-model:value="sendMailModel.toMail" </n-input-group>
:input-props="{ id: 'send-mail-recipient-address' }" /> </n-form-item>
</n-form-item> <n-form-item :label="t('subject')" label-placement="top">
</n-grid-item> <n-input v-model:value="sendMailModel.subject" />
<n-grid-item> </n-form-item>
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }"> <n-form-item :label="t('options')" label-placement="top">
<n-input v-model:value="sendMailModel.toName" <n-radio-group v-model:value="sendMailModel.contentType">
:input-props="{ id: 'send-mail-recipient-name' }" /> <n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
</n-form-item> :label="option.label" />
</n-grid-item> </n-radio-group>
</n-grid> <n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }"> {{ isPreview ? t('edit') : t('preview') }}
<n-input v-model:value="sendMailModel.subject" </n-button>
:input-props="{ id: 'send-mail-subject' }" /> </n-form-item>
</n-form-item> <n-form-item :label="t('content')" label-placement="top">
<n-card :bordered="false" embedded v-if="isPreview">
<div class="editor-panel"> <div v-html="sendMailModel.content" />
<div class="editor-panel-header"> </n-card>
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span <div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
class="required-mark">*</span></n-text> <Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
<div class="editor-controls"> :editor="editorRef" mode="default" />
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType" <Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
size="small" aria-labelledby="send-mail-content-label"> :defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
<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> <n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview"> }" />
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" /> </n-form-item>
</div> </n-form>
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor"> </div>
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" /> </div>
<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> </n-card>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.composer-page { .n-card {
width: 100%; max-width: 800px;
padding: 14px 0 24px; }
.n-button {
text-align: left; text-align: left;
margin-right: 10px;
} }
.composer-card { .center {
width: min(900px, 100%);
margin: 0 auto;
}
.composer-title {
display: flex; 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; text-align: center;
place-items: center;
justify-content: center;
} }
.compose-preview { .left {
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; text-align: left;
place-items: left;
justify-content: left;
} }
.composer-form :deep(.n-input__input-el) { .n-alert {
text-align: left; margin-bottom: 10px;
}
.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> </style>
+3 -41
View File
@@ -56,24 +56,6 @@ 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 () => { const deleteMail = async () => {
if (!currentMail.value) return; if (!currentMail.value) return;
@@ -188,7 +170,7 @@ onBeforeUnmount(() => {
<SettingsFilled /> <SettingsFilled />
</n-icon> </n-icon>
</template> </template>
{{ t('mailboxSettings') }} {{ t('accountSettings') }}
</n-button> </n-button>
</n-flex> </n-flex>
<div v-if="isFirstPage" style="text-align: center; margin-top: 12px;"> <div v-if="isFirstPage" style="text-align: center; margin-top: 12px;">
@@ -200,7 +182,7 @@ onBeforeUnmount(() => {
<!-- 账户设置卡片 --> <!-- 账户设置卡片 -->
<n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable <n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable
@close="showAccountSettingsCard = false" :title="t('mailboxSettings')"> @close="showAccountSettingsCard = false" :title="t('accountSettings')">
<AccountSettings /> <AccountSettings />
</n-card> </n-card>
@@ -234,15 +216,10 @@ onBeforeUnmount(() => {
<n-empty :description="t('noMails')" /> <n-empty :description="t('noMails')" />
</div> </div>
<div v-else> <div v-else>
<h3 v-if="currentMail.subject" <h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
:class="{ 'mail-title-unread': openSettings.enableMailReadStatus && currentMail.is_unread === 1 }">
{{ currentMail.subject }}
</h3>
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false" <MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:enableMailReadStatus="openSettings.enableMailReadStatus"
:onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" /> :onDelete="deleteMail" />
</div> </div>
</div> </div>
@@ -269,19 +246,4 @@ onBeforeUnmount(() => {
margin-top: 20px; margin-top: 20px;
width: 100%; 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> </style>
+4 -29
View File
@@ -7,7 +7,6 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils' import { getRouterPathWithLang } from '../../utils'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
import Login from '../common/Login.vue'; import Login from '../common/Login.vue';
@@ -16,7 +15,6 @@ const message = useMessage()
const router = useRouter() const router = useRouter()
const { locale, t } = useScopedI18n('views.user.AddressManagement') const { locale, t } = useScopedI18n('views.user.AddressManagement')
const { t: credentialT } = useScopedI18n('components.AddressCredentialModal')
const data = ref([]) const data = ref([])
const count = ref(0) const count = ref(0)
@@ -26,24 +24,11 @@ const showTranferAddress = ref(false)
const currentAddress = ref("") const currentAddress = ref("")
const currentAddressId = ref(0) const currentAddressId = ref(0)
const targetUserEmail = ref('') 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) => { const changeMailAddress = async (address_id) => {
try { try {
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`); const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
message.success(t('changeMailAddress') + " " + t('success'));
if (!res.jwt) { if (!res.jwt) {
message.error("jwt not found"); message.error("jwt not found");
return; return;
@@ -129,7 +114,7 @@ const fetchData = async () => {
const columns = [ const columns = [
{ {
title: t('emailAddress'), title: t('name'),
key: "name" key: "name"
}, },
{ {
@@ -161,14 +146,6 @@ const columns = [
key: 'actions', key: 'actions',
render(row) { render(row) {
return h('div', [ return h('div', [
h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => showCredential(row)
},
{ default: () => credentialT('addressCredential') }
),
h(NPopconfirm, h(NPopconfirm,
{ {
onPositiveClick: () => changeMailAddress(row.id) onPositiveClick: () => changeMailAddress(row.id)
@@ -179,9 +156,9 @@ const columns = [
tertiary: true, tertiary: true,
type: "primary", type: "primary",
}, },
{ default: () => t('openMailbox') } { default: () => t('changeMailAddress') }
), ),
default: () => `${t('openMailbox')}?` default: () => `${t('changeMailAddress')}?`
} }
), ),
h(NButton, h(NButton,
@@ -227,8 +204,6 @@ watch([page, pageSize], async () => {
<template> <template>
<div> <div>
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
:jwt="currentAddressCredential" />
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')"> <n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
<span> <span>
<p>{{ t("transferAddressTip") }}</p> <p>{{ t("transferAddressTip") }}</p>
-491
View File
@@ -1,491 +0,0 @@
<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
@@ -1,135 +0,0 @@
<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,7 +5,6 @@ const API_PATHS = [
"/admin/", "/admin/",
"/telegram/", "/telegram/",
"/external/", "/external/",
"/redeem_api/",
]; ];
export async function onRequest(context) { export async function onRequest(context) {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "temp-email-pages", "name": "temp-email-pages",
"version": "1.12.0", "version": "1.11.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -11,7 +11,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"wrangler": "^4.129.0" "wrangler": "^4.124.0"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
-2
View File
@@ -140,7 +140,6 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
collapsed: false, collapsed: false,
items: [ items: [
{ text: 'Worker Variables', link: 'worker-vars' }, { text: 'Worker Variables', link: 'worker-vars' },
{ text: 'Frontend Variables', link: 'frontend-vars' },
{ text: 'Configure Email Sending', link: 'config-send-mail' }, { text: 'Configure Email Sending', link: 'config-send-mail' },
] ]
}, },
@@ -170,7 +169,6 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' }, { text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' }, { text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' }, { 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' }, { text: 'Add Google Ads', link: 'feature/google-ads.md' },
] ]
}, },
-2
View File
@@ -140,7 +140,6 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
collapsed: false, collapsed: false,
items: [ items: [
{ text: 'Worker 变量说明', link: 'worker-vars' }, { text: 'Worker 变量说明', link: 'worker-vars' },
{ text: '前端变量说明', link: 'frontend-vars' },
{ text: '配置发送邮件', link: 'config-send-mail' }, { text: '配置发送邮件', link: 'config-send-mail' },
] ]
}, },
@@ -170,7 +169,6 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: '配置 S3 附件', link: 'feature/s3-attachment' }, { text: '配置 S3 附件', link: 'feature/s3-attachment' },
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' }, { text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
{ text: '配置其他 worker 增强', link: 'feature/another-worker-enhanced' }, { text: '配置其他 worker 增强', link: 'feature/another-worker-enhanced' },
{ text: '配置兑换码', link: 'feature/redeem-code' },
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' }, { 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 | | 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 [Frontend Variables](/en/guide/frontend-vars) | | `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_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_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` | | `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` | | `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,12 +29,6 @@ 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` 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 ```bash
pnpm build --emptyOutDir pnpm build --emptyOutDir
# The first deployment will prompt you to create a project, for production branch enter production # The first deployment will prompt you to create a project, for production branch enter production
@@ -53,8 +47,6 @@ 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`. 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 ```bash
cd frontend cd frontend
pnpm install pnpm install
@@ -67,8 +67,6 @@ JWT_SECRET = "xxx"
# Admin console password, if not configured, console access is not allowed # Admin console password, if not configured, console access is not allowed
# ADMIN_PASSWORDS = ["123", "456"] # 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 # Whether to allow users to create emails, not allowed if not configured
ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_CREATE_EMAIL = true
@@ -150,20 +150,19 @@ cd worker
wrangler secret put SMTP_CONFIG wrangler secret put SMTP_CONFIG
``` ```
## User Accounts, Mailboxes, Email Addresses, and Send Permission ## User Accounts, Email Addresses, and Send Permission
These concepts are related, but they identify different objects: These concepts are related, but they identify different objects:
| Concept | Description | | 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 | | User account | Signs in to the user center and can bind and manage multiple email addresses. The user account's login 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 identity that actually receives and sends mail, such as `name@example.com`. The address currently selected on the frontend home page is authenticated by its Address JWT |
| 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 | | 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: 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 1. Switch to the email address that needs to send mail
2. Open the **Send Mail** page 2. Open the **Send Mail** page
3. Click **Request Access** 3. Click **Request Access**
@@ -4,31 +4,31 @@
> You need to configure `ADMIN_PASSWORDS` or `ADMIN_USER_ROLE` to access the admin console > 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 > 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 Admin Console. After deploying the frontend application, click the upper-left logo 5 times or visit the `/admin` path to enter the management 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. 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 vs User Accounts
`ADMIN_PASSWORDS` is the password for the Admin Console. It is not a site user account `ADMIN_PASSWORDS` is the management 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 and does not correspond to any mailbox address. Logging in with an admin password grants access to
the console, but that login itself cannot receive mail. 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 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. Creating a normal user receive mail depends on whether they created or bound a mailbox address. Creating a normal user
whose account email is `admin@example.com` does not automatically grant admin permissions. whose email looks like `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 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. same role to that user in user management.
![admin](/feature/admin.png) ![admin](/feature/admin.png)
## Mailbox List Sorting ## Account List Sorting
**Admin Console → Mailbox Management → Mailboxes** supports column sorting. Click a column header to toggle ascending/descending order for: The Accounts tab in the admin console supports column sorting. Click the column header to toggle ascending/descending order for:
- ID - ID
- Email Address - Name
- Created At - Created At
- Updated At - Updated At
- Mail Count - Mail Count
@@ -1,17 +1,17 @@
# Adding Google Ads to Your Website # Adding Google Ads to Your Website
Google Ads uses the following two variables. See [Google AdSense](https://www.google.com/adsense/start/) for the values: ## Command Line Deployment
```ini Modify the `.env.prod` file
Add the following two variables, refer to [Google AdSense](https://www.google.com/adsense/start/) for specific values
```txt
VITE_GOOGLE_AD_CLIENT=ca-pub-123456 VITE_GOOGLE_AD_CLIENT=ca-pub-123456
VITE_GOOGLE_AD_SLOT=123456 VITE_GOOGLE_AD_SLOT=123456
``` ```
Ads are not loaded when either variable is empty. See [Frontend Variables](/en/guide/frontend-vars) for their types and defaults. Then execute the following commands to redeploy pages.
## CLI Deployment
Add both variables to `frontend/.env.prod`, then rebuild and deploy:
```bash ```bash
pnpm build --emptyOutDir pnpm build --emptyOutDir
@@ -19,25 +19,11 @@ pnpm build --emptyOutDir
pnpm run deploy pnpm run deploy
``` ```
See [CLI Frontend Deployment](/en/guide/cli/pages) for the complete steps. ## GitHub Action Deployment
## GitHub Actions Deployment Modify `FRONTEND_ENV`, add the following two variables, refer to [Google AdSense](https://www.google.com/adsense/start/) for specific values, then redeploy pages.
Add both variables to the existing `FRONTEND_ENV` secret, then run the `Deploy Frontend` workflow again. ```txt
VITE_GOOGLE_AD_CLIENT=ca-pub-123456
See [GitHub Actions Deployment](/en/guide/actions/github-action) for the complete steps. VITE_GOOGLE_AD_SLOT=123456
## 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.

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