mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-10 09:57:01 +08:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
288634c774 | ||
|
|
066fcfc8c2 | ||
|
|
f88852a353 | ||
|
|
0a0a56e653 | ||
|
|
4ddd502a60 | ||
|
|
406d1956b8 | ||
|
|
66ab5a05df | ||
|
|
8e1bfb8af2 | ||
|
|
fc363cc9c5 | ||
|
|
806ec1aeab | ||
|
|
3c505db9d0 | ||
|
|
5fd181d9c0 | ||
|
|
70206c61ef | ||
|
|
f92b059aac | ||
|
|
5dbb6107dd | ||
|
|
dccca92928 | ||
|
|
005d74bfde |
+22
-2
@@ -6,21 +6,41 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.11.1(main)
|
||||
## v1.12.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |邮箱登录| 新增仅密码登录开关以禁用旧凭据,邮箱登录 JWT 有效期 30 天、低于 7 天自动续期,支持用户重置已绑定邮箱的密码
|
||||
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
|
||||
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
|
||||
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
|
||||
- feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态
|
||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126)
|
||||
- feat: |Admin API| 新增 `ADMIN_API_IP_WHITELIST`,可按来源 IP 限制所有管理接口访问
|
||||
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |邮箱登录| 本地邮箱使用后端 settings 信息缓存两种登录方式,无需前端解码 JWT;Telegram 内部绑定使用不过期 token,与网页邮箱登录 JWT 分离
|
||||
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码
|
||||
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性
|
||||
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
|
||||
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
||||
- fix: |用户发信| 用户地址发信接口支持角色无限额度
|
||||
|
||||
### Improvements
|
||||
|
||||
- fix: |Frontend| 统一邮箱、邮箱地址、用户账号与管理后台的核心界面术语,并补齐附加语言缺失的邮件状态与远程图片文案(issue #1129)
|
||||
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |邮箱登录| 覆盖密码登录 JWT 有效期与续期、非法 token 类型,以及用户重置绑定邮箱密码的权限检查
|
||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||
|
||||
## v1.11.0
|
||||
|
||||
@@ -226,7 +246,7 @@
|
||||
|
||||
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
|
||||
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
|
||||
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
|
||||
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点
|
||||
|
||||
### Improvements
|
||||
|
||||
|
||||
+22
-2
@@ -6,21 +6,41 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.11.1(main)
|
||||
## v1.12.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Mailbox Login| Add a password-only switch that rejects legacy credentials, 30-day mailbox login JWTs renewed with less than 7 days remaining, and password reset for bound mailboxes
|
||||
- feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes
|
||||
- feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html`
|
||||
- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation
|
||||
- feat: |Mail| Add optional read/unread status with click-to-read and manual status switching
|
||||
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||
- feat: |Admin| Add one-click random email-name generation to the address creation page (issue #1126)
|
||||
- feat: |Admin API| Add `ADMIN_API_IP_WHITELIST` to restrict all admin endpoints by source IP
|
||||
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Mailbox Login| Cache both login methods using backend settings without decoding JWTs in the frontend; use non-expiring Telegram binding tokens independently of web mailbox login JWTs
|
||||
- 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: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder
|
||||
- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs
|
||||
|
||||
### Improvements
|
||||
|
||||
- fix: |Frontend| Unify core mailbox, email-address, user-account, and Admin Console terminology, and fill missing additional-locale strings for mail status and remote images (issue #1129)
|
||||
- feat: |Send Mail| Improve the information hierarchy and responsive layout of the user and Admin composers, with a content-format toolbar, draft status, bottom send-action area, and isolated HTML preview
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |Mailbox Login| Cover password JWT lifetime and renewal, invalid token types, and authorization for bound mailbox password resets
|
||||
- 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
|
||||
|
||||
@@ -226,7 +246,7 @@
|
||||
|
||||
- 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: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
|
||||
- test: |Worker| Add `/admin/test/seed_mail` test endpoint
|
||||
|
||||
### Improvements
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS redeem_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
redeem_type TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
result TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
|
||||
expires_at DATETIME NOT NULL,
|
||||
redeemed_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
|
||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
raw TEXT,
|
||||
raw_blob BLOB,
|
||||
metadata TEXT,
|
||||
is_unread INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -119,3 +120,19 @@ CREATE TABLE IF NOT EXISTS user_passkeys (
|
||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS redeem_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
redeem_type TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
result TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
|
||||
expires_at DATETIME NOT NULL,
|
||||
redeemed_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Keep this version in sync with @playwright/test in package.json
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
|
||||
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ config.server = {\
|
||||
"/admin": { target: workerTarget, changeOrigin: true },\
|
||||
"/user_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/open_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/redeem_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/external": { target: workerTarget, changeOrigin: true },\
|
||||
"/health_check": { target: workerTarget, changeOrigin: true },\
|
||||
},\
|
||||
|
||||
@@ -10,10 +10,12 @@ COPY worker/patches/ patches/
|
||||
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
||||
|
||||
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 ./
|
||||
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
||||
COPY ${WRANGLER_TOML} wrangler.toml
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
|
||||
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0", "--test-scheduled"]
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ Test results and HTML reports are exported via volumes:
|
||||
## Configuration
|
||||
|
||||
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
|
||||
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
|
||||
- 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.
|
||||
|
||||
+38
-1
@@ -42,7 +42,7 @@ services:
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8790:8790"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0", "--test-scheduled"]
|
||||
volumes:
|
||||
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
||||
depends_on:
|
||||
@@ -90,6 +90,24 @@ services:
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-site-password:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
args:
|
||||
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.site-password
|
||||
ports:
|
||||
- "8792:8792"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8792", "--ip", "0.0.0.0"]
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "-H", "x-custom-auth: e2e-site-pass", "http://localhost:8792/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
@@ -100,6 +118,19 @@ services:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
|
||||
frontend-env-off:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.frontend
|
||||
ports:
|
||||
- "5174:5174"
|
||||
command: ["pnpm", "exec", "vite", "--port", "5174", "--host", "0.0.0.0"]
|
||||
environment:
|
||||
VITE_WORKER_URL: http://worker-env-off:8790
|
||||
depends_on:
|
||||
worker-env-off:
|
||||
condition: service_healthy
|
||||
|
||||
smtp-proxy:
|
||||
build:
|
||||
context: ../smtp_proxy_server
|
||||
@@ -147,7 +178,9 @@ services:
|
||||
WORKER_URL_ENV_OFF: http://worker-env-off:8790
|
||||
WORKER_GZIP_URL: http://worker-gzip:8788
|
||||
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
|
||||
WORKER_URL_SITE_PASSWORD: http://worker-site-password:8792
|
||||
FRONTEND_URL: https://frontend:5173
|
||||
FRONTEND_URL_ENV_OFF: https://frontend-env-off:5174
|
||||
MAILPIT_API: http://mailpit:8025/api
|
||||
SMTP_PROXY_HOST: smtp-proxy
|
||||
SMTP_PROXY_SMTP_PORT: "8025"
|
||||
@@ -167,8 +200,12 @@ services:
|
||||
condition: service_healthy
|
||||
worker-send-mail-domain:
|
||||
condition: service_healthy
|
||||
worker-site-password:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
frontend-env-off:
|
||||
condition: service_started
|
||||
smtp-proxy:
|
||||
condition: service_started
|
||||
smtp-proxy-tls:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export class EmailMessage {
|
||||
constructor(public from: string, public to: string, public raw: string) {}
|
||||
}
|
||||
@@ -1,37 +1,41 @@
|
||||
import { Context } from 'hono'
|
||||
import { getBooleanValue } from '../utils'
|
||||
|
||||
// Direct DB insert — bypasses the email() handler.
|
||||
const seedMail = async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
|
||||
return c.text("Not available", 404);
|
||||
}
|
||||
const { address, source, raw, message_id } = await c.req.json();
|
||||
const seedMail = async (request: Request, env: Bindings) => {
|
||||
const { address, source, raw, message_id, created_at, address_updated_at, address_created_at } = await request.json<{
|
||||
address: string; source?: string; raw: string; message_id?: string;
|
||||
created_at?: string; address_updated_at?: string; address_created_at?: string;
|
||||
}>();
|
||||
if (!address || !raw) {
|
||||
return c.text("address and raw are required", 400);
|
||||
return new Response("address and raw are required", { status: 400 });
|
||||
}
|
||||
if (raw.length > 1_000_000) {
|
||||
return c.text("raw content too large", 400);
|
||||
return new Response("raw content too large", { status: 400 });
|
||||
}
|
||||
if (message_id && message_id.length > 255) {
|
||||
return c.text("message_id too long", 400);
|
||||
return new Response("message_id too long", { status: 400 });
|
||||
}
|
||||
if (address_updated_at !== undefined) {
|
||||
await env.DB.prepare(`UPDATE address SET updated_at = ? WHERE name = ?`)
|
||||
.bind(address_updated_at, address).run();
|
||||
}
|
||||
if (address_created_at !== undefined) {
|
||||
await env.DB.prepare(`UPDATE address SET created_at = ? WHERE name = ?`)
|
||||
.bind(address_created_at, address).run();
|
||||
}
|
||||
const msgId = message_id || `<e2e-${Date.now()}@test>`;
|
||||
const { success } = await c.env.DB.prepare(
|
||||
const { success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (message_id, source, address, raw, created_at)`
|
||||
+ ` VALUES (?, ?, ?, ?, datetime('now'))`
|
||||
).bind(msgId, source || address, address, raw).run();
|
||||
return c.json({ success });
|
||||
+ ` VALUES (?, ?, ?, ?, COALESCE(?, datetime('now')))`
|
||||
).bind(msgId, source || address, address, raw, created_at ?? null).run();
|
||||
return Response.json({ success });
|
||||
};
|
||||
|
||||
// Exercises the real email() handler with a mock ForwardableEmailMessage.
|
||||
const receiveMail = async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
|
||||
return c.text("Not available", 404);
|
||||
}
|
||||
const { from, to, raw, ai_extract_result } = await c.req.json();
|
||||
const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => {
|
||||
const { from, to, raw, ai_extract_result } = await request.json<{
|
||||
from: string; to: string; raw: string; ai_extract_result?: unknown;
|
||||
}>();
|
||||
if (!from || !to || !raw) {
|
||||
return c.text("from, to and raw are required", 400);
|
||||
return new Response("from, to and raw are required", { status: 400 });
|
||||
}
|
||||
|
||||
// Parse MIME headers (unfold continuation lines, extract key:value pairs)
|
||||
@@ -53,24 +57,19 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
|
||||
forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; },
|
||||
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
|
||||
};
|
||||
const { email: emailHandler } = await import('../email');
|
||||
const { email: emailHandler } = await import('../../worker/src/email');
|
||||
const aiExtractEnvOverrides: Partial<Bindings> = {
|
||||
ENABLE_AI_EMAIL_EXTRACT: true,
|
||||
AI: {
|
||||
run: async () => ({ response: ai_extract_result })
|
||||
} as unknown as Ai,
|
||||
};
|
||||
const env = ai_extract_result
|
||||
? { ...c.env, ...aiExtractEnvOverrides }
|
||||
: c.env;
|
||||
const executionContext: ExecutionContext = {
|
||||
waitUntil: () => {},
|
||||
passThroughOnException: () => {},
|
||||
props: {}
|
||||
};
|
||||
await emailHandler(mockMessage, env, executionContext);
|
||||
const emailEnv = ai_extract_result
|
||||
? { ...env, ...aiExtractEnvOverrides }
|
||||
: env;
|
||||
await emailHandler(mockMessage, emailEnv, ctx);
|
||||
|
||||
return c.json({
|
||||
return Response.json({
|
||||
success: !state.rejected,
|
||||
replyCalled: state.replyCalled,
|
||||
forwardedTo: state.forwardedTo,
|
||||
@@ -7,7 +7,9 @@ export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
|
||||
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
|
||||
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
|
||||
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || '';
|
||||
export const WORKER_URL_SITE_PASSWORD = process.env.WORKER_URL_SITE_PASSWORD || '';
|
||||
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||
export const FRONTEND_URL_ENV_OFF = process.env.FRONTEND_URL_ENV_OFF || '';
|
||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||
export const TEST_DOMAIN = 'test.example.com';
|
||||
|
||||
@@ -27,10 +29,11 @@ export function hashPassword(password: string): string {
|
||||
export async function createTestAddress(
|
||||
ctx: APIRequestContext,
|
||||
name: string,
|
||||
domain: string = TEST_DOMAIN
|
||||
domain: string = TEST_DOMAIN,
|
||||
workerUrl: string = WORKER_URL,
|
||||
): Promise<{ jwt: string; address: string; address_id: number }> {
|
||||
const uniqueName = `${name}${Date.now()}`;
|
||||
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
|
||||
const res = await ctx.post(`${workerUrl}/api/new_address`, {
|
||||
data: { name: uniqueName, domain },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
@@ -75,7 +78,7 @@ export async function seedTestMail(
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
@@ -164,14 +167,20 @@ export function onMailpitMessage(
|
||||
ws.on('open', () => readyResolve());
|
||||
|
||||
ws.on('message', (data: WebSocket.Data) => {
|
||||
// Mailpit may batch multiple newline-separated events in one message.
|
||||
for (const line of data.toString().split('\n')) {
|
||||
if (settled) return;
|
||||
try {
|
||||
const event = JSON.parse(data.toString());
|
||||
const event = JSON.parse(line);
|
||||
if (event.Type === 'new' && predicate(event.Data)) {
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
ws.close();
|
||||
if (!settled) { settled = true; resolve(event.Data); }
|
||||
resolve(event.Data);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore malformed events without dropping the remaining ones */ }
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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 };
|
||||
@@ -1,5 +1,5 @@
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.ts"
|
||||
main = "../e2e/fixtures/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
@@ -15,16 +15,21 @@ USER_ROLES = [
|
||||
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
||||
]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_MAIL_READ_STATUS = true
|
||||
ENABLE_REDEEM_CODE = true
|
||||
REDEEM_CODE_URL = "https://example.com/redeem-codes"
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
NO_LIMIT_SEND_ROLE = "case-role"
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_USER_ROLE = "admin"
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
CLEANUP_BATCH_SIZE = 10
|
||||
SMTP_CONFIG = """
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
@@ -38,3 +43,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email"
|
||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[alias]
|
||||
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name = "cloudflare_temp_email_env_off"
|
||||
main = "src/worker.ts"
|
||||
main = "../e2e/fixtures/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
@@ -13,13 +13,14 @@ JWT_SECRET = "e2e-test-secret-key-env-off"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = false
|
||||
DISABLE_ADDRESS_UPDATED_AT = true
|
||||
ENABLE_REDEEM_CODE = false
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
@@ -32,3 +33,6 @@ id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-env-off"
|
||||
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[alias]
|
||||
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name = "cloudflare_temp_email_gzip"
|
||||
main = "src/worker.ts"
|
||||
main = "../e2e/fixtures/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
@@ -18,7 +18,6 @@ ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
ENABLE_MAIL_GZIP = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
@@ -32,3 +31,6 @@ id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-gzip"
|
||||
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"
|
||||
main = "src/worker.ts"
|
||||
main = "../e2e/fixtures/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
@@ -23,7 +23,6 @@ ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
@@ -36,3 +35,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email"
|
||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[alias]
|
||||
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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"
|
||||
Generated
+42
-60
@@ -6,13 +6,13 @@
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.7.1",
|
||||
"nodemailer": "^9.0.5"
|
||||
"imapflow": "^1.7.8",
|
||||
"nodemailer": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"hono": "^4.13.7",
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
},
|
||||
@@ -23,13 +23,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -48,16 +48,6 @@
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -69,13 +59,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.15.tgz",
|
||||
"integrity": "sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==",
|
||||
"version": "5.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz",
|
||||
"integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.4.2",
|
||||
"libmime": "5.4.3",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
@@ -89,27 +79,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz",
|
||||
"integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
|
||||
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
@@ -129,16 +114,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.1.tgz",
|
||||
"integrity": "sha512-etpyRH7wOPQGawjl/5ChgaaX5NRK2fxJAi9EFFNwv0N3FXsvjsL3Qu5dJ4i/myH61Cq2RSTb6vI5T9rXDbXD+g==",
|
||||
"version": "1.7.8",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz",
|
||||
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.15",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"@zone-eu/mailsplit": "5.4.16",
|
||||
"encoding-japanese": "2.3.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.4.2",
|
||||
"libmime": "5.4.3",
|
||||
"libqp": "2.1.1",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.9"
|
||||
@@ -160,12 +145,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.2.tgz",
|
||||
"integrity": "sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==",
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz",
|
||||
"integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"encoding-japanese": "2.3.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
@@ -178,12 +163,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz",
|
||||
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
@@ -233,28 +218,25 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+5
-4
@@ -3,17 +3,18 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:unit": "node --experimental-strip-types --test tests/unit/*.test.mjs",
|
||||
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
|
||||
"test:down": "docker compose down -v"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"hono": "^4.13.7",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.7.1",
|
||||
"nodemailer": "^9.0.5"
|
||||
"imapflow": "^1.7.8",
|
||||
"nodemailer": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,21 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then
|
||||
echo "==> Waiting for site-password worker at $WORKER_URL_SITE_PASSWORD ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" "$WORKER_URL_SITE_PASSWORD/health_check" > /dev/null 2>&1; then
|
||||
echo " Site-password worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Site-password worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
||||
@@ -72,6 +87,21 @@ for i in $(seq 1 60); do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -n "${FRONTEND_URL_ENV_OFF:-}" ]; then
|
||||
echo "==> Waiting for env-off frontend at $FRONTEND_URL_ENV_OFF ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl --connect-timeout 5 --max-time 10 -skf "$FRONTEND_URL_ENV_OFF" > /dev/null 2>&1; then
|
||||
echo " Env-off frontend ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Env-off frontend not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
|
||||
for i in $(seq 1 30); do
|
||||
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
|
||||
@@ -110,5 +140,13 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
||||
echo " Gzip worker database initialized"
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then
|
||||
echo "==> Initializing site-password worker database"
|
||||
curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_initialize" > /dev/null
|
||||
curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_migration" > /dev/null
|
||||
echo " Site-password worker database initialized"
|
||||
fi
|
||||
|
||||
echo "==> Running Playwright tests"
|
||||
npm run test:unit
|
||||
exec npx playwright test "$@"
|
||||
|
||||
@@ -48,7 +48,7 @@ async function receiveGzipMail(
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
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',
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/seed_mail`, {
|
||||
data: { address, source: from, raw, message_id: messageId },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
WORKER_URL_ENV_OFF,
|
||||
onMailpitMessage,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
@@ -77,3 +79,325 @@ test.describe('Address activity throttling', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const OLD = '2020-01-01 00:00:00';
|
||||
const RECENT = new Date(Date.now() - 3_600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
type Mailbox = { address: string; address_id: number; jwt: string; password: string };
|
||||
type User = { id: number; email: string; jwt: string };
|
||||
|
||||
for (const { base, disabled } of [
|
||||
{ base: WORKER_URL, disabled: false },
|
||||
{ base: WORKER_URL_ENV_OFF, disabled: true },
|
||||
]) {
|
||||
test.describe(`Address activity disabled: ${disabled}`, () => {
|
||||
let mailboxes: Mailbox[];
|
||||
let users: User[];
|
||||
let orphanAddresses: string[];
|
||||
let originalUserSettings: Record<string, unknown>;
|
||||
|
||||
async function call(request: APIRequestContext, path: string,
|
||||
options: Parameters<APIRequestContext['fetch']>[1] = {}, status = 200) {
|
||||
const response = await request.fetch(`${base}${path}`, options);
|
||||
expect(response.status(), await response.text()).toBe(status);
|
||||
return response;
|
||||
}
|
||||
async function list(request: APIRequestContext, path: string, query: Record<string, string>) {
|
||||
return (await call(request, path, { params: { limit: '100', offset: '0', ...query } })).json();
|
||||
}
|
||||
async function addressRow(request: APIRequestContext, mailbox: Mailbox) {
|
||||
return (await list(request, '/admin/address', { query: mailbox.address })).results
|
||||
.find((row: { name: string }) => row.name === mailbox.address);
|
||||
}
|
||||
async function newMailbox(request: APIRequestContext) {
|
||||
const response = await call(request, '/admin/new_address', {
|
||||
method: 'POST', data: { name: `activity${Date.now()}${mailboxes.length}`, domain: 'test.example.com' },
|
||||
});
|
||||
const mailbox: Mailbox = await response.json();
|
||||
mailboxes.push(mailbox);
|
||||
return mailbox;
|
||||
}
|
||||
async function newUser(request: APIRequestContext) {
|
||||
const email = `activity${Date.now()}${users.length}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
await call(request, '/admin/users', { method: 'POST', data: { email, password } });
|
||||
const response = await call(request, '/user_api/login', { method: 'POST', data: { email, password } });
|
||||
const { jwt } = await response.json();
|
||||
const { user_id: id } = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
const user = { id, email, jwt };
|
||||
users.push(user);
|
||||
return user;
|
||||
}
|
||||
const addressAuth = (mailbox: Mailbox) => ({ Authorization: `Bearer ${mailbox.jwt}` });
|
||||
const userAuth = (user: User) => ({ 'x-user-token': user.jwt });
|
||||
async function bind(request: APIRequestContext, mailbox: Mailbox, user: User) {
|
||||
await call(request, '/admin/users/bind_address', {
|
||||
method: 'POST', data: { user_id: user.id, address_id: mailbox.address_id },
|
||||
});
|
||||
}
|
||||
async function seed(request: APIRequestContext, mailbox: Mailbox, updatedAt: string | null = OLD, createdAt?: string) {
|
||||
await call(request, '/__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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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}`;
|
||||
}
|
||||
|
||||
test('password login tokens renew below seven days; legacy credentials do not renew', async ({ request }) => {
|
||||
const mailbox = await createTestAddress(request, 'password-renewal');
|
||||
const day = 24 * 60 * 60;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const identity = { address: mailbox.address, address_id: mailbox.address_id };
|
||||
try {
|
||||
for (const remainingDays of [8, 6]) {
|
||||
const token = signToken({
|
||||
...identity, type: 'address_password_login',
|
||||
iat: now - (30 - remainingDays) * day, exp: now + remainingDays * day,
|
||||
});
|
||||
const response = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const settings = await response.json();
|
||||
expect(settings.type).toBe('address_password_login');
|
||||
if (remainingDays > 7) {
|
||||
expect(settings.new_address_token).toBeNull();
|
||||
continue;
|
||||
}
|
||||
expect(settings.new_address_token).toEqual(expect.any(String));
|
||||
const renewed = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${settings.new_address_token}` },
|
||||
});
|
||||
expect(renewed.ok()).toBe(true);
|
||||
const renewedSettings = await renewed.json();
|
||||
expect(renewedSettings).toMatchObject({ ...identity, type: 'address_password_login', new_address_token: null });
|
||||
expect(renewedSettings.exp - renewedSettings.iat).toBe(30 * day);
|
||||
}
|
||||
const legacy = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${mailbox.jwt}` },
|
||||
});
|
||||
expect(legacy.ok()).toBe(true);
|
||||
expect(await legacy.json()).toMatchObject({ ...identity, new_address_token: null });
|
||||
for (const claims of [
|
||||
{ type: 'telegram_binding' },
|
||||
{ type: 'unknown' },
|
||||
{ type: 'address_password_login' },
|
||||
{ type: 'address_password_login', iat: now - 30 * day, exp: now - 1 },
|
||||
{ type: 'address_password_login', iat: now, exp: now + 31 * day },
|
||||
]) {
|
||||
const response = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${signToken({ ...identity, ...claims })}` },
|
||||
});
|
||||
expect(response.status(), JSON.stringify(claims)).toBe(401);
|
||||
}
|
||||
} finally {
|
||||
await deleteAddress(request, mailbox.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -30,6 +30,10 @@ test.describe('Address Password Login', () => {
|
||||
headers: { Authorization: `Bearer ${loginBody.jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
const settings = await settingsRes.json();
|
||||
expect(settings.type).toBe('address_password_login');
|
||||
expect(settings.exp - settings.iat).toBe(30 * 24 * 60 * 60);
|
||||
expect(settings.new_address_token).toBeNull();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
@@ -164,4 +168,44 @@ test.describe('Address Password Login', () => {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('users can reset a mailbox password only while it is bound to them', async ({ request }) => {
|
||||
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-user-reset');
|
||||
const email = `pwd-reset-user-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('password-reset-user');
|
||||
const newPassword = hashPassword('replacement-mailbox-password');
|
||||
try {
|
||||
const enable = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { enable: true, enableMailVerify: false },
|
||||
});
|
||||
expect(enable.ok()).toBe(true);
|
||||
const register = await request.post(`${WORKER_URL}/user_api/register`, { data: { email, password } });
|
||||
expect(register.ok()).toBe(true);
|
||||
const login = await request.post(`${WORKER_URL}/user_api/login`, { data: { email, password } });
|
||||
expect(login.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await login.json();
|
||||
const headers = { 'x-user-token': userJwt };
|
||||
const reset = (value = newPassword) => request.post(`${WORKER_URL}/user_api/address/${address_id}/reset_password`, {
|
||||
headers, data: { new_password: value },
|
||||
});
|
||||
expect((await reset()).status()).toBe(403);
|
||||
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { ...headers, Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(bind.ok()).toBe(true);
|
||||
expect((await reset('plaintext')).status()).toBe(400);
|
||||
expect((await reset()).ok()).toBe(true);
|
||||
const mailboxLogin = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: newPassword },
|
||||
});
|
||||
expect(mailboxLogin.ok()).toBe(true);
|
||||
const unbind = await request.post(`${WORKER_URL}/user_api/unbind_address`, {
|
||||
headers, data: { address_id },
|
||||
});
|
||||
expect(unbind.ok()).toBe(true);
|
||||
expect((await reset()).status()).toBe(403);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ async function seedTestMailWithReply(
|
||||
text,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe('Bounded cleanup', () => {
|
||||
test('cleans at most one batch and continues on the next run', async ({ request }) => {
|
||||
const address = `cleanup-batch-${Date.now()}@test.example.com`;
|
||||
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
|
||||
request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||
data: {
|
||||
address,
|
||||
raw: 'old cleanup mail',
|
||||
@@ -40,7 +40,7 @@ test.describe('Bounded cleanup', () => {
|
||||
const afterSecondCleanup = await listMails(request, address);
|
||||
expect(afterSecondCleanup.count).toBe(0);
|
||||
|
||||
const recentMailResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
const recentMailResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||
data: {
|
||||
address,
|
||||
raw: 'recent cleanup mail',
|
||||
@@ -63,7 +63,7 @@ test.describe('Bounded cleanup', () => {
|
||||
test('deletes one address batch and its related data', async ({ request }) => {
|
||||
const oldAddress = await createTestAddress(request, 'cleanup-old');
|
||||
|
||||
const seedResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
const seedResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||
data: {
|
||||
address: oldAddress.address,
|
||||
raw: 'address cleanup mail',
|
||||
|
||||
@@ -65,7 +65,7 @@ test.describe('Email forward domain normalization', () => {
|
||||
`Forward domain normalization test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
@@ -118,7 +118,7 @@ test.describe('Email forward domain normalization', () => {
|
||||
`Forward domain boundary test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
@@ -161,7 +161,7 @@ test.describe('Email forward domain normalization', () => {
|
||||
`Forward catch-all domain test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to: address, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
@@ -19,6 +19,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
expect(await res.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID', message: expect.any(String) });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +44,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
||||
}
|
||||
});
|
||||
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 }) => {
|
||||
@@ -53,6 +55,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
expect(await res.json()).toMatchObject({ code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: expect.any(String) });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ test.describe('Mail Deletion', () => {
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
|
||||
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/__test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
expect(seedRes.ok()).toBe(true);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
WORKER_URL,
|
||||
WORKER_URL_ENV_OFF,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const headers = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||
|
||||
test.describe('Mail read status', () => {
|
||||
test('keeps historical mail read and switches one new mail state', async ({ request }) => {
|
||||
const mailbox = await createTestAddress(request, 'mail-read');
|
||||
try {
|
||||
const historical = await request.post(`${WORKER_URL}/__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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import { deleteAddress, WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
const MAX_BATCH_SIZE = 500;
|
||||
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
|
||||
|
||||
async function createCodes(
|
||||
request: APIRequestContext,
|
||||
{
|
||||
count = 1,
|
||||
type = 'role',
|
||||
value = 'case-role',
|
||||
enabled = true,
|
||||
expiresAt = new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}: {
|
||||
count?: unknown;
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
enabled?: unknown;
|
||||
expiresAt?: unknown;
|
||||
},
|
||||
) {
|
||||
return await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
count,
|
||||
redeem_type: type,
|
||||
value,
|
||||
enabled,
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function listCodes(
|
||||
request: APIRequestContext,
|
||||
type: RedeemType,
|
||||
query = '',
|
||||
) {
|
||||
const response = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}&limit=100&offset=0`
|
||||
+ (query ? `&query=${encodeURIComponent(query)}` : ''),
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function deleteCode(request: APIRequestContext, id: number) {
|
||||
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Redemption code Admin API', () => {
|
||||
test('rejects malformed JSON when creating codes', async ({ request }) => {
|
||||
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||
headers: { ...ADMIN_HEADERS, 'content-type': 'application/json' },
|
||||
data: '{',
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('reports the enabled switch and exposes the migrated table', async ({ request }) => {
|
||||
const settingsResponse = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
expect((await settingsResponse.json()).enableRedeemCode).toBe(true);
|
||||
|
||||
const listResponse = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes?redeem_type=role&limit=20&offset=0`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(listResponse.ok()).toBe(true);
|
||||
await expect(listResponse.json()).resolves.toMatchObject({
|
||||
results: expect.any(Array),
|
||||
count: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
test('batch generates UUIDs, filters by type, updates, and deletes', async ({ request }) => {
|
||||
const createResponse = await createCodes(request, { count: 2 });
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
const created = await createResponse.json();
|
||||
expect(created).toMatchObject({ success: true, created: 2 });
|
||||
expect(created.codes).toHaveLength(2);
|
||||
expect(new Set(created.codes).size).toBe(2);
|
||||
expect(created.codes.every((code: string) => UUID_V4_PATTERN.test(code))).toBe(true);
|
||||
const [firstCode, secondCode] = created.codes as string[];
|
||||
const queryPrefix = firstCode.slice(0, 8);
|
||||
|
||||
const roleList = await listCodes(request, 'role', queryPrefix);
|
||||
expect(roleList.count).toBe(1);
|
||||
expect(roleList.results[0]).toMatchObject({
|
||||
code: firstCode,
|
||||
redeem_type: 'role',
|
||||
value: 'case-role',
|
||||
enabled: 1,
|
||||
redeemed: false,
|
||||
result: null,
|
||||
});
|
||||
const balanceList = await listCodes(request, 'send_balance', queryPrefix);
|
||||
expect(balanceList.count).toBe(0);
|
||||
|
||||
const firstId = roleList.results[0].id as number;
|
||||
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstId}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
redeem_type: 'role',
|
||||
value: 'empty-role',
|
||||
enabled: false,
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
expect(updateResponse.ok()).toBe(true);
|
||||
|
||||
const updatedList = await listCodes(request, 'role', firstCode);
|
||||
expect(updatedList.count).toBe(1);
|
||||
expect(updatedList.results[0]).toMatchObject({
|
||||
id: firstId,
|
||||
code: firstCode,
|
||||
value: 'empty-role',
|
||||
enabled: 0,
|
||||
});
|
||||
expect(updatedList.results[0].expires_at).toEqual(expect.any(String));
|
||||
|
||||
const secondList = await listCodes(request, 'role', secondCode);
|
||||
await deleteCode(request, firstId);
|
||||
await deleteCode(request, secondList.results[0].id);
|
||||
expect((await listCodes(request, 'role', firstCode)).count).toBe(0);
|
||||
});
|
||||
|
||||
test('normalizes typed values and accepts both empty and maximum usable prefixes', async ({ request }) => {
|
||||
const emptyResponse = await createCodes(request, {
|
||||
type: 'address_prefix_once',
|
||||
value: ' ',
|
||||
});
|
||||
expect(emptyResponse.ok()).toBe(true);
|
||||
const emptyCode = (await emptyResponse.json()).codes[0] as string;
|
||||
const maxResponse = await createCodes(request, {
|
||||
type: 'address_prefix_once',
|
||||
value: ` ${'A'.repeat(29)} `,
|
||||
});
|
||||
expect(maxResponse.ok()).toBe(true);
|
||||
const maxCode = (await maxResponse.json()).codes[0] as string;
|
||||
const balanceResponse = await createCodes(request, {
|
||||
type: 'send_balance',
|
||||
value: ' 42 ',
|
||||
});
|
||||
expect(balanceResponse.ok()).toBe(true);
|
||||
const balanceCode = (await balanceResponse.json()).codes[0] as string;
|
||||
|
||||
const emptyRow = (await listCodes(request, 'address_prefix_once', emptyCode)).results[0];
|
||||
const maxRow = (await listCodes(request, 'address_prefix_once', maxCode)).results[0];
|
||||
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
|
||||
expect(emptyRow.value).toBe('');
|
||||
expect(maxRow.value).toBe('a'.repeat(29));
|
||||
expect(balanceRow.value).toBe('42');
|
||||
await deleteCode(request, emptyRow.id);
|
||||
await deleteCode(request, maxRow.id);
|
||||
await deleteCode(request, balanceRow.id);
|
||||
});
|
||||
|
||||
test('rejects every invalid batch shape and typed business value', async ({ request }) => {
|
||||
const invalidRequests = [
|
||||
{ type: 'unknown', value: 'x' },
|
||||
{ type: 'role', value: '' },
|
||||
{ type: 'role', value: 'missing-role' },
|
||||
{ type: 'role', value: 'admin' },
|
||||
{ type: 'send_balance', value: '0' },
|
||||
{ type: 'send_balance', value: '-1' },
|
||||
{ type: 'send_balance', value: '1.5' },
|
||||
{ type: 'send_balance', value: '1000000001' },
|
||||
{ type: 'address_prefix_once', value: 'bad-' },
|
||||
{ type: 'address_prefix_once', value: 'a'.repeat(30) },
|
||||
{ count: 0 },
|
||||
{ count: -1 },
|
||||
{ count: 1.5 },
|
||||
{ count: MAX_BATCH_SIZE + 1 },
|
||||
{ count: '1' },
|
||||
{ count: null },
|
||||
{ enabled: 'yes' },
|
||||
{ value: 1 },
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: '' },
|
||||
{ expiresAt: 'not-a-date' },
|
||||
{ expiresAt: new Date(Date.now() - 60_000).toISOString() },
|
||||
];
|
||||
|
||||
for (const invalidRequest of invalidRequests) {
|
||||
const response = await createCodes(request, invalidRequest);
|
||||
expect(response.status(), JSON.stringify(invalidRequest)).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects invalid updates and IDs', async ({ request }) => {
|
||||
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||
const createResponse = await createCodes(request, { count: 2 });
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
const [firstCode, secondCode] = (await createResponse.json()).codes as string[];
|
||||
const firstRow = (await listCodes(request, 'role', firstCode)).results[0];
|
||||
const secondRow = (await listCodes(request, 'role', secondCode)).results[0];
|
||||
|
||||
const invalidId = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { redeem_type: 'role', value: 'case-role', enabled: true, expires_at: expiresAt },
|
||||
});
|
||||
expect(invalidId.status()).toBe(400);
|
||||
|
||||
const invalidRole = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { redeem_type: 'role', value: 'admin', enabled: true, expires_at: expiresAt },
|
||||
});
|
||||
expect(invalidRole.status()).toBe(400);
|
||||
|
||||
const wrongType = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
redeem_type: 'send_balance',
|
||||
value: '1',
|
||||
enabled: true,
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
expect(wrongType.status()).toBe(404);
|
||||
|
||||
const invalidDelete = await request.delete(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(invalidDelete.status()).toBe(400);
|
||||
|
||||
await deleteCode(request, firstRow.id);
|
||||
await deleteCode(request, secondRow.id);
|
||||
});
|
||||
|
||||
test('does not update a redeemed code', async ({ request }) => {
|
||||
let code = '';
|
||||
let addressResult: { jwt?: string } | undefined;
|
||||
try {
|
||||
const createResponse = await createCodes(request, {
|
||||
type: 'address_prefix_once',
|
||||
value: 'locked',
|
||||
});
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
code = (await createResponse.json()).codes[0] as string;
|
||||
|
||||
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: 'mail', domain: 'test.example.com' },
|
||||
});
|
||||
expect(redeemResponse.ok()).toBe(true);
|
||||
addressResult = await redeemResponse.json();
|
||||
|
||||
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||
expect(row.redeemed).toBe(true);
|
||||
expect(row.redeemed_at).toEqual(expect.any(String));
|
||||
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
redeem_type: 'address_prefix_once',
|
||||
value: 'changed',
|
||||
enabled: false,
|
||||
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
},
|
||||
});
|
||||
expect(updateResponse.status()).toBe(409);
|
||||
|
||||
const unchanged = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||
expect(unchanged).toMatchObject({
|
||||
code,
|
||||
value: 'locked',
|
||||
enabled: 1,
|
||||
redeemed: true,
|
||||
});
|
||||
await deleteCode(request, row.id);
|
||||
} finally {
|
||||
if (addressResult?.jwt) await deleteAddress(request, addressResult.jwt);
|
||||
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||
if (row) await deleteCode(request, row.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('exports one selected type with its business columns and enforces the limit', async ({ request }) => {
|
||||
const roleResponse = await createCodes(request, {});
|
||||
expect(roleResponse.ok()).toBe(true);
|
||||
const roleCode = (await roleResponse.json()).codes[0] as string;
|
||||
const balanceResponse = await createCodes(request, {
|
||||
type: 'send_balance',
|
||||
value: '25',
|
||||
});
|
||||
expect(balanceResponse.ok()).toBe(true);
|
||||
const balanceCode = (await balanceResponse.json()).codes[0] as string;
|
||||
|
||||
const roleExport = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=role&limit=100`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(roleExport.ok()).toBe(true);
|
||||
expect(roleExport.headers()['content-type']).toContain('text/csv');
|
||||
expect(roleExport.headers()['content-disposition']).toContain('redeem-codes-role.csv');
|
||||
const roleCsv = await roleExport.text();
|
||||
expect(roleCsv).toContain('role,redeemed_user_id');
|
||||
expect(roleCsv).toContain(roleCode);
|
||||
expect(roleCsv).not.toContain(balanceCode);
|
||||
|
||||
const balanceExport = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=send_balance&limit=100`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const balanceCsv = await balanceExport.text();
|
||||
expect(balanceCsv).toContain('amount,target_address');
|
||||
expect(balanceCsv).toContain(balanceCode);
|
||||
expect(balanceCsv).not.toContain(roleCode);
|
||||
|
||||
for (const query of [
|
||||
'redeem_type=unknown&limit=1',
|
||||
'redeem_type=role&limit=0',
|
||||
'redeem_type=role&limit=10001',
|
||||
'redeem_type=role&limit=1x',
|
||||
'redeem_type=role',
|
||||
]) {
|
||||
const response = await request.get(`${WORKER_URL}/admin/redeem_codes/export?${query}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(response.status(), query).toBe(400);
|
||||
}
|
||||
|
||||
const roleRow = (await listCodes(request, 'role', roleCode)).results[0];
|
||||
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
|
||||
await deleteCode(request, roleRow.id);
|
||||
await deleteCode(request, balanceRow.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,628 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
getAddressSender,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
|
||||
type AddressRedeemResult = {
|
||||
type: 'address_prefix_once';
|
||||
address: string;
|
||||
address_id: number;
|
||||
jwt: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
const uniqueValue = (label: string) => (
|
||||
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
);
|
||||
|
||||
async function createCode(
|
||||
request: APIRequestContext,
|
||||
type: RedeemType,
|
||||
value: string,
|
||||
options: { enabled?: boolean; expiresAt?: string } = {},
|
||||
) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
count: 1,
|
||||
redeem_type: type,
|
||||
value,
|
||||
enabled: options.enabled ?? true,
|
||||
expires_at: options.expiresAt ?? new Date(Date.now() + 3_600_000).toISOString(),
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
expect(body.codes).toHaveLength(1);
|
||||
return body.codes[0] as string;
|
||||
}
|
||||
|
||||
async function listCode(request: APIRequestContext, type: RedeemType, code: string) {
|
||||
const response = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
|
||||
+ `&limit=100&offset=0&query=${encodeURIComponent(code)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
return body.results.find((row: { code: string }) => row.code === code);
|
||||
}
|
||||
|
||||
async function deleteCode(request: APIRequestContext, type: RedeemType, code: string) {
|
||||
const row = await listCode(request, type, code);
|
||||
if (!row) return;
|
||||
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function queryCode(request: APIRequestContext, code: unknown) {
|
||||
return await request.post(`${WORKER_URL}/redeem_api/query`, { data: { code } });
|
||||
}
|
||||
|
||||
async function queryResult(request: APIRequestContext, code: unknown) {
|
||||
return await request.post(`${WORKER_URL}/redeem_api/result`, { data: { code } });
|
||||
}
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `${uniqueValue('redeem-user')}@test.example.com`;
|
||||
const createResponse = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { email, password: hashPassword('redeem-user-password') },
|
||||
});
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
const listResponse = await request.get(
|
||||
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const user = (await listResponse.json()).results.find(
|
||||
(row: { user_email: string }) => row.user_email === email,
|
||||
);
|
||||
expect(user).toBeTruthy();
|
||||
await setUserRole(request, user.id, null);
|
||||
return { id: user.id as number, email };
|
||||
}
|
||||
|
||||
async function setUserRole(request: APIRequestContext, userId: number, role: string | null) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { user_id: userId, role_text: role },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function deleteUser(request: APIRequestContext, userId: number) {
|
||||
await setUserRole(request, userId, null);
|
||||
const response = await request.delete(`${WORKER_URL}/admin/users/${userId}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Redemption code query and guards', () => {
|
||||
test('previews each supported type without consuming it', async ({ request }) => {
|
||||
const roleCode = await createCode(request, 'role', 'case-role');
|
||||
const balanceCode = await createCode(request, 'send_balance', '12');
|
||||
const addressCode = await createCode(request, 'address_prefix_once', 'vip');
|
||||
try {
|
||||
const roleResponse = await queryCode(request, roleCode);
|
||||
expect(roleResponse.ok()).toBe(true);
|
||||
expect(await roleResponse.json()).toEqual({
|
||||
redeem_type: 'role', value: 'case-role', status: 'unused',
|
||||
});
|
||||
|
||||
const balanceResponse = await queryCode(request, balanceCode);
|
||||
expect(balanceResponse.ok()).toBe(true);
|
||||
expect(await balanceResponse.json()).toEqual({
|
||||
redeem_type: 'send_balance', value: '12', status: 'unused',
|
||||
});
|
||||
|
||||
const addressResponse = await queryCode(request, addressCode);
|
||||
expect(addressResponse.ok()).toBe(true);
|
||||
expect(await addressResponse.json()).toEqual({
|
||||
redeem_type: 'address_prefix_once',
|
||||
value: 'vip',
|
||||
status: 'unused',
|
||||
});
|
||||
|
||||
expect((await listCode(request, 'role', roleCode)).redeemed_at).toBeNull();
|
||||
expect((await listCode(request, 'send_balance', balanceCode)).redeemed_at).toBeNull();
|
||||
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed_at).toBeNull();
|
||||
expect((await listCode(request, 'role', roleCode)).redeemed).toBe(false);
|
||||
expect((await listCode(request, 'send_balance', balanceCode)).redeemed).toBe(false);
|
||||
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed).toBe(false);
|
||||
expect((await queryResult(request, addressCode)).status()).toBe(400);
|
||||
} finally {
|
||||
await deleteCode(request, 'role', roleCode);
|
||||
await deleteCode(request, 'send_balance', balanceCode);
|
||||
await deleteCode(request, 'address_prefix_once', addressCode);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects missing, malformed, unknown, and disabled codes, but reports expired codes', async ({ request }) => {
|
||||
const disabledCode = await createCode(request, 'role', 'case-role', { enabled: false });
|
||||
const expiredCode = await createCode(request, 'role', 'case-role', {
|
||||
expiresAt: new Date(Date.now() + 5_000).toISOString(),
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_500));
|
||||
for (const code of [undefined, null, '', 'x'.repeat(257), uniqueValue('missing')]) {
|
||||
expect((await queryCode(request, code)).status()).toBe(400);
|
||||
}
|
||||
expect((await queryCode(request, disabledCode)).status()).toBe(400);
|
||||
const expiredResponse = await queryCode(request, expiredCode);
|
||||
expect(expiredResponse.ok()).toBe(true);
|
||||
expect(await expiredResponse.json()).toEqual({
|
||||
redeem_type: 'role', value: 'case-role', status: 'expired',
|
||||
});
|
||||
} finally {
|
||||
await deleteCode(request, 'role', disabledCode);
|
||||
await deleteCode(request, 'role', expiredCode);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects malformed JSON request bodies', async ({ request }) => {
|
||||
const options = {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: '{',
|
||||
};
|
||||
for (const path of ['/query', '/result', '/redeem']) {
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api${path}`, options);
|
||||
expect(response.status()).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses the code type and does not consume when its required target is missing', async ({ request }) => {
|
||||
const code = await createCode(request, 'send_balance', '9');
|
||||
try {
|
||||
const mismatchResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: 'nobody@test.example.com' },
|
||||
});
|
||||
expect(mismatchResponse.status()).toBe(400);
|
||||
expect((await queryCode(request, code)).ok()).toBe(true);
|
||||
expect((await listCode(request, 'send_balance', code)).redeemed_at).toBeNull();
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Role redemption', () => {
|
||||
test('concurrent different roles consume only the winning code', async ({ request }) => {
|
||||
const user = await createUser(request);
|
||||
const roles = ['case-role', 'empty-role'];
|
||||
const codes = await Promise.all(roles.map((role) => createCode(request, 'role', role)));
|
||||
try {
|
||||
const responses = await Promise.all(codes.map((code) => (
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email },
|
||||
})
|
||||
)));
|
||||
expect(responses.filter((response) => response.ok())).toHaveLength(1);
|
||||
expect(responses.filter((response) => response.status() === 409)).toHaveLength(1);
|
||||
const winner = responses.findIndex((response) => response.ok());
|
||||
const loser = 1 - winner;
|
||||
const rows = await Promise.all(codes.map((code) => listCode(request, 'role', code)));
|
||||
expect(rows[winner].redeemed).toBe(true);
|
||||
expect(rows[loser]).toMatchObject({ redeemed: false, result: null, redeemed_at: null });
|
||||
const users = await request.get(
|
||||
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect((await users.json()).results[0].role_text).toBe(roles[winner]);
|
||||
} finally {
|
||||
for (const code of codes) await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies a configured role by case-insensitive account email and consumes once', async ({ request }) => {
|
||||
const user = await createUser(request);
|
||||
const code = await createCode(request, 'role', 'case-role');
|
||||
try {
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email.toUpperCase() },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.json()).toEqual({
|
||||
success: true,
|
||||
type: 'role',
|
||||
role: 'case-role',
|
||||
user_email: user.email,
|
||||
});
|
||||
|
||||
const userResponse = await request.get(
|
||||
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect((await userResponse.json()).results[0].role_text).toBe('case-role');
|
||||
|
||||
const row = await listCode(request, 'role', code);
|
||||
expect(row.redeemed).toBe(true);
|
||||
expect(row.redeemed_at).toEqual(expect.any(String));
|
||||
expect(JSON.parse(row.result)).toMatchObject({
|
||||
type: 'role',
|
||||
user_id: user.id,
|
||||
user_email: user.email,
|
||||
role: 'case-role',
|
||||
});
|
||||
const publicResult = await queryResult(request, code);
|
||||
expect(publicResult.ok()).toBe(true);
|
||||
expect(await publicResult.json()).toEqual({
|
||||
type: 'role', role: 'case-role', user_email: user.email,
|
||||
});
|
||||
|
||||
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email },
|
||||
});
|
||||
expect(repeatedResponse.status()).toBe(400);
|
||||
expect(await (await queryCode(request, code)).json()).toEqual({
|
||||
redeem_type: 'role', value: 'case-role', status: 'redeemed',
|
||||
});
|
||||
} finally {
|
||||
await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts the same existing role', async ({ request }) => {
|
||||
const user = await createUser(request);
|
||||
const code = await createCode(request, 'role', 'case-role');
|
||||
try {
|
||||
await setUserRole(request, user.id, 'case-role');
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
} finally {
|
||||
await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the code unused when the account is missing or has a conflicting role', async ({ request }) => {
|
||||
const missingCode = await createCode(request, 'role', 'case-role');
|
||||
const conflictCode = await createCode(request, 'role', 'case-role');
|
||||
const user = await createUser(request);
|
||||
try {
|
||||
const missingResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: missingCode, user_email: `${uniqueValue('missing')}@test.example.com` },
|
||||
});
|
||||
expect(missingResponse.status()).toBe(400);
|
||||
expect((await queryCode(request, missingCode)).ok()).toBe(true);
|
||||
|
||||
await setUserRole(request, user.id, 'empty-role');
|
||||
const conflictResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: conflictCode, user_email: user.email },
|
||||
});
|
||||
expect(conflictResponse.status()).toBe(409);
|
||||
expect((await queryCode(request, conflictCode)).ok()).toBe(true);
|
||||
expect((await listCode(request, 'role', conflictCode)).result).toBeNull();
|
||||
} finally {
|
||||
await deleteCode(request, 'role', missingCode);
|
||||
await deleteCode(request, 'role', conflictCode);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Sending-credit redemption', () => {
|
||||
test('preserves default credits before the mailbox is first opened', async ({ request }) => {
|
||||
const address = await createTestAddress(request, 'rcfresh');
|
||||
const code = await createCode(request, 'send_balance', '7');
|
||||
try {
|
||||
const before = await request.get(
|
||||
`${WORKER_URL}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address.address)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
expect((await before.json()).results).toHaveLength(0);
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.json()).toMatchObject({ amount: 7, balance: 17 });
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const settings = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||
});
|
||||
expect(settings.ok()).toBe(true);
|
||||
}
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(17);
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
await deleteAddress(request, address.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('coalesces NULL balance, preserves disabled state, records result, and consumes once', async ({ request }) => {
|
||||
const address = await createTestAddress(request, 'rcnull');
|
||||
const code = await createCode(request, 'send_balance', '7');
|
||||
try {
|
||||
const settingsResponse = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||
});
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
const sender = await getAddressSender(request, address.address);
|
||||
const nullResponse = await request.post(`${WORKER_URL}/admin/address_sender`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
address: address.address,
|
||||
address_id: sender.id,
|
||||
balance: null,
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
expect(nullResponse.ok()).toBe(true);
|
||||
|
||||
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address.toUpperCase() },
|
||||
});
|
||||
expect(redeemResponse.ok()).toBe(true);
|
||||
expect(await redeemResponse.json()).toEqual({
|
||||
success: true,
|
||||
type: 'send_balance',
|
||||
address: address.address,
|
||||
amount: 7,
|
||||
balance: 7,
|
||||
});
|
||||
|
||||
const updatedSender = await getAddressSender(request, address.address);
|
||||
expect(updatedSender.balance).toBe(7);
|
||||
expect(updatedSender.enabled).toBe(0);
|
||||
expect(JSON.parse((await listCode(request, 'send_balance', code)).result)).toMatchObject({
|
||||
type: 'send_balance',
|
||||
address: address.address,
|
||||
amount: 7,
|
||||
});
|
||||
const publicResult = await queryResult(request, code);
|
||||
expect(publicResult.ok()).toBe(true);
|
||||
expect(await publicResult.json()).toEqual({
|
||||
type: 'send_balance', address: address.address, amount: 7,
|
||||
});
|
||||
|
||||
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address },
|
||||
});
|
||||
expect(repeatedResponse.status()).toBe(400);
|
||||
expect(await (await queryCode(request, code)).json()).toEqual({
|
||||
redeem_type: 'send_balance', value: '7', status: 'redeemed',
|
||||
});
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
await deleteAddress(request, address.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not consume when the target address is missing', async ({ request }) => {
|
||||
const code = await createCode(request, 'send_balance', '5');
|
||||
try {
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: `${uniqueValue('missing')}@test.example.com` },
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
expect((await queryCode(request, code)).ok()).toBe(true);
|
||||
expect((await listCode(request, 'send_balance', code)).result).toBeNull();
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('concurrent requests add the balance exactly once', async ({ request }) => {
|
||||
const address = await createTestAddress(request, 'rcrace');
|
||||
const code = await createCode(request, 'send_balance', '5');
|
||||
try {
|
||||
await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||
});
|
||||
const before = await getAddressSender(request, address.address);
|
||||
const responses = await Promise.all([
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address },
|
||||
}),
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, address: address.address },
|
||||
}),
|
||||
]);
|
||||
expect(responses.filter((response) => response.ok())).toHaveLength(1);
|
||||
expect(responses.filter((response) => [400, 409].includes(response.status()))).toHaveLength(1);
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(before.balance + 5);
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
await deleteAddress(request, address.jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Special-address redemption', () => {
|
||||
test('uses the redemption prefix without the system prefix and repeats the complete original result', async ({ request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'vip');
|
||||
const name = `a${Math.random().toString(36).slice(2, 8)}`;
|
||||
let result: AddressRedeemResult | undefined;
|
||||
try {
|
||||
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name, domain: 'TEST.EXAMPLE.COM' },
|
||||
});
|
||||
expect(firstResponse.ok()).toBe(true);
|
||||
result = await firstResponse.json();
|
||||
expect(result).toMatchObject({
|
||||
type: 'address_prefix_once',
|
||||
address: `vip${name}@test.example.com`,
|
||||
address_id: expect.any(Number),
|
||||
jwt: expect.any(String),
|
||||
password: expect.any(String),
|
||||
});
|
||||
expect(result.address).not.toContain('tmpvip');
|
||||
|
||||
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: 'ignored', domain: 'manual.example.com' },
|
||||
});
|
||||
expect(repeatedResponse.ok()).toBe(true);
|
||||
expect(await repeatedResponse.json()).toEqual(result);
|
||||
|
||||
const previewResponse = await queryCode(request, code);
|
||||
expect(previewResponse.ok()).toBe(true);
|
||||
const preview = await previewResponse.json();
|
||||
expect(preview).toEqual({
|
||||
redeem_type: 'address_prefix_once', value: 'vip', status: 'redeemed',
|
||||
});
|
||||
const publicResult = await queryResult(request, code);
|
||||
expect(publicResult.ok()).toBe(true);
|
||||
expect(await publicResult.json()).toEqual({
|
||||
type: 'address_prefix_once',
|
||||
address: result.address,
|
||||
jwt: result.jwt,
|
||||
password: result.password,
|
||||
});
|
||||
const row = await listCode(request, 'address_prefix_once', code);
|
||||
expect(JSON.parse(row.result)).toEqual(result);
|
||||
} finally {
|
||||
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('supports an empty prefix and the longest usable prefix', async ({ request }) => {
|
||||
const emptyCode = await createCode(request, 'address_prefix_once', '');
|
||||
const maxCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
|
||||
const emptyName = `n${Math.random().toString(36).slice(2, 8)}`;
|
||||
let emptyResult: AddressRedeemResult | undefined;
|
||||
let maxResult: AddressRedeemResult | undefined;
|
||||
try {
|
||||
const emptyResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: emptyCode, name: emptyName, domain: 'test.example.com' },
|
||||
});
|
||||
expect(emptyResponse.ok()).toBe(true);
|
||||
emptyResult = await emptyResponse.json();
|
||||
expect(emptyResult.address).toBe(`${emptyName}@test.example.com`);
|
||||
|
||||
const maxResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: maxCode, name: 'z', domain: 'test.example.com' },
|
||||
});
|
||||
expect(maxResponse.ok()).toBe(true);
|
||||
maxResult = await maxResponse.json();
|
||||
expect(maxResult.address.split('@')[0]).toBe(`${'a'.repeat(29)}z`);
|
||||
} finally {
|
||||
if (emptyResult?.jwt) await deleteAddress(request, emptyResult.jwt);
|
||||
if (maxResult?.jwt) await deleteAddress(request, maxResult.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', emptyCode);
|
||||
await deleteCode(request, 'address_prefix_once', maxCode);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps length and domain validation without consuming failed codes', async ({ request }) => {
|
||||
const lengthCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
|
||||
const domainCode = await createCode(request, 'address_prefix_once', 'vip');
|
||||
try {
|
||||
const lengthResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: lengthCode, name: 'zz', domain: 'test.example.com' },
|
||||
});
|
||||
expect(lengthResponse.status()).toBe(400);
|
||||
|
||||
const domainResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: domainCode, name: 'valid', domain: 'invalid.example.com' },
|
||||
});
|
||||
expect(domainResponse.status()).toBe(400);
|
||||
|
||||
expect((await queryCode(request, lengthCode)).ok()).toBe(true);
|
||||
expect((await queryCode(request, domainCode)).ok()).toBe(true);
|
||||
} finally {
|
||||
await deleteCode(request, 'address_prefix_once', lengthCode);
|
||||
await deleteCode(request, 'address_prefix_once', domainCode);
|
||||
}
|
||||
});
|
||||
|
||||
test('generates a valid random suffix when the user omits the name', async ({ request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'rnd');
|
||||
let result: AddressRedeemResult | undefined;
|
||||
try {
|
||||
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, domain: 'test.example.com' },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
result = await response.json();
|
||||
const localPart = result.address.split('@')[0];
|
||||
expect(localPart.startsWith('rnd')).toBe(true);
|
||||
expect(localPart.length).toBeLessThanOrEqual(30);
|
||||
expect(localPart.length).toBeGreaterThan(3);
|
||||
} finally {
|
||||
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not return an address result after its original address is removed', async ({ request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'removed');
|
||||
try {
|
||||
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: 'mail', domain: 'test.example.com' },
|
||||
});
|
||||
const first = await firstResponse.json();
|
||||
await deleteAddress(request, first.jwt);
|
||||
|
||||
const repeatResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: 'ignored', domain: 'test.example.com' },
|
||||
});
|
||||
expect(repeatResponse.status()).toBe(400);
|
||||
const resultResponse = await request.post(`${WORKER_URL}/redeem_api/result`, {
|
||||
data: { code },
|
||||
});
|
||||
expect(resultResponse.status()).toBe(400);
|
||||
} finally {
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('concurrent creations converge on one complete result', async ({ request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'race');
|
||||
let result: AddressRedeemResult | undefined;
|
||||
try {
|
||||
const responses = await Promise.all([
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: `a${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
|
||||
}),
|
||||
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, name: `b${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
|
||||
}),
|
||||
]);
|
||||
expect(responses.every((response) => response.ok())).toBe(true);
|
||||
const results = await Promise.all(responses.map((response) => response.json()));
|
||||
expect(results[1]).toEqual(results[0]);
|
||||
result = results[0];
|
||||
expect(result.password).toEqual(expect.any(String));
|
||||
expect(JSON.parse((await listCode(request, 'address_prefix_once', code)).result)).toEqual(result);
|
||||
} finally {
|
||||
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('expiry stops retrieval of an existing result', async ({ request }) => {
|
||||
const expiredCode = await createCode(request, 'address_prefix_once', 'exp', {
|
||||
expiresAt: new Date(Date.now() + 5_000).toISOString(),
|
||||
});
|
||||
let expiredResult: AddressRedeemResult | undefined;
|
||||
try {
|
||||
expiredResult = await (await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: expiredCode, name: 'mail', domain: 'test.example.com' },
|
||||
})).json();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_500));
|
||||
|
||||
expect((await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code: expiredCode },
|
||||
})).status()).toBe(400);
|
||||
expect(await (await queryCode(request, expiredCode)).json()).toMatchObject({ status: 'expired' });
|
||||
expect((await queryResult(request, expiredCode)).status()).toBe(400);
|
||||
} finally {
|
||||
if (expiredResult?.jwt) await deleteAddress(request, expiredResult.jwt);
|
||||
await deleteCode(request, 'address_prefix_once', expiredCode);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -51,4 +51,33 @@ test.describe('Send Mail via SMTP', () => {
|
||||
// Cleanup
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('external sending stores mail fields without bearer credentials', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'external-sender');
|
||||
const mail = {
|
||||
from_name: 'External sender', to_name: 'Recipient', to_mail: 'recipient@test.example.com',
|
||||
subject: `External ${Date.now()}`, content: 'External message', is_html: false,
|
||||
};
|
||||
try {
|
||||
const listener = onMailpitMessage(message => message.Subject === mail.subject);
|
||||
await listener.ready;
|
||||
const response = await request.post(`${WORKER_URL}/external/api/send_mail`, {
|
||||
data: { ...mail, token: jwt, extra_credential: 'must-not-be-stored' },
|
||||
});
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
expect((await listener.message).From.Address).toBe(address);
|
||||
const sendbox = await request.get(`${WORKER_URL}/api/sendbox?limit=20&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(sendbox.ok()).toBe(true);
|
||||
const { results } = await sendbox.json();
|
||||
expect(results).toHaveLength(1);
|
||||
const stored = JSON.parse(results[0].raw);
|
||||
expect(stored).toMatchObject(mail);
|
||||
expect(stored).not.toHaveProperty('token');
|
||||
expect(stored).not.toHaveProperty('extra_credential');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Telegram AI extraction rendering', () => {
|
||||
'Telegram AI extraction realtime body',
|
||||
].join('\r\n');
|
||||
|
||||
const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: {
|
||||
from: 'sender@test.example.com',
|
||||
to: address,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
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') });
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
getAddressSender,
|
||||
hashPassword,
|
||||
onMailpitMessage,
|
||||
updateAddressSender,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `user-send-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
async function bindAddress(
|
||||
request: APIRequestContext,
|
||||
userJwt: string,
|
||||
addressJwt: string,
|
||||
) {
|
||||
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${addressJwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('User send mail API', () => {
|
||||
test('sends and manages sent items for a bound address only', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let userId: number | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
const bound = await createTestAddress(request, 'user-send-bound-');
|
||||
const accessRequest = await createTestAddress(request, 'user-send-access-');
|
||||
const outsider = await createTestAddress(request, 'usr-outsider-');
|
||||
addresses.push(bound, accessRequest, outsider);
|
||||
await bindAddress(request, user.jwt, bound.jwt);
|
||||
await bindAddress(request, user.jwt, accessRequest.jwt);
|
||||
|
||||
const invalidAddressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/0/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(invalidAddressSettingsRes.status()).toBe(400);
|
||||
|
||||
const outsiderSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderSettingsRes.status()).toBe(400);
|
||||
|
||||
const outsiderCredentialRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderCredentialRes.status()).toBe(400);
|
||||
|
||||
const outsiderAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderAccessRes.status()).toBe(400);
|
||||
|
||||
const outsiderUserSendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'Forbidden user send',
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(outsiderUserSendRes.status()).toBe(400);
|
||||
|
||||
const unauthenticatedSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
);
|
||||
expect(unauthenticatedSendboxRes.status()).toBe(401);
|
||||
|
||||
const requestAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(requestAccessRes.ok()).toBe(true);
|
||||
|
||||
const accessSender = await getAddressSender(request, accessRequest.address);
|
||||
await updateAddressSender(request, {
|
||||
address: accessRequest.address,
|
||||
address_id: accessSender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const duplicateAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(duplicateAccessRes.status()).toBe(400);
|
||||
expect(await duplicateAccessRes.text()).toContain('Already');
|
||||
|
||||
const addressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(addressSettingsRes.ok()).toBe(true);
|
||||
const addressSettings = await addressSettingsRes.json();
|
||||
expect(addressSettings.address).toBe(bound.address);
|
||||
expect(addressSettings.send_balance).toBe(10);
|
||||
|
||||
await deleteAllMailpitMessages(request);
|
||||
const subject = `User API send ${Date.now()}`;
|
||||
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const sendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
from_name: 'User Sender',
|
||||
from_mail: outsider.address,
|
||||
to_name: 'Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: 'Sent through the user API',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.From.Address).toBe(bound.address);
|
||||
|
||||
const outsiderSubject = `Outsider send ${Date.now()}`;
|
||||
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: outsiderSubject,
|
||||
content: 'This sent item must remain inaccessible to the user',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(outsiderSendRes.ok()).toBe(true);
|
||||
const outsiderAddressSendboxRes = await request.get(
|
||||
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||
);
|
||||
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
|
||||
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
|
||||
JSON.parse(item.raw).subject === outsiderSubject
|
||||
));
|
||||
expect(outsiderMail).toBeTruthy();
|
||||
|
||||
const unauthorizedDeleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(unauthorizedDeleteRes.ok()).toBe(true);
|
||||
const outsiderSendboxAfterDeleteRes = await request.get(
|
||||
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||
);
|
||||
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
|
||||
|
||||
const updatedSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect((await updatedSettingsRes.json()).send_balance).toBe(9);
|
||||
|
||||
const sender = await getAddressSender(request, bound.address);
|
||||
await updateAddressSender(request, {
|
||||
address: bound.address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const noBalanceRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'No balance user send',
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(noBalanceRes.status()).toBe(400);
|
||||
expect(await noBalanceRes.text()).toContain('No balance');
|
||||
|
||||
const userSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(userSendboxRes.ok()).toBe(true);
|
||||
const userSendbox = await userSendboxRes.json();
|
||||
expect(userSendbox.count).toBe(1);
|
||||
expect(userSendbox.results).toHaveLength(1);
|
||||
expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject);
|
||||
|
||||
const filteredSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(filteredSendboxRes.ok()).toBe(true);
|
||||
expect((await filteredSendboxRes.json()).count).toBe(1);
|
||||
|
||||
const outsiderFilterRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderFilterRes.ok()).toBe(true);
|
||||
expect((await outsiderFilterRes.json()).count).toBe(0);
|
||||
|
||||
const deleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(deleteRes.ok()).toBe(true);
|
||||
|
||||
const emptySendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
const emptySendbox = await emptySendboxRes.json();
|
||||
expect(emptySendbox.count).toBe(0);
|
||||
expect(emptySendbox.results).toHaveLength(0);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('applies unlimited balance from the user role access token', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
const userIds: number[] = [];
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userIds.push(user.userId);
|
||||
const address = await createTestAddress(request, 'user-send-role-');
|
||||
addresses.push(address);
|
||||
await bindAddress(request, user.jwt, address.jwt);
|
||||
|
||||
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
data: { user_id: user.userId, role_text: 'case-role' },
|
||||
});
|
||||
expect(updateRoleRes.ok()).toBe(true);
|
||||
|
||||
const accessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(accessRes.ok()).toBe(true);
|
||||
const sender = await getAddressSender(request, address.address);
|
||||
await updateAddressSender(request, {
|
||||
address: address.address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
});
|
||||
expect(userSettingsRes.ok()).toBe(true);
|
||||
const { access_token: accessToken } = await userSettingsRes.json();
|
||||
expect(accessToken).toBeTruthy();
|
||||
const userHeaders = {
|
||||
'x-user-token': user.jwt,
|
||||
'x-user-access-token': accessToken,
|
||||
};
|
||||
|
||||
const addressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/settings`,
|
||||
{ headers: userHeaders },
|
||||
);
|
||||
expect(addressSettingsRes.ok()).toBe(true);
|
||||
expect((await addressSettingsRes.json()).send_balance).toBe(99999);
|
||||
|
||||
const sendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/send_mail`,
|
||||
{
|
||||
headers: userHeaders,
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `Unlimited role send ${Date.now()}`,
|
||||
content: 'Sent without consuming address balance',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(0);
|
||||
|
||||
const otherUser = await createUser(request);
|
||||
userIds.push(otherUser.userId);
|
||||
const otherAddress = await createTestAddress(request, 'user-send-other-');
|
||||
addresses.push(otherAddress);
|
||||
await bindAddress(request, otherUser.jwt, otherAddress.jwt);
|
||||
const otherAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': otherUser.jwt } },
|
||||
);
|
||||
expect(otherAccessRes.ok()).toBe(true);
|
||||
const otherSender = await getAddressSender(request, otherAddress.address);
|
||||
await updateAddressSender(request, {
|
||||
address: otherAddress.address,
|
||||
address_id: otherSender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const mixedHeaders = {
|
||||
'x-user-token': otherUser.jwt,
|
||||
'x-user-access-token': accessToken,
|
||||
};
|
||||
const otherSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`,
|
||||
{ headers: mixedHeaders },
|
||||
);
|
||||
expect(otherSettingsRes.ok()).toBe(true);
|
||||
expect((await otherSettingsRes.json()).send_balance).toBe(0);
|
||||
|
||||
const otherSendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`,
|
||||
{
|
||||
headers: mixedHeaders,
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `Mismatched role token ${Date.now()}`,
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(otherSendRes.status()).toBe(400);
|
||||
expect(await otherSendRes.text()).toContain('No balance');
|
||||
} finally {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
await Promise.allSettled(userIds.map((userId) => (
|
||||
request.delete(`${WORKER_URL}/admin/users/${userId}`)
|
||||
)));
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -95,7 +95,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
||||
`Webhook trigger test body`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: {
|
||||
from,
|
||||
to: address,
|
||||
@@ -157,7 +157,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
||||
`Should not trigger webhook`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to: address, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
test('waits for access settings before showing the Admin password dialog', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const testWindow = window as Window & { __adminPasswordRendered?: boolean };
|
||||
testWindow.__adminPasswordRendered = false;
|
||||
|
||||
const detectAdminPassword = () => {
|
||||
const textNodes = document.createTreeWalker(document, NodeFilter.SHOW_TEXT);
|
||||
while (textNodes.nextNode()) {
|
||||
if (textNodes.currentNode.textContent?.trim() === '管理员密码') {
|
||||
testWindow.__adminPasswordRendered = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
new MutationObserver(detectAdminPassword).observe(document, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
});
|
||||
|
||||
let releaseSettings!: () => void;
|
||||
const settingsPending = new Promise<void>((resolve) => {
|
||||
releaseSettings = resolve;
|
||||
});
|
||||
let markSettingsRequested!: () => void;
|
||||
const settingsRequested = new Promise<void>((resolve) => {
|
||||
markSettingsRequested = resolve;
|
||||
});
|
||||
|
||||
await page.route('**/open_api/settings', async (route) => {
|
||||
markSettingsRequested();
|
||||
await settingsPending;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/zh/admin`);
|
||||
await settingsRequested;
|
||||
|
||||
try {
|
||||
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
|
||||
} finally {
|
||||
releaseSettings();
|
||||
}
|
||||
|
||||
await expect(page.getByText('快速设置', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
|
||||
expect(await page.evaluate(() => (
|
||||
window as Window & { __adminPasswordRendered?: boolean }
|
||||
).__adminPasswordRendered)).toBe(false);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ test('create an address with a custom subdomain from the UI', async ({ page, req
|
||||
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.getByRole('button', { name: 'Create New Email' }).click();
|
||||
await page.getByRole('button', { name: 'Create Email Address' }).click();
|
||||
|
||||
const name = `subui${Date.now()}`;
|
||||
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.locator('.n-input-group:visible .n-input input').last().fill('team');
|
||||
|
||||
await createForm.getByRole('button', { name: 'Create New Email' }).click();
|
||||
await createForm.getByRole('button', { name: 'Create Email Address' }).click();
|
||||
|
||||
const domain = `team.${TEST_DOMAIN}`;
|
||||
const address = `tmp${name}@${domain}`;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test('keeps refresh unread and supports automatic and manual state changes', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
try {
|
||||
const mailbox = await createTestAddress(request, 'mail-read-browser');
|
||||
jwt = mailbox.jwt;
|
||||
const subject = `Unread browser mail ${Date.now()}`;
|
||||
await seedTestMail(request, mailbox.address, { subject });
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const mail = (await list.json()).results.find((item: any) => item.raw.includes(subject));
|
||||
expect(mail.is_unread).toBe(1);
|
||||
await expect(page.locator('.mail-list-unread').filter({ hasText: subject })).toBeVisible();
|
||||
|
||||
await page.route('**/api/mails/*/read', async route => {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
await route.continue();
|
||||
});
|
||||
const waitForReadUpdate = (isUnread: boolean) => page.waitForResponse(response =>
|
||||
/\/api\/mails\/\d+\/read$/.test(new URL(response.url()).pathname)
|
||||
&& response.request().method() === 'PATCH'
|
||||
&& response.request().postDataJSON().isUnread === isUnread
|
||||
);
|
||||
const readResponse = waitForReadUpdate(false);
|
||||
await page.getByText(subject, { exact: true }).click();
|
||||
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
|
||||
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||
expect((await readResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||
|
||||
const unreadResponse = waitForReadUpdate(true);
|
||||
await page.getByRole('button', { name: 'Mark as Unread' }).click();
|
||||
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(1);
|
||||
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Read' })).toHaveClass(/n-button--loading/);
|
||||
expect((await unreadResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Read' })).not.toHaveClass(/n-button--loading/);
|
||||
|
||||
const manualReadResponse = waitForReadUpdate(false);
|
||||
await page.getByRole('button', { name: 'Mark as Read' }).click();
|
||||
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
|
||||
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toHaveClass(/n-button--loading/);
|
||||
expect((await manualReadResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||
|
||||
const updatedList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const updatedMail = (await updatedList.json()).results.find((item: any) => item.id === mail.id);
|
||||
expect(updatedMail.is_unread).toBe(0);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
FRONTEND_URL,
|
||||
FRONTEND_URL_ENV_OFF,
|
||||
WORKER_URL,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
const uniqueValue = (label: string) => (
|
||||
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
);
|
||||
|
||||
async function createCode(
|
||||
request: APIRequestContext,
|
||||
type: 'role' | 'send_balance' | 'address_prefix_once',
|
||||
value: string,
|
||||
) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
count: 1,
|
||||
redeem_type: type,
|
||||
value,
|
||||
enabled: true,
|
||||
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
expect(body.codes).toHaveLength(1);
|
||||
return body.codes[0] as string;
|
||||
}
|
||||
|
||||
async function findCode(request: APIRequestContext, type: string, code: string) {
|
||||
const response = await request.get(
|
||||
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
|
||||
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const body = await response.json();
|
||||
return body.results.find((row: { code: string }) => row.code === code);
|
||||
}
|
||||
|
||||
async function deleteCode(request: APIRequestContext, type: string, code: string) {
|
||||
const row = await findCode(request, type, code);
|
||||
if (!row) return;
|
||||
await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `${uniqueValue('browser-redeem-user')}@test.example.com`;
|
||||
const password = createHash('sha256').update('browser-redeem-password').digest('hex');
|
||||
const response = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { email, password },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const usersResponse = await request.get(
|
||||
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
|
||||
{ headers: ADMIN_HEADERS },
|
||||
);
|
||||
const user = (await usersResponse.json()).results[0];
|
||||
await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { user_id: user.id, role_text: null },
|
||||
});
|
||||
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginResponse.ok()).toBe(true);
|
||||
return { id: user.id as number, email, jwt: (await loginResponse.json()).jwt as string };
|
||||
}
|
||||
|
||||
async function deleteUser(request: APIRequestContext, userId: number) {
|
||||
await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { user_id: userId, role_text: null },
|
||||
});
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`, { headers: ADMIN_HEADERS });
|
||||
}
|
||||
|
||||
test('the mailbox entry opens the pure redemption page and completes role redemption', async ({ page, request }) => {
|
||||
const user = await createUser(request);
|
||||
const code = await createCode(request, 'role', 'case-role');
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await expect(page.getByTestId('redeem-entry')).toBeVisible();
|
||||
await page.getByTestId('redeem-entry').click();
|
||||
await expect(page).toHaveURL(/\/en\/redeem$/);
|
||||
await expect(page.getByTestId('redeem-code-link')).toHaveAttribute(
|
||||
'href', 'https://example.com/redeem-codes',
|
||||
);
|
||||
await page.evaluate((jwt) => localStorage.setItem('userJwt', jwt), user.jwt);
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname === '/user_api/settings'
|
||||
));
|
||||
await page.reload();
|
||||
await settingsResponse;
|
||||
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Role benefits' })).toBeVisible();
|
||||
await expect(page.getByTestId('redeem-user-email')).toHaveCount(0);
|
||||
await page.getByTestId('redeem-now').click();
|
||||
const userEmailInput = page.getByTestId('redeem-user-email').locator('input');
|
||||
await expect(userEmailInput).toHaveValue(user.email);
|
||||
await userEmailInput.fill(user.email.toUpperCase());
|
||||
const refreshedSettings = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname === '/user_api/settings' && response.ok()
|
||||
));
|
||||
await page.getByRole('button', { name: 'Confirm redemption' }).click();
|
||||
const refreshedUser = await (await refreshedSettings).json();
|
||||
expect(refreshedUser.user_role.role).toBe('case-role');
|
||||
expect(refreshedUser.access_token).toEqual(expect.any(String));
|
||||
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
|
||||
await expect(page.getByText(user.email, { exact: false })).toBeVisible();
|
||||
await expect(page.getByText('case-role', { exact: false })).toBeVisible();
|
||||
|
||||
const row = await findCode(request, 'role', code);
|
||||
expect(JSON.parse(row.result)).toMatchObject({ user_email: user.email, role: 'case-role' });
|
||||
await page.getByRole('button', { name: 'Redeem another code' }).click();
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
const nextQuery = page.waitForRequest((request) => (
|
||||
new URL(request.url()).pathname === '/redeem_api/query'
|
||||
));
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
expect((await nextQuery).headers()['x-user-access-token']).toBe(refreshedUser.access_token);
|
||||
} finally {
|
||||
await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('sending credits defaults to the current mailbox and keeps the target editable', async ({ page, request }) => {
|
||||
const address = await createTestAddress(request, 'rdb-');
|
||||
const code = await createCode(request, 'send_balance', '3');
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await expect(page.getByTestId('redeem-entry')).toBeVisible();
|
||||
await page.getByTestId('redeem-entry').click();
|
||||
await expect(page).toHaveURL(/\/en\/redeem$/);
|
||||
await page.evaluate((jwt) => localStorage.setItem('jwt', jwt), address.jwt);
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname === '/api/settings'
|
||||
));
|
||||
await page.reload();
|
||||
await settingsResponse;
|
||||
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
await page.getByTestId('redeem-now').click();
|
||||
const targetInput = page.getByTestId('redeem-target-address').locator('input');
|
||||
await expect(targetInput).toHaveValue(address.address);
|
||||
await targetInput.fill(address.address.toUpperCase());
|
||||
await page.getByRole('button', { name: 'Confirm redemption' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
|
||||
} finally {
|
||||
await deleteCode(request, 'send_balance', code);
|
||||
await deleteAddress(request, address.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('the special-address page shows and retrieves the same full credentials', async ({ page, request }) => {
|
||||
const code = await createCode(request, 'address_prefix_once', 'ui');
|
||||
const name = `m${Math.random().toString(36).slice(2, 8)}`;
|
||||
let jwt = '';
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/redeem`);
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Custom mailbox' })).toBeVisible();
|
||||
await expect(page.getByText('Unused', { exact: true })).toBeVisible();
|
||||
await page.getByTestId('redeem-address-name').locator('input').fill(name);
|
||||
await page.getByRole('button', { name: 'Redeem mailbox' }).click();
|
||||
|
||||
const addressValue = page.getByTestId('address-credential-address');
|
||||
const passwordValue = page.getByTestId('address-credential-password');
|
||||
const jwtValue = page.getByTestId('address-credential-jwt');
|
||||
await expect(addressValue).toHaveText(`ui${name}@test.example.com`);
|
||||
const firstAddress = await addressValue.innerText();
|
||||
const firstPassword = await passwordValue.innerText();
|
||||
const firstJwt = await jwtValue.innerText();
|
||||
expect(firstPassword).not.toBe('');
|
||||
expect(firstJwt).not.toBe('');
|
||||
jwt = firstJwt;
|
||||
|
||||
await page.getByRole('button', { name: 'Redeem another code' }).click();
|
||||
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||
await expect(page.getByText('Used', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(firstAddress, { exact: false })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'View redemption result' }).click();
|
||||
await expect(page.getByTestId('address-credential-address')).toHaveText(firstAddress);
|
||||
await expect(page.getByTestId('address-credential-password')).toHaveText(firstPassword);
|
||||
await expect(page.getByTestId('address-credential-jwt')).toHaveText(firstJwt);
|
||||
} finally {
|
||||
if (jwt) {
|
||||
await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
}
|
||||
await deleteCode(request, 'address_prefix_once', code);
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin can batch-create, search, export, and display a redemption result', async ({ page, request }) => {
|
||||
let code = '';
|
||||
const user = await createUser(request);
|
||||
try {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
await expect(page.getByTestId('redeem-admin-create')).toBeVisible();
|
||||
await page.getByTestId('redeem-admin-create').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByTestId('redeem-admin-count').locator('input').fill('1');
|
||||
await dialog.getByTestId('redeem-admin-role').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: 'case-role' }).click();
|
||||
await dialog.getByRole('button', { name: 'Generate' }).click();
|
||||
await expect(page.getByText('Select a future expiration time')).toBeVisible();
|
||||
const expirationInput = dialog.locator('.n-date-picker input');
|
||||
await expirationInput.fill('2099-01-01 00:00:00');
|
||||
await expirationInput.press('Enter');
|
||||
const createResponsePromise = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname === '/admin/redeem_codes/batch'
|
||||
));
|
||||
const createdCodesDownload = page.waitForEvent('download');
|
||||
await dialog.getByRole('button', { name: 'Generate' }).click();
|
||||
const createResponse = await createResponsePromise;
|
||||
code = (await createResponse.json()).codes[0];
|
||||
expect((await createdCodesDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
|
||||
await expect(page.getByText('Redemption codes generated: 1.')).toBeVisible();
|
||||
|
||||
await page.getByTestId('redeem-admin-search').locator('input').fill(code);
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
await expect(page.getByText(code, { exact: true })).toBeVisible();
|
||||
|
||||
const exportDownload = page.waitForEvent('download');
|
||||
await page.getByTestId('redeem-admin-export').click();
|
||||
await expect(page.getByText('Export rows (maximum 10000)')).toBeVisible();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Download CSV' }).click();
|
||||
expect((await exportDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
|
||||
|
||||
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||
data: { code, user_email: user.email },
|
||||
});
|
||||
expect(redeemResponse.ok()).toBe(true);
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
await expect(page.getByText(user.email, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
if (code) await deleteCode(request, 'role', code);
|
||||
await deleteUser(request, user.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin ignores a stale list response after switching redemption type', async ({ page }) => {
|
||||
let releaseRole!: () => void;
|
||||
let markRoleRequested!: () => void;
|
||||
let markRoleCompleted!: () => void;
|
||||
const roleGate = new Promise<void>((resolve) => { releaseRole = resolve; });
|
||||
const roleRequested = new Promise<void>((resolve) => { markRoleRequested = resolve; });
|
||||
const roleCompleted = new Promise<void>((resolve) => { markRoleCompleted = resolve; });
|
||||
|
||||
await page.route('**/admin/redeem_codes?*', async (route) => {
|
||||
const type = new URL(route.request().url()).searchParams.get('redeem_type');
|
||||
if (type === 'role') {
|
||||
markRoleRequested();
|
||||
await roleGate;
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
count: 1,
|
||||
results: [{
|
||||
id: 1,
|
||||
code: 'STALE-ROLE-CODE',
|
||||
redeem_type: 'role',
|
||||
value: 'case-role',
|
||||
result: null,
|
||||
enabled: 1,
|
||||
expires_at: '2099-01-01 00:00:00',
|
||||
redeemed_at: null,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
markRoleCompleted();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
count: 1,
|
||||
results: [{
|
||||
id: 2,
|
||||
code: 'CURRENT-BALANCE-CODE',
|
||||
redeem_type: 'send_balance',
|
||||
value: '20',
|
||||
result: null,
|
||||
enabled: 1,
|
||||
expires_at: '2099-01-01 00:00:00',
|
||||
redeemed_at: null,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
await roleRequested;
|
||||
|
||||
await page.getByTestId('redeem-admin-filter-type').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: 'Sending credits' }).click();
|
||||
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
|
||||
|
||||
releaseRole();
|
||||
await roleCompleted;
|
||||
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('STALE-ROLE-CODE', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const { locale, roleRequired, roleType, addressType, create, generate, prefixError } of [
|
||||
{
|
||||
locale: 'en', roleRequired: 'Select a role', roleType: 'Role benefits',
|
||||
addressType: 'Custom mailbox', create: 'Batch Generate', generate: 'Generate',
|
||||
prefixError: 'Use only letters and digits, up to 29 characters. Leave empty for no prefix.',
|
||||
},
|
||||
{
|
||||
locale: 'zh', roleRequired: '请选择角色', roleType: '角色权益',
|
||||
addressType: '专属邮箱', create: '批量生成', generate: '生成',
|
||||
prefixError: '前缀仅支持英文字母和数字,最多 29 个字符;留空表示无前缀。',
|
||||
},
|
||||
]) {
|
||||
test(`Admin validates missing role and invalid prefix in ${locale}`, async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||
});
|
||||
let createRequests = 0;
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'POST'
|
||||
&& new URL(request.url()).pathname === '/admin/redeem_codes/batch') {
|
||||
createRequests++;
|
||||
}
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
|
||||
await expect(page.getByTestId('redeem-admin-filter-type')).toContainText(roleType);
|
||||
await page.getByRole('button', { name: create, exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: generate, exact: true }).click();
|
||||
await expect(page.getByText(roleRequired, { exact: true })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'close', exact: true }).click();
|
||||
await page.getByTestId('redeem-admin-filter-type').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: addressType }).click();
|
||||
await page.getByRole('button', { name: create, exact: true }).click();
|
||||
await dialog.locator('input[maxlength]').fill('bad-');
|
||||
await dialog.getByRole('button', { name: generate, exact: true }).click();
|
||||
await expect(page.getByText(prefixError, { exact: true })).toBeVisible();
|
||||
expect(createRequests).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('the disabled frontend hides the entry and redirects the page', async ({ page }) => {
|
||||
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/`);
|
||||
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
|
||||
|
||||
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/redeem`);
|
||||
await expect(page).not.toHaveURL(/\/redeem$/);
|
||||
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/admin`);
|
||||
await expect(page.getByTestId('redeem-admin-create')).toHaveCount(0);
|
||||
await expect(page.getByText('Redemption Codes', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { test, expect, request as apiRequest, type Page } from '@playwright/test';
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
requestSendAccess,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const expectEditorOriginsToAlign = async (page: Page) => {
|
||||
const editorOriginDelta = await page.locator('.compose-textarea').evaluate((editor) => {
|
||||
const textareaElement = editor.querySelector('textarea');
|
||||
const placeholder = editor.querySelector('.n-input__placeholder');
|
||||
if (!textareaElement) throw new Error('compose textarea element not found');
|
||||
if (!placeholder) throw new Error('compose placeholder element not found');
|
||||
const textareaBox = textareaElement.getBoundingClientRect();
|
||||
const placeholderBox = placeholder.getBoundingClientRect();
|
||||
const textareaStyle = getComputedStyle(textareaElement);
|
||||
const placeholderStyle = getComputedStyle(placeholder);
|
||||
return {
|
||||
x: textareaBox.x + parseFloat(textareaStyle.paddingLeft)
|
||||
- placeholderBox.x - parseFloat(placeholderStyle.paddingLeft),
|
||||
y: textareaBox.y + parseFloat(textareaStyle.paddingTop)
|
||||
- placeholderBox.y - parseFloat(placeholderStyle.paddingTop),
|
||||
};
|
||||
});
|
||||
expect(Math.abs(editorOriginDelta.x)).toBeLessThan(1);
|
||||
expect(Math.abs(editorOriginDelta.y)).toBeLessThan(1);
|
||||
};
|
||||
|
||||
test.describe('Send mail composer', () => {
|
||||
test('edits a draft, changes format, and previews HTML', async ({ page }) => {
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'compose-ui');
|
||||
jwt = created.jwt;
|
||||
await requestSendAccess(api, jwt);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.composer-title')).toContainText(created.address);
|
||||
|
||||
const expectedFieldOrder = [
|
||||
'send-mail-sender-address',
|
||||
'send-mail-sender-name',
|
||||
'send-mail-recipient-address',
|
||||
'send-mail-recipient-name',
|
||||
];
|
||||
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||
(inputs) => inputs.map((input) => input.id)
|
||||
);
|
||||
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||
|
||||
const textarea = page.locator('.compose-textarea textarea');
|
||||
await expectEditorOriginsToAlign(page);
|
||||
|
||||
const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
|
||||
const subject = page.getByRole('textbox', { name: /^Subject/ });
|
||||
await recipient.fill('recipient@test.example.com');
|
||||
await subject.fill('Composer preview');
|
||||
|
||||
await page.getByText('HTML', { exact: true }).click();
|
||||
const htmlContent = [
|
||||
'<h1>Preview heading</h1><p>Preview body</p>',
|
||||
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
|
||||
].join('');
|
||||
let previewDialogAppeared = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
previewDialogAppeared = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await textarea.fill(htmlContent);
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
|
||||
const preview = page.locator('.compose-preview');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText('Preview heading');
|
||||
await expect(preview.locator('script, [onerror]')).toHaveCount(0);
|
||||
expect(previewDialogAppeared).toBe(false);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(preview).toBeHidden();
|
||||
await expect(recipient).toHaveValue('recipient@test.example.com');
|
||||
await expect(subject).toHaveValue('Composer preview');
|
||||
await expect(textarea).toHaveValue(htmlContent);
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
|
||||
await page.getByText('Enviar correo', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
|
||||
await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('HTML', { exact: true }).click();
|
||||
const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
|
||||
await expect(previewButton).toBeVisible();
|
||||
const previewBox = await previewButton.boundingBox();
|
||||
expect(previewBox).not.toBeNull();
|
||||
expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the Admin field order and editor placeholder aligned', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'mails');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
const expectedFieldOrder = [
|
||||
'admin-send-mail-sender-address',
|
||||
'admin-send-mail-sender-name',
|
||||
'admin-send-mail-recipient-address',
|
||||
'admin-send-mail-recipient-name',
|
||||
];
|
||||
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||
(inputs) => inputs.map((input) => input.id)
|
||||
);
|
||||
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||
await expectEditorOriginsToAlign(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
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'
|
||||
&& url.searchParams.get('limit') === '100';
|
||||
});
|
||||
await page.getByText('Mail Box', { exact: true }).click();
|
||||
await page.getByText('Inbox', { exact: true }).click();
|
||||
const initialMailboxResponse = await initialMailboxAddressesResponse;
|
||||
expect(initialMailboxResponse.ok()).toBe(true);
|
||||
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `user-send-browser-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { email, jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
test.describe('User send mail page', () => {
|
||||
test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let userId: number | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
const address = await createTestAddress(request, 'usr-browser-');
|
||||
const secondAddress = await createTestAddress(request, 'usr-second-');
|
||||
addresses.push(address, secondAddress);
|
||||
for (const boundAddress of addresses) {
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${boundAddress.jwt}`,
|
||||
'x-user-token': user.jwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
}
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate((userJwt) => {
|
||||
localStorage.setItem('userJwt', userJwt);
|
||||
}, user.jwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||
const credentialResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname
|
||||
=== `/user_api/bind_address_jwt/${address.address_id}`
|
||||
));
|
||||
const addressRow = page.getByRole('row').filter({ hasText: address.address });
|
||||
await addressRow.getByRole('button', { name: 'Address Credential' }).click();
|
||||
expect((await credentialResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('dialog')).toContainText(address.address);
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const initialSettingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${secondAddress.address_id}/settings`
|
||||
));
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
expect((await initialSettingsResponse).ok()).toBe(true);
|
||||
await expect(page.locator('.composer-title h2')).toHaveText('Compose email');
|
||||
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address.address_id}/settings`
|
||||
));
|
||||
await page.locator('.address-picker-select').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: address.address }).click();
|
||||
expect((await settingsResponse).ok()).toBe(true);
|
||||
await expect(page.locator('.address-picker-select')).toContainText(address.address);
|
||||
|
||||
const subject = `Browser user send ${Date.now()}`;
|
||||
await page.getByRole('textbox', { name: /^Recipient address/ })
|
||||
.fill('recipient@test.example.com');
|
||||
await page.getByRole('textbox', { name: /^Subject/ }).fill(subject);
|
||||
await page.locator('.compose-textarea textarea').fill('Sent from the user page');
|
||||
|
||||
const sendResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address.address_id}/send_mail`
|
||||
));
|
||||
const sendboxResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname === '/user_api/sendbox'
|
||||
));
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
expect((await sendResponse).ok()).toBe(true);
|
||||
expect((await sendboxResponse).ok()).toBe(true);
|
||||
|
||||
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
|
||||
await expect(page.locator('.n-thing-header__title').filter({ hasText: subject }))
|
||||
.toHaveText(subject, { timeout: 15_000 });
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { after, before, test } from 'node:test';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
let server;
|
||||
let onMailpitMessage;
|
||||
|
||||
before(async () => {
|
||||
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||
await once(server, 'listening');
|
||||
const originalApi = process.env.MAILPIT_API;
|
||||
process.env.MAILPIT_API = `http://127.0.0.1:${server.address().port}/api`;
|
||||
try {
|
||||
({ onMailpitMessage } = await import('../../fixtures/test-helpers.ts'));
|
||||
} finally {
|
||||
if (originalApi === undefined) delete process.env.MAILPIT_API;
|
||||
else process.env.MAILPIT_API = originalApi;
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
for (const client of server.clients) client.terminate();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
const target = { Subject: 'target-message' };
|
||||
const newEvent = JSON.stringify({ Type: 'new', Data: target });
|
||||
const statsEvent = JSON.stringify({ Type: 'stats', Data: { total: 1 } });
|
||||
|
||||
for (const [name, payload] of [
|
||||
['single event', newEvent],
|
||||
['new event followed by stats', `${newEvent}\n${statsEvent}`],
|
||||
['stats followed by new event', `${statsEvent}\n${newEvent}`],
|
||||
['malformed and unrelated events before the match',
|
||||
`invalid-json\n${JSON.stringify({ Type: 'new', Data: { Subject: 'other' } })}\n${newEvent}\n`],
|
||||
]) {
|
||||
test(name, async () => {
|
||||
const connected = once(server, 'connection');
|
||||
const listener = onMailpitMessage((mail) => mail.Subject === target.Subject, { timeout: 1000 });
|
||||
const received = assert.doesNotReject(async () => {
|
||||
assert.deepEqual(await listener.message, target);
|
||||
});
|
||||
const [client] = await connected;
|
||||
await listener.ready;
|
||||
client.send(payload);
|
||||
await received;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
VITE_API_BASE=https://temp-email-api.xxx.xxx
|
||||
VITE_DEFAULT_LANG=zh
|
||||
VITE_CF_WEB_ANALY_TOKEN=
|
||||
VITE_IS_TELEGRAM=false
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/logo.png" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/logo.png">
|
||||
<script id="app-config" type="application/json">{}</script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.11.1",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,16 +28,16 @@
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.19.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"axios": "^1.20.0",
|
||||
"dompurify": "^3.4.15",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.45.0",
|
||||
"naive-ui": "^2.45.3",
|
||||
"postal-mime": "^2.7.6",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.41",
|
||||
"vue": "^3.5.42",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-i18n": "^11.4.10",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -53,7 +53,7 @@
|
||||
"vitest": "^4.1.11",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1",
|
||||
"wrangler": "^4.124.0"
|
||||
"wrangler": "^4.129.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
Generated
+426
-420
File diff suppressed because it is too large
Load Diff
+10
-8
@@ -3,7 +3,7 @@ import {
|
||||
darkTheme,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, watchEffect } from 'vue'
|
||||
import { useScript } from '@unhead/vue'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from './store'
|
||||
import { useIsMobile } from './utils/composables'
|
||||
@@ -12,12 +12,13 @@ import Footer from './views/Footer.vue';
|
||||
import { api } from './api'
|
||||
import { getNaiveLocaleConfig } from './i18n/naive-locale'
|
||||
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
|
||||
import { APP_CONFIG } from './config'
|
||||
|
||||
const {
|
||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||
} = useGlobalState()
|
||||
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
|
||||
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
|
||||
const adClient = APP_CONFIG.GOOGLE_AD_CLIENT;
|
||||
const adSlot = APP_CONFIG.GOOGLE_AD_SLOT;
|
||||
const { locale } = useI18n({ useScope: 'global' });
|
||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
|
||||
@@ -31,12 +32,13 @@ watchEffect(() => {
|
||||
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
|
||||
})
|
||||
|
||||
// Load Google Ad script at top level (not inside onMounted)
|
||||
if (showAd.value) {
|
||||
useScript({
|
||||
useHead({
|
||||
script: [{
|
||||
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
||||
async: true,
|
||||
crossorigin: "anonymous",
|
||||
crossorigin: 'anonymous',
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,7 +49,7 @@ onMounted(async () => {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
|
||||
const token = APP_CONFIG.CF_WEB_ANALY_TOKEN;
|
||||
|
||||
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
|
||||
if (token && !exist) {
|
||||
@@ -66,7 +68,7 @@ onMounted(async () => {
|
||||
|
||||
|
||||
// check if telegram is enabled
|
||||
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
|
||||
const enableTelegram = APP_CONFIG.IS_TELEGRAM;
|
||||
if (
|
||||
(typeof enableTelegram === 'boolean' && enableTelegram === true)
|
||||
||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const setRuntimeConfig = (config) => {
|
||||
const element = document.createElement('script')
|
||||
element.id = 'app-config'
|
||||
element.type = 'application/json'
|
||||
element.textContent = JSON.stringify(config)
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
|
||||
describe('APP_CONFIG', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITE_API_BASE', 'https://build.example.com')
|
||||
vi.stubEnv('VITE_DEFAULT_LANG', 'zh')
|
||||
vi.stubEnv('VITE_IS_TELEGRAM', 'false')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector('#app-config')?.remove()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('uses build settings when runtime settings are absent', async () => {
|
||||
const { APP_CONFIG } = await import('../config')
|
||||
|
||||
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
|
||||
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||
})
|
||||
|
||||
it('overrides only settings provided by index.html', async () => {
|
||||
setRuntimeConfig({ API_BASE: 'https://runtime.example.com', DEFAULT_LANG: 'en' })
|
||||
|
||||
const { APP_CONFIG } = await import('../config')
|
||||
|
||||
expect(APP_CONFIG.API_BASE).toBe('https://runtime.example.com')
|
||||
expect(APP_CONFIG.DEFAULT_LANG).toBe('en')
|
||||
})
|
||||
|
||||
it('allows an explicit empty runtime value', async () => {
|
||||
setRuntimeConfig({ API_BASE: '' })
|
||||
|
||||
const { APP_CONFIG } = await import('../config')
|
||||
|
||||
expect(APP_CONFIG.API_BASE).toBe('')
|
||||
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||
})
|
||||
|
||||
it('falls back to build settings for invalid runtime value types', async () => {
|
||||
setRuntimeConfig({ API_BASE: {}, DEFAULT_LANG: 1, IS_TELEGRAM: [] })
|
||||
|
||||
const { APP_CONFIG } = await import('../config')
|
||||
|
||||
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
|
||||
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||
expect(APP_CONFIG.IS_TELEGRAM).toBe('false')
|
||||
})
|
||||
|
||||
it('reads runtime settings only once', async () => {
|
||||
setRuntimeConfig({ DEFAULT_LANG: 'en' })
|
||||
const firstImport = await import('../config')
|
||||
|
||||
document.querySelector('#app-config').textContent = JSON.stringify({ DEFAULT_LANG: 'de' })
|
||||
const secondImport = await import('../config')
|
||||
|
||||
expect(secondImport.APP_CONFIG).toBe(firstImport.APP_CONFIG)
|
||||
expect(secondImport.APP_CONFIG.DEFAULT_LANG).toBe('en')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
};
|
||||
+49
-18
@@ -1,27 +1,44 @@
|
||||
import { useGlobalState } from '../store'
|
||||
import { h } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import axios from 'axios'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { getFingerprint } from '../utils/fingerprint'
|
||||
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
|
||||
import { sanitizeHtml } from '../utils/sanitize-html'
|
||||
import { APP_CONFIG } from '../config'
|
||||
import { createUserAccessTokenInterceptor } from './user-access-token-interceptor'
|
||||
import { ErrorCode } from './error-codes'
|
||||
import { updateLocalAddressCache } from '../utils/local-address-cache'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||
const API_BASE = APP_CONFIG.API_BASE || "";
|
||||
const {
|
||||
loading, auth, jwt, settings, openSettings,
|
||||
userOpenSettings, userSettings, announcement,
|
||||
showAuth, adminAuth, showAdminAuth, userJwt
|
||||
} = useGlobalState();
|
||||
|
||||
const localAddressCache = useLocalStorage('LocalAddressCache', []);
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: API_BASE,
|
||||
timeout: 30000,
|
||||
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 = {}) => {
|
||||
loading.value = true;
|
||||
const showLoading = options.showLoading !== false;
|
||||
if (showLoading) loading.value = true;
|
||||
try {
|
||||
// Get browser fingerprint for request tracking
|
||||
const fingerprint = await getFingerprint();
|
||||
@@ -42,32 +59,33 @@ const apiFetch = async (path, options = {}) => {
|
||||
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
|
||||
const adminAuthHeader = safeHeaderValue(adminAuth.value);
|
||||
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
|
||||
const authorizationHeader = safeBearerHeader(jwt.value);
|
||||
const authorizationHeader = safeBearerHeader(options.addressJwt ?? jwt.value);
|
||||
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
||||
|
||||
const response = await instance.request(path, {
|
||||
const initialResponse = await instance.request(path, {
|
||||
method: options.method || 'GET',
|
||||
data: options.body || null,
|
||||
headers,
|
||||
});
|
||||
if (response.status === 401 && path.startsWith("/admin")) {
|
||||
const response = await interceptResponse(path, initialResponse);
|
||||
if (ErrorCode.isAdminAuthError(response)) {
|
||||
showAdminAuth.value = true;
|
||||
}
|
||||
if (response.status === 401 && openSettings.value.needAuth) {
|
||||
if (ErrorCode.isSiteAuthError(response)) {
|
||||
showAuth.value = true;
|
||||
}
|
||||
if (response.status >= 300) {
|
||||
throw new Error(`[${response.status}]: ${response.data}` || "error");
|
||||
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||
}
|
||||
const data = response.data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
throw new Error(`Code ${error.response.status}: ${error.response.data}` || "error");
|
||||
throw new Error(`Code ${error.response.status}: ${error.response.data?.message || error.response.data}`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (showLoading) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +117,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
||||
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||
enableMailReadStatus: res["enableMailReadStatus"] === true,
|
||||
enableAutoReply: res["enableAutoReply"] || false,
|
||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||
copyright: res["copyright"] || openSettings.value.copyright,
|
||||
@@ -107,7 +126,10 @@ const getOpenSettings = async (message, notification) => {
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
|
||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||
addressPasswordLoginOnly: res["addressPasswordLoginOnly"] === true,
|
||||
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
|
||||
enableRedeemCode: res["enableRedeemCode"] || false,
|
||||
redeemCodeUrl: res["redeemCodeUrl"] || "",
|
||||
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
|
||||
statusUrl: res["statusUrl"] || "",
|
||||
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
||||
@@ -137,18 +159,27 @@ const getOpenSettings = async (message, notification) => {
|
||||
}
|
||||
|
||||
const getSettings = async () => {
|
||||
let addressToken = jwt.value;
|
||||
try {
|
||||
if (typeof jwt.value != 'string' || jwt.value.trim() === '' || jwt.value === 'undefined') {
|
||||
return "";
|
||||
if (!safeHeaderValue(addressToken)) return;
|
||||
const res = await apiFetch('/api/settings', { addressJwt: addressToken });
|
||||
if (jwt.value !== addressToken) return;
|
||||
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, addressToken, res);
|
||||
settings.value = res;
|
||||
const renewedAddressToken = res.new_address_token;
|
||||
if (!renewedAddressToken) return;
|
||||
try {
|
||||
const renewedSettings = await apiFetch('/api/settings', { addressJwt: renewedAddressToken });
|
||||
if (jwt.value !== addressToken) return;
|
||||
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, renewedAddressToken, renewedSettings);
|
||||
addressToken = renewedAddressToken;
|
||||
jwt.value = renewedAddressToken;
|
||||
settings.value = renewedSettings;
|
||||
} catch (error) {
|
||||
console.error('Failed to renew mailbox JWT', error);
|
||||
}
|
||||
const res = await apiFetch("/api/settings");;
|
||||
settings.value = {
|
||||
address: res["address"],
|
||||
auto_reply: res["auto_reply"],
|
||||
send_balance: res["send_balance"],
|
||||
};
|
||||
} finally {
|
||||
settings.value.fetched = true;
|
||||
if (jwt.value === addressToken) settings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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 });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { APP_CONFIG } from '@/config'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
const props = defineProps({
|
||||
address: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
jwt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
addressPassword: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const { openSettings, auth } = useGlobalState()
|
||||
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
|
||||
const message = useMessage()
|
||||
|
||||
const configuredApiBaseUrl = APP_CONFIG.API_BASE || ''
|
||||
const frontendBaseUrl = computed(() => window.location.origin)
|
||||
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
|
||||
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
|
||||
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
|
||||
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
|
||||
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
|
||||
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
|
||||
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
|
||||
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
|
||||
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
|
||||
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
|
||||
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
|
||||
const securityLabel = computed(() =>
|
||||
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
|
||||
)
|
||||
const agentConfigJson = computed(() => JSON.stringify({
|
||||
base: apiBaseUrl.value,
|
||||
jwt: props.jwt,
|
||||
site_password: auth.value || '',
|
||||
}, null, 2))
|
||||
const agentText = computed(() => [
|
||||
`${t('currentAddress')}: ${props.address || '-'}`,
|
||||
`${t('apiBase')}: ${apiBaseUrl.value}`,
|
||||
`${t('agentSkill')}: ${agentSkillUrl}`,
|
||||
`${t('agentConfig')}:`,
|
||||
agentConfigJson.value,
|
||||
].join('\n'))
|
||||
const smtpImapText = computed(() => [
|
||||
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
|
||||
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
|
||||
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
|
||||
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
|
||||
`${t('security')}: ${securityLabel.value}`,
|
||||
`${t('username')}: ${props.address || '-'}`,
|
||||
`${t('password')}: ${props.jwt}`,
|
||||
].join('\n'))
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (!text) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success(t('copySuccess'))
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
try {
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
if (document.execCommand('copy')) {
|
||||
message.success(t('copySuccess'))
|
||||
return
|
||||
}
|
||||
message.error(t('copyFailed'))
|
||||
} finally {
|
||||
textarea.parentNode?.removeChild(textarea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error(t('copyFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="credential-content">
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
{{ t(openSettings.addressPasswordLoginOnly ? 'passwordOnlyTip' : 'tip') }}
|
||||
</n-alert>
|
||||
<section class="credential-panel">
|
||||
<h3 class="credential-title">{{ t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : '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 v-if="!openSettings.addressPasswordLoginOnly" 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 v-if="!openSettings.addressPasswordLoginOnly" 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,13 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import AddressCredentialContent from './AddressCredentialContent.vue'
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -24,299 +28,17 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
|
||||
const { openSettings, auth } = useGlobalState()
|
||||
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
|
||||
const message = useMessage()
|
||||
const { t } = useScopedI18n('components.AddressCredentialModal')
|
||||
|
||||
const modalShow = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value),
|
||||
})
|
||||
|
||||
const configuredApiBaseUrl = import.meta.env.VITE_API_BASE || ''
|
||||
const frontendBaseUrl = computed(() => window.location.origin)
|
||||
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
|
||||
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
|
||||
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
|
||||
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
|
||||
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
|
||||
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
|
||||
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
|
||||
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
|
||||
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
|
||||
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
|
||||
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
|
||||
const securityLabel = computed(() =>
|
||||
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
|
||||
)
|
||||
const agentConfigJson = computed(() => JSON.stringify({
|
||||
base: apiBaseUrl.value,
|
||||
jwt: props.jwt,
|
||||
site_password: auth.value || '',
|
||||
}, null, 2))
|
||||
const agentText = computed(() => [
|
||||
`${t('currentAddress')}: ${props.address || '-'}`,
|
||||
`${t('apiBase')}: ${apiBaseUrl.value}`,
|
||||
`${t('agentSkill')}: ${agentSkillUrl}`,
|
||||
`${t('agentConfig')}:`,
|
||||
agentConfigJson.value,
|
||||
].join('\n'))
|
||||
const smtpImapText = computed(() => [
|
||||
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
|
||||
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
|
||||
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
|
||||
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
|
||||
`${t('security')}: ${securityLabel.value}`,
|
||||
`${t('username')}: ${props.address || '-'}`,
|
||||
`${t('password')}: ${props.jwt}`,
|
||||
].join('\n'))
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (!text) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success(t('copySuccess'))
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
try {
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
if (document.execCommand('copy')) {
|
||||
message.success(t('copySuccess'))
|
||||
return
|
||||
}
|
||||
message.error(t('copyFailed'))
|
||||
} finally {
|
||||
textarea.parentNode?.removeChild(textarea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error(t('copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
|
||||
<n-modal v-model:show="modalShow" preset="card" :title="t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'title')"
|
||||
style="width: min(760px, calc(100vw - 32px));">
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
{{ t('tip') }}
|
||||
</n-alert>
|
||||
<section class="credential-panel">
|
||||
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
|
||||
<div class="credential-section">
|
||||
<div class="credential-field" v-if="address">
|
||||
<span class="credential-label">{{ t('currentAddress') }}</span>
|
||||
<div class="credential-copy-row">
|
||||
<code class="credential-code">{{ address }}</code>
|
||||
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
|
||||
<div class="credential-copy-row">
|
||||
<code class="credential-code">{{ jwt }}</code>
|
||||
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-field" v-if="addressPassword">
|
||||
<span class="credential-label">{{ t('addressPassword') }}</span>
|
||||
<code class="credential-code">{{ addressPassword }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<n-collapse accordion class="credential-collapse">
|
||||
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="credential-section">
|
||||
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('apiBase') }}</span>
|
||||
<code class="credential-code">{{ apiBaseUrl }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('agentSkill') }}</span>
|
||||
<code class="credential-code">
|
||||
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
|
||||
</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('agentConfig') }}</span>
|
||||
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
||||
{{ t('docs') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="credential-section">
|
||||
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
|
||||
<div class="credential-grid">
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('smtpHost') }}</span>
|
||||
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('smtpPort') }}</span>
|
||||
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('imapHost') }}</span>
|
||||
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('imapPort') }}</span>
|
||||
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('security') }}</span>
|
||||
<code class="credential-code">{{ securityLabel }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('username') }}</span>
|
||||
<code class="credential-code">{{ address }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('password') }}</span>
|
||||
<code class="credential-code">{{ jwt }}</code>
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
||||
{{ t('docs') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="credential-section">
|
||||
<div class="credential-field">
|
||||
<code class="credential-code">{{ autoLoginUrl }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.credential-collapse {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.credential-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.credential-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.credential-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.credential-tip {
|
||||
margin: 0;
|
||||
color: var(--n-text-color-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.credential-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.credential-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.credential-label {
|
||||
color: var(--n-text-color-2);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.credential-code {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--n-color-embedded);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.credential-copy-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.credential-code-block {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.credential-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.credential-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.credential-copy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Copy } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getCachedAddresses } from '../utils/local-address-cache'
|
||||
|
||||
const props = defineProps({
|
||||
showCopy: {
|
||||
@@ -28,6 +29,8 @@ const {
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('components.AddressSelect')
|
||||
const { t: loginT } = useScopedI18n('views.common.Login')
|
||||
const { t: localAddressT } = useScopedI18n('views.index.LocalAddress')
|
||||
|
||||
const addressOptions = ref([])
|
||||
const addressValue = ref(null)
|
||||
@@ -45,21 +48,6 @@ const formatAddressLabel = (address) => {
|
||||
return address.replace('@' + domain, `@${domainLabel}`);
|
||||
}
|
||||
|
||||
const parseJwtAddress = (curJwt) => {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
)
|
||||
)
|
||||
);
|
||||
return payload.address;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const getOptionValue = (key, scope, payload, address) => {
|
||||
if (optionValueMap.has(key)) {
|
||||
const cached = optionValueMap.get(key)
|
||||
@@ -74,18 +62,17 @@ const getOptionValue = (key, scope, payload, address) => {
|
||||
}
|
||||
|
||||
const buildLocalOptions = (excludeAddresses = new Set()) => {
|
||||
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
const children = localAddressCache.value
|
||||
.map((curJwt) => {
|
||||
const address = parseJwtAddress(curJwt);
|
||||
if (!address) return null;
|
||||
const children = getCachedAddresses(localAddressCache.value)
|
||||
.map(({ token, address, type }, index) => {
|
||||
if (excludeAddresses.has(address)) return null;
|
||||
const label = formatAddressLabel(address);
|
||||
const key = `local:${curJwt}`;
|
||||
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
|
||||
if (settings.value.address && address === settings.value.address) {
|
||||
const isPasswordLogin = type === 'address_password_login';
|
||||
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||
const label = address
|
||||
? `${formatAddressLabel(address)} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||
: localAddressT('savedMailbox', { index: index + 1 });
|
||||
const key = `local:${token}`;
|
||||
const option = { label, value: getOptionValue(key, 'local', token, address), address };
|
||||
if (token === jwt.value) {
|
||||
addressValue.value = option.value;
|
||||
}
|
||||
return option;
|
||||
@@ -153,7 +140,7 @@ const refreshAddressOptions = async () => {
|
||||
if (userJwt.value) {
|
||||
const userChildren = await buildUserOptions();
|
||||
if (userChildren.length > 0) {
|
||||
groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
|
||||
groups.push({ type: 'group', label: t('linkedMailboxes'), children: userChildren });
|
||||
}
|
||||
const userAddressSet = new Set(userChildren.map((item) => item.address));
|
||||
const localChildren = buildLocalOptions(userAddressSet);
|
||||
@@ -207,7 +194,7 @@ onMounted(async () => {
|
||||
await refreshAddressOptions();
|
||||
});
|
||||
|
||||
watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
||||
watch([userJwt, isTelegram, localAddressCache, () => settings.value.address, () => openSettings.value.addressPasswordLoginOnly], async () => {
|
||||
await refreshAddressOptions();
|
||||
});
|
||||
</script>
|
||||
@@ -215,7 +202,7 @@ watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
||||
<template>
|
||||
<n-flex class="address-row" align="center" justify="center" :wrap="true">
|
||||
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
|
||||
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
|
||||
:loading="addressLoading" :placeholder="t('selectMailbox')" @update:value="onAddressChange"
|
||||
class="address-select" />
|
||||
<slot name="actions" />
|
||||
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
|
||||
|
||||
@@ -55,6 +55,14 @@ const props = defineProps({
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
enableMailReadStatus: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
updateMailReadStatus: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
})
|
||||
|
||||
const localFilterKeyword = ref('')
|
||||
@@ -94,6 +102,29 @@ const data = computed(() => {
|
||||
});
|
||||
})
|
||||
|
||||
const openMail = (mail) => {
|
||||
curMail.value = mail
|
||||
if (mail?.is_unread !== 1 || !props.enableMailReadStatus) return
|
||||
mail.is_unread = 0
|
||||
void props.updateMailReadStatus(mail.id, false).catch(() => {
|
||||
mail.is_unread = 1
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCurrentMailUnread = async () => {
|
||||
if (!curMail.value || !props.enableMailReadStatus) return
|
||||
const mail = curMail.value
|
||||
const previousValue = mail.is_unread
|
||||
const isUnread = previousValue !== 1
|
||||
mail.is_unread = isUnread ? 1 : 0
|
||||
try {
|
||||
await props.updateMailReadStatus(mail.id, isUnread)
|
||||
message.success(t("success"))
|
||||
} catch {
|
||||
mail.is_unread = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
const canGoPrevMail = computed(() => {
|
||||
if (!curMail.value) return false
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
@@ -111,12 +142,12 @@ const prevMail = async () => {
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
curMail.value = data.value[currentIndex - 1]
|
||||
openMail(data.value[currentIndex - 1])
|
||||
} else if (page.value > 1) {
|
||||
page.value--
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[data.value.length - 1]
|
||||
openMail(data.value[data.value.length - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,12 +157,12 @@ const nextMail = async () => {
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex < data.value.length - 1) {
|
||||
curMail.value = data.value[currentIndex + 1]
|
||||
openMail(data.value[currentIndex + 1])
|
||||
} else if (count.value > page.value * pageSize.value) {
|
||||
page.value++
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[0]
|
||||
openMail(data.value[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +236,7 @@ const backFirstPageAndRefresh = async () => {
|
||||
await refresh();
|
||||
}
|
||||
|
||||
const clickRow = async (row) => {
|
||||
const clickRow = (row) => {
|
||||
if (multiActionMode.value) {
|
||||
row.checked = !row.checked;
|
||||
curMail.value = row;
|
||||
@@ -215,7 +246,7 @@ const clickRow = async (row) => {
|
||||
curMail.value = null;
|
||||
return;
|
||||
}
|
||||
curMail.value = row;
|
||||
openMail(row);
|
||||
};
|
||||
|
||||
|
||||
@@ -397,7 +428,7 @@ onBeforeUnmount(() => {
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
@@ -461,6 +492,7 @@ onBeforeUnmount(() => {
|
||||
style="overflow: auto; max-height: 100vh;">
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
|
||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
@@ -475,7 +507,7 @@ onBeforeUnmount(() => {
|
||||
<div v-else class="mail-list-scroll">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
@@ -536,7 +568,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="{ 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }">
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
@@ -567,6 +600,7 @@ onBeforeUnmount(() => {
|
||||
<n-card :bordered="false" embedded style="overflow: auto;">
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
|
||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||
:onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
@@ -676,6 +710,21 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-list-unread :deep(.n-thing-header__title) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mail-list-unread :deep(.n-thing-header__title)::before {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 8px;
|
||||
border-radius: 50%;
|
||||
background: #2080f0;
|
||||
content: '';
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
|
||||
@@ -34,6 +34,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableMailReadStatus: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 回调函数 props
|
||||
onDelete: {
|
||||
type: Function,
|
||||
@@ -50,6 +54,10 @@ const props = defineProps({
|
||||
onSaveToS3: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onUpdateMailReadStatus: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,6 +65,7 @@ const showTextMail = ref(preferShowTextMail.value);
|
||||
const showAttachments = ref(false);
|
||||
const curAttachments = ref([]);
|
||||
const attachmentLoding = ref(false);
|
||||
const readStatusUpdating = ref(false);
|
||||
const showFullscreen = ref(false);
|
||||
|
||||
// Per-mail consent, deliberately independent of the global setting: it only
|
||||
@@ -96,6 +105,15 @@ const handleForward = () => {
|
||||
props.onForward();
|
||||
};
|
||||
|
||||
const handleUpdateMailReadStatus = async () => {
|
||||
readStatusUpdating.value = true;
|
||||
try {
|
||||
await props.onUpdateMailReadStatus();
|
||||
} finally {
|
||||
readStatusUpdating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSaveToS3 = async (filename, blob) => {
|
||||
attachmentLoding.value = true;
|
||||
@@ -138,6 +156,11 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" :loading="readStatusUpdating"
|
||||
@click="handleUpdateMailReadStatus">
|
||||
{{ mail.is_unread === 1 ? t('markAsRead') : t('markAsUnread') }}
|
||||
</n-button>
|
||||
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(mail.raw)">
|
||||
<template #icon>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
type RuntimeConfig = Record<string, unknown>
|
||||
|
||||
const getRuntimeConfig = (): RuntimeConfig => {
|
||||
if (typeof document === 'undefined') return {}
|
||||
|
||||
const content = document.querySelector('#app-config')?.textContent
|
||||
if (!content?.trim()) return {}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(content)
|
||||
return config && typeof config === 'object' && !Array.isArray(config) ? config : {}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse app config', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeConfig = getRuntimeConfig()
|
||||
|
||||
const getStringConfigValue = (key: string, buildValue: string): string => {
|
||||
const runtimeValue = runtimeConfig[key]
|
||||
return typeof runtimeValue === 'string' ? runtimeValue : buildValue
|
||||
}
|
||||
|
||||
const getTelegramConfigValue = (buildValue: string): string | boolean => {
|
||||
const runtimeValue = runtimeConfig.IS_TELEGRAM
|
||||
return typeof runtimeValue === 'string' || typeof runtimeValue === 'boolean'
|
||||
? runtimeValue
|
||||
: buildValue
|
||||
}
|
||||
|
||||
export const APP_CONFIG = {
|
||||
API_BASE: getStringConfigValue('API_BASE', import.meta.env.VITE_API_BASE || ''),
|
||||
DEFAULT_LANG: getStringConfigValue('DEFAULT_LANG', import.meta.env.VITE_DEFAULT_LANG || ''),
|
||||
CF_WEB_ANALY_TOKEN: getStringConfigValue('CF_WEB_ANALY_TOKEN', import.meta.env.VITE_CF_WEB_ANALY_TOKEN || ''),
|
||||
IS_TELEGRAM: getTelegramConfigValue(import.meta.env.VITE_IS_TELEGRAM || ''),
|
||||
GOOGLE_AD_CLIENT: getStringConfigValue('GOOGLE_AD_CLIENT', import.meta.env.VITE_GOOGLE_AD_CLIENT || ''),
|
||||
GOOGLE_AD_SLOT: getStringConfigValue('GOOGLE_AD_SLOT', import.meta.env.VITE_GOOGLE_AD_SLOT || ''),
|
||||
} as const
|
||||
@@ -1,4 +1,20 @@
|
||||
export const deMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
|
||||
"views.index.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
"views.index.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||
"views.index.SendMail.recipientAddress": "Empfängeradresse",
|
||||
"views.index.SendMail.recipientName": "Empfängername (optional)",
|
||||
"views.index.SendMail.senderAddress": "Absenderadresse",
|
||||
"views.index.SendMail.senderName": "Absendername (optional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Von einer konfigurierten E-Mail-Adresse senden",
|
||||
"views.admin.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.admin.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
"views.admin.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||
"views.admin.SendMail.recipientAddress": "Empfängeradresse",
|
||||
"views.admin.SendMail.recipientName": "Empfängername (optional)",
|
||||
"views.admin.SendMail.senderAddress": "Absenderadresse",
|
||||
"views.admin.SendMail.senderName": "Absendername (optional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
@@ -17,10 +33,12 @@ export const deMessages = {
|
||||
"views.Index.about": "Über",
|
||||
"views.Admin.about": "Über",
|
||||
"views.Header.accessHeader": "Zugangspasswort",
|
||||
"views.Admin.account": "Konto",
|
||||
"views.Index.accountSettings": "E-Mail-Einstellungen",
|
||||
"views.Admin.account_settings": "Kontoeinstellungen",
|
||||
"views.index.SimpleIndex.accountSettings": "E-Mail-Einstellungen",
|
||||
"views.Header.admin": "Admin-Konsole",
|
||||
"views.Admin.mailbox_management": "Postfachverwaltung",
|
||||
"views.Admin.mailbox_list": "Postfächer",
|
||||
"views.Index.mailboxSettings": "Postfacheinstellungen",
|
||||
"views.Admin.mailbox_settings": "Globale Postfacheinstellungen",
|
||||
"views.index.SimpleIndex.mailboxSettings": "Postfacheinstellungen",
|
||||
"views.index.Attachment.action": "Aktion",
|
||||
"views.admin.SenderAccess.action": "Aktion",
|
||||
"views.user.UserSettings.actions": "Aktionen",
|
||||
@@ -34,7 +52,7 @@ export const deMessages = {
|
||||
"views.admin.Maintenance.customSqlTip": "Füge benutzerdefinierte DELETE-SQL-Anweisungen für die geplante Bereinigung hinzu. Pro Eintrag ist nur eine DELETE-Anweisung erlaubt.",
|
||||
"views.admin.Maintenance.addCustomSql": "Benutzerdefinierte SQL hinzufügen",
|
||||
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen",
|
||||
"components.AddressSelect.address": "Adresse",
|
||||
"components.AddressSelect.selectMailbox": "Postfach auswählen",
|
||||
"views.user.AddressManagement.address": "Adresse",
|
||||
"views.index.TelegramAddress.address": "Adresse",
|
||||
"views.admin.SenderAccess.address": "Adresse",
|
||||
@@ -70,7 +88,7 @@ export const deMessages = {
|
||||
"views.admin.Account.clearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente dieser E-Mail-Adresse leeren?",
|
||||
"views.admin.Account.multiDeleteTip": "Möchtest du wirklich die ausgewählten Adressen löschen?",
|
||||
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
|
||||
"views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
|
||||
"views.admin.Account.deleteTip": "Möchtest du wirklich dieses Postfach löschen?",
|
||||
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du 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?",
|
||||
@@ -107,7 +125,7 @@ export const deMessages = {
|
||||
"views.admin.AccountSettings.cancel": "Abbrechen",
|
||||
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
||||
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
||||
"views.user.AddressManagement.changeMailAddress": "Adresse wechseln",
|
||||
"views.user.AddressManagement.openMailbox": "Postfach öffnen",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
|
||||
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
|
||||
"views.index.AccountSettings.changePassword": "Adresspasswort ändern",
|
||||
@@ -146,8 +164,8 @@ export const deMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Kopieren",
|
||||
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
|
||||
"views.Footer.copyright": "Urheberrecht",
|
||||
"views.Admin.account_create": "Konto erstellen",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
|
||||
"views.Admin.mailbox_create": "E-Mail-Adresse erstellen",
|
||||
"views.admin.CreateAccount.createEmailAddress": "E-Mail-Adresse erstellen",
|
||||
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
|
||||
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
|
||||
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
|
||||
@@ -182,8 +200,8 @@ export const deMessages = {
|
||||
"views.admin.UserManagement.delete": "Löschen",
|
||||
"views.admin.AccountSettings.delete_rule": "Löschen",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Löschen",
|
||||
"views.index.AccountSettings.deleteAccount": "E-Mail-Adresse löschen",
|
||||
"views.admin.Account.deleteAccount": "Konto löschen",
|
||||
"views.index.AccountSettings.deleteMailbox": "Postfach löschen",
|
||||
"views.admin.Account.deleteMailbox": "Postfach löschen",
|
||||
"views.user.UserSettings.deletePasskey": "Passkey löschen",
|
||||
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
|
||||
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
|
||||
@@ -254,7 +272,7 @@ export const deMessages = {
|
||||
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
|
||||
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
|
||||
"views.common.Login.help": "Hilfe",
|
||||
"views.Header.home": "Startseite",
|
||||
"views.Header.mailbox": "Postfach",
|
||||
"views.index.SendMail.html": "HTML",
|
||||
"views.admin.SendMail.html": "HTML",
|
||||
"views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)",
|
||||
@@ -303,7 +321,7 @@ export const deMessages = {
|
||||
"views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||
"views.admin.CreateAccount.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||
"views.index.AddressBar.fetchAddressError": "Die E-Mail-Zugangsdaten sind ungültig oder das Konto existiert nicht. Es kann auch ein Netzwerkproblem sein. Bitte später erneut versuchen.",
|
||||
"views.Index.mailbox": "Posteingang",
|
||||
"views.Index.inbox": "Posteingang",
|
||||
"views.User.user_mail_box_tab": "Posteingang",
|
||||
"views.user.AddressManagement.mail_count": "E-Mail-Anzahl",
|
||||
"views.admin.Account.mail_count": "E-Mail-Anzahl",
|
||||
@@ -333,11 +351,11 @@ export const deMessages = {
|
||||
"views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren",
|
||||
"views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren",
|
||||
"views.admin.Account.multiDelete": "Mehrfach löschen",
|
||||
"views.user.AddressManagement.name": "Name",
|
||||
"views.user.AddressManagement.emailAddress": "E-Mail-Adresse",
|
||||
"views.index.AutoReply.name": "Name",
|
||||
"views.admin.Account.name": "Name",
|
||||
"views.admin.Account.emailAddress": "E-Mail-Adresse",
|
||||
"views.admin.UserOauth2Settings.name": "Name",
|
||||
"views.admin.UserAddressManagement.name": "Name",
|
||||
"views.admin.UserAddressManagement.emailAddress": "E-Mail-Adresse",
|
||||
"views.admin.Maintenance.sqlName": "Name",
|
||||
"views.index.AccountSettings.newPassword": "Neues Passwort",
|
||||
"views.admin.Account.newPassword": "Neues Passwort",
|
||||
@@ -386,6 +404,7 @@ export const deMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
|
||||
"views.admin.CreateAccount.generateName": "Zufälligen Namen erzeugen",
|
||||
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
|
||||
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
|
||||
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
|
||||
@@ -568,13 +587,13 @@ export const deMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Regex verwenden, um die E-Mail umzuwandeln. Beispiel: ^(.+)@old\\.com$ mit $1@new.com",
|
||||
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
|
||||
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
|
||||
"views.Header.user": "Benutzer",
|
||||
"views.Header.userCenter": "Benutzerbereich",
|
||||
"views.Admin.user": "Benutzer",
|
||||
"components.AddressSelect.userAddresses": "Benutzeradressen",
|
||||
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
|
||||
"components.AddressSelect.linkedMailboxes": "Verknüpfte Postfächer",
|
||||
"views.Admin.loginViaUserAdmin": "Benutzerkonto (Administratorrolle)",
|
||||
"views.admin.Statistics.userCount": "Benutzeranzahl",
|
||||
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
|
||||
"views.index.AddressBar.userLogin": "Benutzeranmeldung",
|
||||
"views.index.AddressBar.userCenter": "Benutzerbereich",
|
||||
"views.Admin.user_management": "Benutzerverwaltung",
|
||||
"views.Admin.user_settings": "Benutzereinstellungen",
|
||||
"views.User.user_settings": "Benutzereinstellungen",
|
||||
@@ -634,5 +653,15 @@ export const deMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
||||
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
||||
"components.AddressCredentialModal.username": "Benutzername"
|
||||
"components.AddressCredentialModal.username": "Benutzername",
|
||||
"views.User.send_mail": "E-Mail senden",
|
||||
"views.user.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
||||
"views.user.UserSendBox.sendbox": "Gesendet",
|
||||
"components.MailContentRenderer.markAsRead": "Als gelesen markieren",
|
||||
"components.MailContentRenderer.markAsUnread": "Als ungelesen markieren",
|
||||
"components.MailContentRenderer.loadRemoteImages": "Bilder laden",
|
||||
"components.MailContentRenderer.remoteImagesBlocked": "{count} externe Ressourcen wurden zum Schutz deiner Privatsphäre blockiert",
|
||||
"views.user.AddressManagement.itemCount": "Gesamt",
|
||||
"views.index.SimpleIndex.readStatusUpdated": "Lesestatus aktualisiert",
|
||||
"views.common.Appearance.autoLoadRemoteImages": "Externe Bilder im E-Mail-Inhalt automatisch laden"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
export const esMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
|
||||
"views.index.SendMail.composeMail": "Redactar correo",
|
||||
"views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||
"views.index.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||
"views.index.SendMail.recipientAddress": "Dirección del destinatario",
|
||||
"views.index.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||
"views.index.SendMail.senderAddress": "Dirección del remitente",
|
||||
"views.index.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Enviar desde una dirección de correo configurada",
|
||||
"views.admin.SendMail.composeMail": "Redactar correo",
|
||||
"views.admin.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||
"views.admin.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||
"views.admin.SendMail.recipientAddress": "Dirección del destinatario",
|
||||
"views.admin.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||
"views.admin.SendMail.senderAddress": "Dirección del remitente",
|
||||
"views.admin.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
@@ -17,10 +33,12 @@ export const esMessages = {
|
||||
"views.Index.about": "Acerca de",
|
||||
"views.Admin.about": "Acerca de",
|
||||
"views.Header.accessHeader": "Contraseña de acceso",
|
||||
"views.Admin.account": "Cuenta",
|
||||
"views.Index.accountSettings": "Configuración del correo",
|
||||
"views.Admin.account_settings": "Configuración de la cuenta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configuración del correo",
|
||||
"views.Header.admin": "Consola de administración",
|
||||
"views.Admin.mailbox_management": "Gestión de buzones",
|
||||
"views.Admin.mailbox_list": "Buzones",
|
||||
"views.Index.mailboxSettings": "Configuración del buzón",
|
||||
"views.Admin.mailbox_settings": "Configuración global de buzones",
|
||||
"views.index.SimpleIndex.mailboxSettings": "Configuración del buzón",
|
||||
"views.index.Attachment.action": "Acción",
|
||||
"views.admin.SenderAccess.action": "Acción",
|
||||
"views.user.UserSettings.actions": "Acciones",
|
||||
@@ -34,7 +52,7 @@ export const esMessages = {
|
||||
"views.admin.Maintenance.customSqlTip": "Añade sentencias SQL DELETE personalizadas para la limpieza programada. Solo se permite una sentencia DELETE por entrada.",
|
||||
"views.admin.Maintenance.addCustomSql": "Añadir SQL personalizada",
|
||||
"views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2",
|
||||
"components.AddressSelect.address": "Dirección",
|
||||
"components.AddressSelect.selectMailbox": "Seleccionar buzón",
|
||||
"views.user.AddressManagement.address": "Dirección",
|
||||
"views.index.TelegramAddress.address": "Dirección",
|
||||
"views.admin.SenderAccess.address": "Dirección",
|
||||
@@ -70,7 +88,7 @@ export const esMessages = {
|
||||
"views.admin.Account.clearSentItemsTip": "¿Seguro que quieres vaciar los enviados de este correo?",
|
||||
"views.admin.Account.multiDeleteTip": "¿Seguro que quieres eliminar las direcciones seleccionadas?",
|
||||
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
|
||||
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
|
||||
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este buzón?",
|
||||
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar 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?",
|
||||
@@ -107,7 +125,7 @@ export const esMessages = {
|
||||
"views.admin.AccountSettings.cancel": "Cancelar",
|
||||
"components.MailBox.cancelMultiAction": "Cancelar selección múltiple",
|
||||
"components.SendBox.cancelMultiAction": "Cancelar selección múltiple",
|
||||
"views.user.AddressManagement.changeMailAddress": "Cambiar dirección",
|
||||
"views.user.AddressManagement.openMailbox": "Abrir buzón",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
|
||||
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
|
||||
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
|
||||
@@ -146,8 +164,8 @@ export const esMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Copiar",
|
||||
"components.AiExtractInfo.copyFailed": "Error al copiar",
|
||||
"views.Footer.copyright": "Derechos de autor",
|
||||
"views.Admin.account_create": "Crear cuenta",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
|
||||
"views.Admin.mailbox_create": "Crear dirección de correo",
|
||||
"views.admin.CreateAccount.createEmailAddress": "Crear dirección de correo",
|
||||
"views.common.Login.getNewEmail": "Crear nuevo correo",
|
||||
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
|
||||
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
|
||||
@@ -182,8 +200,8 @@ export const esMessages = {
|
||||
"views.admin.UserManagement.delete": "Eliminar",
|
||||
"views.admin.AccountSettings.delete_rule": "Eliminar",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
|
||||
"views.index.AccountSettings.deleteAccount": "Eliminar dirección de correo",
|
||||
"views.admin.Account.deleteAccount": "Eliminar cuenta",
|
||||
"views.index.AccountSettings.deleteMailbox": "Eliminar buzón",
|
||||
"views.admin.Account.deleteMailbox": "Eliminar buzón",
|
||||
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
|
||||
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
|
||||
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
|
||||
@@ -254,7 +272,7 @@ export const esMessages = {
|
||||
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
|
||||
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
|
||||
"views.common.Login.help": "Ayuda",
|
||||
"views.Header.home": "Inicio",
|
||||
"views.Header.mailbox": "Buzón",
|
||||
"views.index.SendMail.html": "HTML",
|
||||
"views.admin.SendMail.html": "HTML",
|
||||
"views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)",
|
||||
@@ -303,7 +321,7 @@ export const esMessages = {
|
||||
"views.admin.Account.addressCredential": "Credencial de dirección de correo",
|
||||
"views.admin.CreateAccount.addressCredential": "Credencial de dirección de correo",
|
||||
"views.index.AddressBar.fetchAddressError": "La credencial del correo no es válida o la cuenta no existe; también puede ser un problema de red. Inténtalo más tarde.",
|
||||
"views.Index.mailbox": "Bandeja de entrada",
|
||||
"views.Index.inbox": "Bandeja de entrada",
|
||||
"views.User.user_mail_box_tab": "Bandeja de entrada",
|
||||
"views.user.AddressManagement.mail_count": "Cantidad de correos",
|
||||
"views.admin.Account.mail_count": "Cantidad de correos",
|
||||
@@ -333,11 +351,11 @@ export const esMessages = {
|
||||
"views.admin.Account.multiClearInbox": "Vaciar varias bandejas",
|
||||
"views.admin.Account.multiClearSentItems": "Vaciar varios enviados",
|
||||
"views.admin.Account.multiDelete": "Eliminar en lote",
|
||||
"views.user.AddressManagement.name": "Nombre",
|
||||
"views.user.AddressManagement.emailAddress": "Dirección de correo",
|
||||
"views.index.AutoReply.name": "Nombre",
|
||||
"views.admin.Account.name": "Nombre",
|
||||
"views.admin.Account.emailAddress": "Dirección de correo",
|
||||
"views.admin.UserOauth2Settings.name": "Nombre",
|
||||
"views.admin.UserAddressManagement.name": "Nombre",
|
||||
"views.admin.UserAddressManagement.emailAddress": "Dirección de correo",
|
||||
"views.admin.Maintenance.sqlName": "Nombre",
|
||||
"views.index.AccountSettings.newPassword": "Nueva contraseña",
|
||||
"views.admin.Account.newPassword": "Nueva contraseña",
|
||||
@@ -386,6 +404,7 @@ export const esMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
|
||||
"views.admin.CreateAccount.generateName": "Generar nombre aleatorio",
|
||||
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
|
||||
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
|
||||
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
|
||||
@@ -568,13 +587,13 @@ export const esMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Usa regex para transformar el correo. Ejemplo: ^(.+)@old\\.com$ con $1@new.com",
|
||||
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
|
||||
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
|
||||
"views.Header.user": "Usuario",
|
||||
"views.Header.userCenter": "Centro de usuario",
|
||||
"views.Admin.user": "Usuario",
|
||||
"components.AddressSelect.userAddresses": "Direcciones del usuario",
|
||||
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
|
||||
"components.AddressSelect.linkedMailboxes": "Buzones vinculados",
|
||||
"views.Admin.loginViaUserAdmin": "Cuenta de usuario (rol de administrador)",
|
||||
"views.admin.Statistics.userCount": "Cantidad de usuarios",
|
||||
"views.admin.UserManagement.user_email": "Correo del usuario",
|
||||
"views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
|
||||
"views.index.AddressBar.userCenter": "Centro de usuario",
|
||||
"views.Admin.user_management": "Gestión de usuarios",
|
||||
"views.Admin.user_settings": "Configuración de usuario",
|
||||
"views.User.user_settings": "Configuración de usuario",
|
||||
@@ -634,5 +653,15 @@ export const esMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
|
||||
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
|
||||
"components.AddressCredentialModal.username": "Usuario"
|
||||
"components.AddressCredentialModal.username": "Usuario",
|
||||
"views.User.send_mail": "Enviar correo",
|
||||
"views.user.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada",
|
||||
"views.user.UserSendBox.sendbox": "Enviados",
|
||||
"components.MailContentRenderer.markAsRead": "Marcar como leído",
|
||||
"components.MailContentRenderer.markAsUnread": "Marcar como no leído",
|
||||
"components.MailContentRenderer.loadRemoteImages": "Cargar imágenes",
|
||||
"components.MailContentRenderer.remoteImagesBlocked": "Se bloquearon {count} recursos remotos para proteger tu privacidad",
|
||||
"views.user.AddressManagement.itemCount": "Total",
|
||||
"views.index.SimpleIndex.readStatusUpdated": "Estado de lectura actualizado",
|
||||
"views.common.Appearance.autoLoadRemoteImages": "Cargar automáticamente las imágenes externas del correo"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
export const jaMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
|
||||
"views.index.SendMail.composeMail": "メールを作成",
|
||||
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
"views.index.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||
"views.index.SendMail.recipientAddress": "宛先メールアドレス",
|
||||
"views.index.SendMail.recipientName": "宛先名(任意)",
|
||||
"views.index.SendMail.senderAddress": "送信元メールアドレス",
|
||||
"views.index.SendMail.senderName": "送信者名(任意)",
|
||||
"views.admin.SendMail.adminComposeTip": "設定済みのメールアドレスから送信",
|
||||
"views.admin.SendMail.composeMail": "メールを作成",
|
||||
"views.admin.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
"views.admin.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||
"views.admin.SendMail.recipientAddress": "宛先メールアドレス",
|
||||
"views.admin.SendMail.recipientName": "宛先名(任意)",
|
||||
"views.admin.SendMail.senderAddress": "送信元メールアドレス",
|
||||
"views.admin.SendMail.senderName": "送信者名(任意)",
|
||||
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
@@ -17,10 +33,12 @@ export const jaMessages = {
|
||||
"views.Index.about": "概要",
|
||||
"views.Admin.about": "概要",
|
||||
"views.Header.accessHeader": "アクセス用パスワード",
|
||||
"views.Admin.account": "アカウント",
|
||||
"views.Index.accountSettings": "メール設定",
|
||||
"views.Admin.account_settings": "アカウント設定",
|
||||
"views.index.SimpleIndex.accountSettings": "メール設定",
|
||||
"views.Header.admin": "管理画面",
|
||||
"views.Admin.mailbox_management": "メールボックス管理",
|
||||
"views.Admin.mailbox_list": "メールボックス一覧",
|
||||
"views.Index.mailboxSettings": "メールボックス設定",
|
||||
"views.Admin.mailbox_settings": "メールボックスのグローバル設定",
|
||||
"views.index.SimpleIndex.mailboxSettings": "メールボックス設定",
|
||||
"views.index.Attachment.action": "操作",
|
||||
"views.admin.SenderAccess.action": "操作",
|
||||
"views.user.UserSettings.actions": "操作",
|
||||
@@ -34,7 +52,7 @@ export const jaMessages = {
|
||||
"views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。",
|
||||
"views.admin.Maintenance.addCustomSql": "カスタム SQL を追加",
|
||||
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加",
|
||||
"components.AddressSelect.address": "アドレス",
|
||||
"components.AddressSelect.selectMailbox": "メールボックスを選択",
|
||||
"views.user.AddressManagement.address": "アドレス",
|
||||
"views.index.TelegramAddress.address": "アドレス",
|
||||
"views.admin.SenderAccess.address": "アドレス",
|
||||
@@ -70,7 +88,7 @@ export const jaMessages = {
|
||||
"views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?",
|
||||
"views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?",
|
||||
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
|
||||
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
|
||||
"views.admin.Account.deleteTip": "このメールボックスを削除してもよろしいですか?",
|
||||
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
|
||||
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
|
||||
@@ -107,7 +125,7 @@ export const jaMessages = {
|
||||
"views.admin.AccountSettings.cancel": "キャンセル",
|
||||
"components.MailBox.cancelMultiAction": "複数選択をキャンセル",
|
||||
"components.SendBox.cancelMultiAction": "複数選択をキャンセル",
|
||||
"views.user.AddressManagement.changeMailAddress": "アドレスを変更",
|
||||
"views.user.AddressManagement.openMailbox": "メールボックスを開く",
|
||||
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
|
||||
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
|
||||
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
|
||||
@@ -146,8 +164,8 @@ export const jaMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "コピー",
|
||||
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
|
||||
"views.Footer.copyright": "著作権",
|
||||
"views.Admin.account_create": "アカウントを作成",
|
||||
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
|
||||
"views.Admin.mailbox_create": "メールアドレスを作成",
|
||||
"views.admin.CreateAccount.createEmailAddress": "メールアドレスを作成",
|
||||
"views.common.Login.getNewEmail": "新しいメールを作成",
|
||||
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
|
||||
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
|
||||
@@ -182,8 +200,8 @@ export const jaMessages = {
|
||||
"views.admin.UserManagement.delete": "削除",
|
||||
"views.admin.AccountSettings.delete_rule": "削除",
|
||||
"views.admin.Maintenance.deleteCustomSql": "削除",
|
||||
"views.index.AccountSettings.deleteAccount": "メールアドレスを削除",
|
||||
"views.admin.Account.deleteAccount": "アカウントを削除",
|
||||
"views.index.AccountSettings.deleteMailbox": "メールボックスを削除",
|
||||
"views.admin.Account.deleteMailbox": "メールボックスを削除",
|
||||
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
|
||||
"views.admin.AccountSettings.delete_success": "削除しました",
|
||||
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
|
||||
@@ -254,7 +272,7 @@ export const jaMessages = {
|
||||
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
|
||||
"views.common.Appearance.globalTabplacement": "全体タブ位置",
|
||||
"views.common.Login.help": "ヘルプ",
|
||||
"views.Header.home": "ホーム",
|
||||
"views.Header.mailbox": "メールボックス",
|
||||
"views.index.SendMail.html": "HTML",
|
||||
"views.admin.SendMail.html": "HTML",
|
||||
"views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)",
|
||||
@@ -303,7 +321,7 @@ export const jaMessages = {
|
||||
"views.admin.Account.addressCredential": "メールアドレス資格情報",
|
||||
"views.admin.CreateAccount.addressCredential": "メールアドレス資格情報",
|
||||
"views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。",
|
||||
"views.Index.mailbox": "受信箱",
|
||||
"views.Index.inbox": "受信箱",
|
||||
"views.User.user_mail_box_tab": "受信箱",
|
||||
"views.user.AddressManagement.mail_count": "メール数",
|
||||
"views.admin.Account.mail_count": "メール数",
|
||||
@@ -333,11 +351,11 @@ export const jaMessages = {
|
||||
"views.admin.Account.multiClearInbox": "受信箱を一括削除",
|
||||
"views.admin.Account.multiClearSentItems": "送信済みを一括削除",
|
||||
"views.admin.Account.multiDelete": "一括削除",
|
||||
"views.user.AddressManagement.name": "名前",
|
||||
"views.user.AddressManagement.emailAddress": "メールアドレス",
|
||||
"views.index.AutoReply.name": "名前",
|
||||
"views.admin.Account.name": "名前",
|
||||
"views.admin.Account.emailAddress": "メールアドレス",
|
||||
"views.admin.UserOauth2Settings.name": "名前",
|
||||
"views.admin.UserAddressManagement.name": "名前",
|
||||
"views.admin.UserAddressManagement.emailAddress": "メールアドレス",
|
||||
"views.admin.Maintenance.sqlName": "名前",
|
||||
"views.index.AccountSettings.newPassword": "新しいパスワード",
|
||||
"views.admin.Account.newPassword": "新しいパスワード",
|
||||
@@ -386,6 +404,7 @@ export const jaMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
|
||||
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
|
||||
"views.admin.CreateAccount.generateName": "ランダム名を生成",
|
||||
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
|
||||
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
|
||||
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
|
||||
@@ -568,13 +587,13 @@ export const jaMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com",
|
||||
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
|
||||
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
|
||||
"views.Header.user": "ユーザー",
|
||||
"views.Header.userCenter": "ユーザーセンター",
|
||||
"views.Admin.user": "ユーザー",
|
||||
"components.AddressSelect.userAddresses": "ユーザーのアドレス",
|
||||
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
|
||||
"components.AddressSelect.linkedMailboxes": "連携済みメールボックス",
|
||||
"views.Admin.loginViaUserAdmin": "ユーザーアカウント(管理者ロール)",
|
||||
"views.admin.Statistics.userCount": "ユーザー数",
|
||||
"views.admin.UserManagement.user_email": "ユーザーメール",
|
||||
"views.index.AddressBar.userLogin": "ユーザーログイン",
|
||||
"views.index.AddressBar.userCenter": "ユーザーセンター",
|
||||
"views.Admin.user_management": "ユーザー管理",
|
||||
"views.Admin.user_settings": "ユーザー設定",
|
||||
"views.User.user_settings": "ユーザー設定",
|
||||
@@ -634,5 +653,15 @@ export const jaMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
||||
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
||||
"components.AddressCredentialModal.username": "ユーザー名"
|
||||
"components.AddressCredentialModal.username": "ユーザー名",
|
||||
"views.User.send_mail": "メール送信",
|
||||
"views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
|
||||
"views.user.UserSendBox.sendbox": "送信済み",
|
||||
"components.MailContentRenderer.markAsRead": "既読にする",
|
||||
"components.MailContentRenderer.markAsUnread": "未読にする",
|
||||
"components.MailContentRenderer.loadRemoteImages": "画像を読み込む",
|
||||
"components.MailContentRenderer.remoteImagesBlocked": "プライバシー保護のため、{count} 件の外部リソースをブロックしました",
|
||||
"views.user.AddressManagement.itemCount": "合計",
|
||||
"views.index.SimpleIndex.readStatusUpdated": "既読状態を更新しました",
|
||||
"views.common.Appearance.autoLoadRemoteImages": "メール本文の外部画像を自動的に読み込む"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
export const ptBRMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
|
||||
"views.index.SendMail.composeMail": "Escrever e-mail",
|
||||
"views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||
"views.index.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||
"views.index.SendMail.recipientAddress": "Endereço do destinatário",
|
||||
"views.index.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||
"views.index.SendMail.senderAddress": "Endereço do remetente",
|
||||
"views.index.SendMail.senderName": "Nome do remetente (opcional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Enviar usando um endereço de e-mail configurado",
|
||||
"views.admin.SendMail.composeMail": "Escrever e-mail",
|
||||
"views.admin.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||
"views.admin.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||
"views.admin.SendMail.recipientAddress": "Endereço do destinatário",
|
||||
"views.admin.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||
"views.admin.SendMail.senderAddress": "Endereço do remetente",
|
||||
"views.admin.SendMail.senderName": "Nome do remetente (opcional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
@@ -17,10 +33,12 @@ export const ptBRMessages = {
|
||||
"views.Index.about": "Sobre",
|
||||
"views.Admin.about": "Sobre",
|
||||
"views.Header.accessHeader": "Senha de acesso",
|
||||
"views.Admin.account": "Conta",
|
||||
"views.Index.accountSettings": "Configurações de e-mail",
|
||||
"views.Admin.account_settings": "Configurações da conta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configurações de e-mail",
|
||||
"views.Header.admin": "Painel administrativo",
|
||||
"views.Admin.mailbox_management": "Gerenciamento de caixas de correio",
|
||||
"views.Admin.mailbox_list": "Caixas de correio",
|
||||
"views.Index.mailboxSettings": "Configurações da caixa de correio",
|
||||
"views.Admin.mailbox_settings": "Configurações globais de caixas de correio",
|
||||
"views.index.SimpleIndex.mailboxSettings": "Configurações da caixa de correio",
|
||||
"views.index.Attachment.action": "Ação",
|
||||
"views.admin.SenderAccess.action": "Ação",
|
||||
"views.user.UserSettings.actions": "Ações",
|
||||
@@ -34,7 +52,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Maintenance.customSqlTip": "Adicione instruções SQL DELETE personalizadas para a limpeza agendada. Apenas uma instrução DELETE é permitida por item.",
|
||||
"views.admin.Maintenance.addCustomSql": "Adicionar SQL personalizada",
|
||||
"views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2",
|
||||
"components.AddressSelect.address": "Endereço",
|
||||
"components.AddressSelect.selectMailbox": "Selecionar caixa de correio",
|
||||
"views.user.AddressManagement.address": "Endereço",
|
||||
"views.index.TelegramAddress.address": "Endereço",
|
||||
"views.admin.SenderAccess.address": "Endereço",
|
||||
@@ -70,7 +88,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Account.clearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados deste e-mail?",
|
||||
"views.admin.Account.multiDeleteTip": "Tem certeza de que deseja excluir os endereços selecionados?",
|
||||
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
|
||||
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
|
||||
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir esta caixa de correio?",
|
||||
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir 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?",
|
||||
@@ -107,7 +125,7 @@ export const ptBRMessages = {
|
||||
"views.admin.AccountSettings.cancel": "Cancelar",
|
||||
"components.MailBox.cancelMultiAction": "Cancelar múltipla seleção",
|
||||
"components.SendBox.cancelMultiAction": "Cancelar múltipla seleção",
|
||||
"views.user.AddressManagement.changeMailAddress": "Alterar endereço",
|
||||
"views.user.AddressManagement.openMailbox": "Abrir caixa de correio",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
|
||||
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
|
||||
"views.index.AccountSettings.changePassword": "Alterar senha do endereço",
|
||||
@@ -146,8 +164,8 @@ export const ptBRMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Copiar",
|
||||
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
|
||||
"views.Footer.copyright": "Direitos autorais",
|
||||
"views.Admin.account_create": "Criar conta",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
|
||||
"views.Admin.mailbox_create": "Criar endereço de e-mail",
|
||||
"views.admin.CreateAccount.createEmailAddress": "Criar endereço de e-mail",
|
||||
"views.common.Login.getNewEmail": "Criar novo e-mail",
|
||||
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
|
||||
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
|
||||
@@ -182,8 +200,8 @@ export const ptBRMessages = {
|
||||
"views.admin.UserManagement.delete": "Excluir",
|
||||
"views.admin.AccountSettings.delete_rule": "Excluir",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Excluir",
|
||||
"views.index.AccountSettings.deleteAccount": "Excluir endereço de e-mail",
|
||||
"views.admin.Account.deleteAccount": "Excluir conta",
|
||||
"views.index.AccountSettings.deleteMailbox": "Excluir caixa de correio",
|
||||
"views.admin.Account.deleteMailbox": "Excluir caixa de correio",
|
||||
"views.user.UserSettings.deletePasskey": "Excluir passkey",
|
||||
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
|
||||
"views.admin.UserManagement.deleteUser": "Excluir usuário",
|
||||
@@ -254,7 +272,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
|
||||
"views.common.Appearance.globalTabplacement": "Posição global das abas",
|
||||
"views.common.Login.help": "Ajuda",
|
||||
"views.Header.home": "Início",
|
||||
"views.Header.mailbox": "Caixa de correio",
|
||||
"views.index.SendMail.html": "HTML",
|
||||
"views.admin.SendMail.html": "HTML",
|
||||
"views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)",
|
||||
@@ -303,7 +321,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Account.addressCredential": "Credencial do endereço de e-mail",
|
||||
"views.admin.CreateAccount.addressCredential": "Credencial do endereço de e-mail",
|
||||
"views.index.AddressBar.fetchAddressError": "A credencial do e-mail é inválida ou a conta não existe; também pode ser um problema de rede. Tente novamente mais tarde.",
|
||||
"views.Index.mailbox": "Caixa de entrada",
|
||||
"views.Index.inbox": "Caixa de entrada",
|
||||
"views.User.user_mail_box_tab": "Caixa de entrada",
|
||||
"views.user.AddressManagement.mail_count": "Quantidade de e-mails",
|
||||
"views.admin.Account.mail_count": "Quantidade de e-mails",
|
||||
@@ -333,11 +351,11 @@ export const ptBRMessages = {
|
||||
"views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada",
|
||||
"views.admin.Account.multiClearSentItems": "Limpar vários itens enviados",
|
||||
"views.admin.Account.multiDelete": "Excluir em lote",
|
||||
"views.user.AddressManagement.name": "Nome",
|
||||
"views.user.AddressManagement.emailAddress": "Endereço de e-mail",
|
||||
"views.index.AutoReply.name": "Nome",
|
||||
"views.admin.Account.name": "Nome",
|
||||
"views.admin.Account.emailAddress": "Endereço de e-mail",
|
||||
"views.admin.UserOauth2Settings.name": "Nome",
|
||||
"views.admin.UserAddressManagement.name": "Nome",
|
||||
"views.admin.UserAddressManagement.emailAddress": "Endereço de e-mail",
|
||||
"views.admin.Maintenance.sqlName": "Nome",
|
||||
"views.index.AccountSettings.newPassword": "Nova senha",
|
||||
"views.admin.Account.newPassword": "Nova senha",
|
||||
@@ -386,6 +404,7 @@ export const ptBRMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
|
||||
"views.admin.CreateAccount.generateName": "Gerar nome aleatório",
|
||||
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
|
||||
"views.user.UserLogin.pleaseInputCode": "Informe o código",
|
||||
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
|
||||
@@ -568,13 +587,13 @@ export const ptBRMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Use regex para transformar o e-mail. Exemplo: ^(.+)@old\\.com$ com $1@new.com",
|
||||
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
|
||||
"views.common.Appearance.useUTCDate": "Usar data UTC",
|
||||
"views.Header.user": "Usuário",
|
||||
"views.Header.userCenter": "Central do usuário",
|
||||
"views.Admin.user": "Usuário",
|
||||
"components.AddressSelect.userAddresses": "Endereços do usuário",
|
||||
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
|
||||
"components.AddressSelect.linkedMailboxes": "Caixas de correio vinculadas",
|
||||
"views.Admin.loginViaUserAdmin": "Conta de usuário (função de administrador)",
|
||||
"views.admin.Statistics.userCount": "Quantidade de usuários",
|
||||
"views.admin.UserManagement.user_email": "E-mail do usuário",
|
||||
"views.index.AddressBar.userLogin": "Login do usuário",
|
||||
"views.index.AddressBar.userCenter": "Central do usuário",
|
||||
"views.Admin.user_management": "Gerenciamento de usuários",
|
||||
"views.Admin.user_settings": "Configurações do usuário",
|
||||
"views.User.user_settings": "Configurações do usuário",
|
||||
@@ -634,5 +653,15 @@ export const ptBRMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
|
||||
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
|
||||
"components.AddressCredentialModal.username": "Nome de usuário"
|
||||
"components.AddressCredentialModal.username": "Nome de usuário",
|
||||
"views.User.send_mail": "Enviar e-mail",
|
||||
"views.user.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado",
|
||||
"views.user.UserSendBox.sendbox": "Enviados",
|
||||
"components.MailContentRenderer.markAsRead": "Marcar como lido",
|
||||
"components.MailContentRenderer.markAsUnread": "Marcar como não lido",
|
||||
"components.MailContentRenderer.loadRemoteImages": "Carregar imagens",
|
||||
"components.MailContentRenderer.remoteImagesBlocked": "{count} recursos externos foram bloqueados para proteger sua privacidade",
|
||||
"views.user.AddressManagement.itemCount": "Total",
|
||||
"views.index.SimpleIndex.readStatusUpdated": "Status de leitura atualizado",
|
||||
"views.common.Appearance.autoLoadRemoteImages": "Carregar automaticamente imagens externas no corpo do e-mail"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
import { LOCALE_REGISTRY, SUPPORTED_LOCALES } from './locale-registry'
|
||||
import { APP_CONFIG } from '../config'
|
||||
|
||||
export { SUPPORTED_LOCALES } from './locale-registry'
|
||||
export type { SupportedLocale } from './locale-registry'
|
||||
|
||||
import type { SupportedLocale } from './locale-registry'
|
||||
|
||||
export const DEFAULT_LOCALE: SupportedLocale = 'zh'
|
||||
export const FALLBACK_LOCALE: SupportedLocale = 'zh'
|
||||
export const PREFERRED_LOCALE_STORAGE_KEY = 'preferredLocale'
|
||||
export const EMPTY_LOCALE_MESSAGES = Object.fromEntries(
|
||||
@@ -29,6 +29,9 @@ export const resolveSupportedLocale = (locale: string | null | undefined): Suppo
|
||||
return null
|
||||
}
|
||||
|
||||
export const DEFAULT_LOCALE: SupportedLocale = resolveSupportedLocale(APP_CONFIG.DEFAULT_LANG)
|
||||
|| FALLBACK_LOCALE
|
||||
|
||||
export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => {
|
||||
if (!locale) return null
|
||||
const normalizedLocale = locale.trim().toLowerCase()
|
||||
|
||||
@@ -4,6 +4,7 @@ import User from '../views/User.vue'
|
||||
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
|
||||
import i18n from '../i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
getBrowserLocales,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
resolveSupportedLocale,
|
||||
} from '../i18n/utils'
|
||||
|
||||
const { jwt, preferredLocale } = useGlobalState()
|
||||
const { jwt, preferredLocale, openSettings } = useGlobalState()
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -27,6 +28,11 @@ const router = createRouter({
|
||||
alias: '/:lang/user',
|
||||
component: User
|
||||
},
|
||||
{
|
||||
path: '/redeem',
|
||||
alias: '/:lang/redeem',
|
||||
component: () => import('../views/Redeem.vue')
|
||||
},
|
||||
{
|
||||
path: '/user/oauth2/callback',
|
||||
alias: '/:lang/user/oauth2/callback',
|
||||
@@ -50,7 +56,7 @@ const router = createRouter({
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const routeLocale = resolveSupportedLocale(to.path.split('/')[1])
|
||||
const resolvedLocale = routeLocale || DEFAULT_LOCALE
|
||||
i18n.global.locale.value = resolvedLocale
|
||||
@@ -64,7 +70,12 @@ router.beforeEach((to, from, next) => {
|
||||
if (Object.prototype.hasOwnProperty.call(to.query, 'jwt')) {
|
||||
const jwtQuery = Array.isArray(to.query.jwt) ? to.query.jwt[0] : to.query.jwt
|
||||
if (typeof jwtQuery === 'string') {
|
||||
jwt.value = jwtQuery
|
||||
try {
|
||||
const config = openSettings.value.fetched ? openSettings.value : await api.fetch('/open_api/settings');
|
||||
if (!config.addressPasswordLoginOnly) jwt.value = jwtQuery;
|
||||
} catch {
|
||||
// Do not import a login link until the server policy is known.
|
||||
}
|
||||
}
|
||||
const query = { ...to.query }
|
||||
delete query.jwt
|
||||
|
||||
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
|
||||
disableAnonymousUserCreateEmail: false,
|
||||
disableCustomAddressName: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableMailReadStatus: false,
|
||||
enableAutoReply: false,
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
@@ -41,7 +42,10 @@ export const useGlobalState = createGlobalState(
|
||||
showGithubForUser: true,
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
addressPasswordLoginOnly: false,
|
||||
enableAgentEmailInfo: false,
|
||||
enableRedeemCode: false,
|
||||
redeemCodeUrl: '',
|
||||
smtpImapProxyConfig: {
|
||||
smtp: {
|
||||
host: '',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useBreakpoint, useMemo } from 'vooks'
|
||||
|
||||
export function useIsMobile() {
|
||||
const breakpointRef = useBreakpoint()
|
||||
const breakpointRef = useBreakpoint({ xs: 0, s: 768 })
|
||||
return useMemo(() => {
|
||||
return breakpointRef.value === 'xs'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export type CachedAddress = {
|
||||
token: string;
|
||||
address?: string;
|
||||
type?: 'address_password_login' | null;
|
||||
};
|
||||
|
||||
export const getCachedAddresses = (cache: (string | CachedAddress)[]): CachedAddress[] => cache
|
||||
.map(entry => typeof entry === 'string' ? { token: entry } : entry)
|
||||
.filter(entry => typeof entry?.token === 'string' && entry.token);
|
||||
|
||||
export const updateLocalAddressCache = (
|
||||
cache: (string | CachedAddress)[], token: string,
|
||||
{ address, type }: { address: string; type?: 'address_password_login' },
|
||||
) => {
|
||||
const entries = getCachedAddresses(cache);
|
||||
const loginType = type ?? null;
|
||||
const existing = entries.find(entry => entry.token === token
|
||||
|| (entry.address === address && entry.type === loginType));
|
||||
const updated = { token, address, type: loginType };
|
||||
if (!existing) return [...entries, updated];
|
||||
return entries.flatMap(entry => {
|
||||
if (entry === existing) return [updated];
|
||||
if (entry.token === token || (entry.address === address && entry.type === loginType)) return [];
|
||||
return [entry];
|
||||
});
|
||||
};
|
||||
@@ -30,6 +30,7 @@ import MailWebhook from './admin/MailWebhook.vue';
|
||||
import WorkerConfig from './admin/WorkerConfig.vue';
|
||||
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
|
||||
import AiExtractSettings from './admin/AiExtractSettings.vue';
|
||||
import RedeemCodes from './admin/RedeemCodes.vue';
|
||||
|
||||
const {
|
||||
adminAuth, showAdminAuth, adminTab, loading,
|
||||
@@ -106,7 +107,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="userSettings.fetched">
|
||||
<div v-if="openSettings.fetched && userSettings.fetched">
|
||||
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
|
||||
preset="dialog" :title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
@@ -124,7 +125,7 @@ onMounted(async () => {
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
@@ -135,15 +136,15 @@ onMounted(async () => {
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<n-tab-pane name="account" :tab="t('mailbox_management')">
|
||||
<n-tabs key="account-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<n-tab-pane name="account" :tab="t('mailbox_list')">
|
||||
<Account />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_create" :tab="t('account_create')">
|
||||
<n-tab-pane name="account_create" :tab="t('mailbox_create')">
|
||||
<CreateAccount />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||
@@ -201,6 +202,9 @@ onMounted(async () => {
|
||||
<n-tab-pane name="statistics" :tab="t('statistics')">
|
||||
<Statistics />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableRedeemCode" name="redeemCodes" :tab="t('redeemCodes')">
|
||||
<RedeemCodes />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<n-tabs key="maintenance-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
AdminPanelSettingsFilled, MonitorHeartFilled,
|
||||
KeyboardArrowDownOutlined, OpenInNewOutlined
|
||||
} from '@vicons/material'
|
||||
import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
import { Envelope, GithubAlt, Language, User } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
@@ -123,8 +123,8 @@ const menuOptions = computed(() => [
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => t('home'),
|
||||
icon: () => h(NIcon, { component: Home })
|
||||
default: () => t('mailbox'),
|
||||
icon: () => h(NIcon, { component: Envelope })
|
||||
}),
|
||||
key: "home"
|
||||
},
|
||||
@@ -142,7 +142,7 @@ const menuOptions = computed(() => [
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => t('user'),
|
||||
default: () => t('userCenter'),
|
||||
icon: () => h(NIcon, { component: User }),
|
||||
}
|
||||
),
|
||||
@@ -165,7 +165,7 @@ const menuOptions = computed(() => [
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => "Admin",
|
||||
default: () => t('admin'),
|
||||
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
|
||||
}
|
||||
),
|
||||
@@ -331,6 +331,32 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-page-header) {
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
:deep(.n-page-header__main) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.n-page-header__title) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.n-page-header__title h3) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.n-page-header__extra) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.n-layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -436,7 +462,7 @@ onMounted(async () => {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:deep(.n-page-header) {
|
||||
padding: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
:deep(.n-page-header__title) {
|
||||
@@ -444,10 +470,11 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
:deep(.n-page-header__title h3) {
|
||||
max-width: calc(100vw - 136px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: calc(100vw - 104px);
|
||||
margin: 0;
|
||||
font-size: clamp(16px, 5vw, 20px);
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -45,6 +45,14 @@ const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const updateMailReadStatus = async (id, isUnread) => {
|
||||
await api.fetch(`/api/mails/${id}/read`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isUnread }),
|
||||
showLoading: false
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSenboxMail = async (curMailId) => {
|
||||
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
@@ -116,7 +124,7 @@ onMounted(() => {
|
||||
{{ t('enterSimpleMode') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<n-tab-pane name="mailbox" :tab="t('mailbox')">
|
||||
<n-tab-pane name="mailbox" :tab="t('inbox')">
|
||||
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="mailIdQuery" />
|
||||
@@ -127,7 +135,8 @@ onMounted(() => {
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
|
||||
:enableMailReadStatus="openSettings.enableMailReadStatus" :updateMailReadStatus="updateMailReadStatus" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
@@ -136,7 +145,7 @@ onMounted(() => {
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
|
||||
<n-tab-pane name="accountSettings" :tab="t('mailboxSettings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="appearance" :tab="t('appearance')">
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
AlternateEmailOutlined,
|
||||
ArrowBackOutlined,
|
||||
CheckCircleOutlined,
|
||||
RedeemOutlined,
|
||||
SendOutlined,
|
||||
} from '@vicons/material'
|
||||
|
||||
import { api } from '../api'
|
||||
import AddressCredentialContent from '../components/AddressCredentialContent.vue'
|
||||
import { useGlobalState } from '../store'
|
||||
import { useScopedI18n } from '../i18n/app'
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
const { locale, t } = useScopedI18n('views.Redeem')
|
||||
const {
|
||||
jwt,
|
||||
settings,
|
||||
openSettings,
|
||||
userSettings,
|
||||
addressPassword,
|
||||
showAddressCredential,
|
||||
} = useGlobalState()
|
||||
|
||||
const code = ref('')
|
||||
const codeInfo = ref(null)
|
||||
const redeemStarted = ref(false)
|
||||
const completed = ref(null)
|
||||
const targetUserEmail = ref('')
|
||||
const targetAddress = ref(settings.value.address || '')
|
||||
const emailName = ref('')
|
||||
const emailDomain = ref('')
|
||||
const subdomainMode = ref('normal')
|
||||
const customSubdomain = ref('')
|
||||
let latestQueryRequest = 0
|
||||
|
||||
const activeRedeemType = computed(() => completed.value?.type || codeInfo.value?.redeem_type || '')
|
||||
const codeStatusType = computed(() => {
|
||||
if (codeInfo.value?.status === 'expired') return 'error'
|
||||
return codeInfo.value?.status === 'unused' ? 'success' : 'default'
|
||||
})
|
||||
const redeemTypes = computed(() => [
|
||||
{
|
||||
type: 'role',
|
||||
icon: RedeemOutlined,
|
||||
title: t('roleTitle'),
|
||||
},
|
||||
{
|
||||
type: 'send_balance',
|
||||
icon: SendOutlined,
|
||||
title: t('balanceTitle'),
|
||||
},
|
||||
{
|
||||
type: 'address_prefix_once',
|
||||
icon: AlternateEmailOutlined,
|
||||
title: t('prefixTitle'),
|
||||
},
|
||||
])
|
||||
const redeemCodeUrl = computed(() => {
|
||||
try {
|
||||
const url = new URL(openSettings.value.redeemCodeUrl)
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const domainsOptions = computed(() => {
|
||||
const allowed = openSettings.value.defaultDomains?.length
|
||||
? openSettings.value.defaultDomains
|
||||
: openSettings.value.domains.map((item) => item.value)
|
||||
return openSettings.value.domains.filter((item) => allowed.includes(item.value))
|
||||
})
|
||||
|
||||
const canUseRandomSubdomain = computed(() => (
|
||||
openSettings.value.randomSubdomainDomains?.includes(emailDomain.value)
|
||||
))
|
||||
const addressNameMaxLength = computed(() => Math.max(
|
||||
(openSettings.value.maxAddressLen || 30) - (codeInfo.value?.value?.length || 0),
|
||||
1,
|
||||
))
|
||||
const addressNameMinLength = computed(() => Math.min(
|
||||
addressNameMaxLength.value,
|
||||
Math.max(
|
||||
(openSettings.value.minAddressLen || 1) - (codeInfo.value?.value?.length || 0),
|
||||
1,
|
||||
),
|
||||
))
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) subdomainMode.value = 'normal'
|
||||
})
|
||||
|
||||
watch(addressNameMaxLength, (maxLength) => {
|
||||
emailName.value = emailName.value.slice(0, maxLength)
|
||||
})
|
||||
|
||||
watch(domainsOptions, (options) => {
|
||||
if (!options.some((item) => item.value === emailDomain.value)) {
|
||||
emailDomain.value = options[0]?.value || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(code, () => {
|
||||
latestQueryRequest += 1
|
||||
codeInfo.value = null
|
||||
redeemStarted.value = false
|
||||
})
|
||||
|
||||
watch(() => userSettings.value.user_email, (email) => {
|
||||
if (!targetUserEmail.value) targetUserEmail.value = email || ''
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => settings.value.address, (address) => {
|
||||
if (!targetAddress.value) targetAddress.value = address || ''
|
||||
}, { immediate: true })
|
||||
|
||||
const loadContext = async () => {
|
||||
if (!openSettings.value.fetched) {
|
||||
await api.getOpenSettings(message, notification)
|
||||
}
|
||||
if (!openSettings.value.enableRedeemCode) {
|
||||
await router.replace(getRouterPathWithLang('/', locale.value))
|
||||
return
|
||||
}
|
||||
if (jwt.value && !settings.value.address) {
|
||||
try {
|
||||
await api.getSettings()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryCode = async () => {
|
||||
const queriedCode = code.value.trim()
|
||||
if (!queriedCode) return
|
||||
const requestId = ++latestQueryRequest
|
||||
try {
|
||||
const result = await api.fetch('/redeem_api/query', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: queriedCode }),
|
||||
})
|
||||
if (requestId !== latestQueryRequest || code.value.trim() !== queriedCode) return
|
||||
codeInfo.value = result
|
||||
redeemStarted.value = false
|
||||
completed.value = null
|
||||
await loadContext()
|
||||
} catch (error) {
|
||||
if (requestId !== latestQueryRequest) return
|
||||
codeInfo.value = null
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const resetRedemption = () => {
|
||||
code.value = ''
|
||||
codeInfo.value = null
|
||||
redeemStarted.value = false
|
||||
completed.value = null
|
||||
}
|
||||
|
||||
const redeem = async () => {
|
||||
if (!codeInfo.value) return
|
||||
try {
|
||||
let result
|
||||
if (codeInfo.value.redeem_type === 'role') {
|
||||
if (!targetUserEmail.value.trim()) return
|
||||
result = await api.fetch('/redeem_api/redeem', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: code.value,
|
||||
user_email: targetUserEmail.value.trim(),
|
||||
}),
|
||||
})
|
||||
} else if (codeInfo.value.redeem_type === 'send_balance') {
|
||||
if (!targetAddress.value.trim()) return
|
||||
result = await api.fetch('/redeem_api/redeem', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: code.value,
|
||||
address: targetAddress.value.trim(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
completed.value = result
|
||||
codeInfo.value = null
|
||||
if (result?.type === 'role'
|
||||
&& result.user_email.toLowerCase() === userSettings.value.user_email?.trim().toLowerCase()
|
||||
) {
|
||||
await api.getUserSettings(message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const generateName = () => {
|
||||
const length = Math.min(
|
||||
addressNameMaxLength.value,
|
||||
Math.max(addressNameMinLength.value, 8),
|
||||
)
|
||||
const charset = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||
emailName.value = Array.from(bytes, (value) => charset[value % charset.length]).join('')
|
||||
}
|
||||
|
||||
const createAddress = async () => {
|
||||
if (!codeInfo.value || codeInfo.value.redeem_type !== 'address_prefix_once') return
|
||||
try {
|
||||
const domain = subdomainMode.value === 'custom'
|
||||
? `${customSubdomain.value.trim()}.${emailDomain.value}`
|
||||
: emailDomain.value
|
||||
const result = await api.fetch('/redeem_api/redeem', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: code.value,
|
||||
name: openSettings.value.disableCustomAddressName ? '' : emailName.value,
|
||||
domain,
|
||||
enableRandomSubdomain: subdomainMode.value === 'random',
|
||||
}),
|
||||
})
|
||||
completed.value = result
|
||||
codeInfo.value = null
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const showRedeemResult = async () => {
|
||||
try {
|
||||
completed.value = await api.fetch('/redeem_api/result', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: code.value }),
|
||||
})
|
||||
codeInfo.value = null
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const useRedeemedAddress = async () => {
|
||||
if (completed.value?.type !== 'address_prefix_once') return
|
||||
jwt.value = completed.value.jwt
|
||||
addressPassword.value = completed.value.password || ''
|
||||
await api.getSettings()
|
||||
showAddressCredential.value = true
|
||||
await router.push(getRouterPathWithLang('/', locale.value))
|
||||
}
|
||||
|
||||
const goBack = async () => {
|
||||
await router.push(getRouterPathWithLang('/', locale.value))
|
||||
}
|
||||
|
||||
onMounted(loadContext)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="openSettings.enableRedeemCode" class="redeem-page">
|
||||
<section class="redeem-shell">
|
||||
<n-card class="redeem-card" :bordered="false" embedded>
|
||||
<n-button text class="back-link" @click="goBack">
|
||||
<template #icon>
|
||||
<n-icon :component="ArrowBackOutlined" />
|
||||
</template>
|
||||
{{ t('back') }}
|
||||
</n-button>
|
||||
<div class="heading-copy">
|
||||
<div class="heading-icon">
|
||||
<n-icon :component="RedeemOutlined" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{{ t('title') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="type-section">
|
||||
<div class="section-label">{{ t('categoryTitle') }}</div>
|
||||
<div class="type-grid">
|
||||
<div v-for="item in redeemTypes" :key="item.type" class="type-card"
|
||||
:class="{ active: activeRedeemType === item.type }">
|
||||
<n-icon :component="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-divider />
|
||||
<div v-if="completed" class="success-state">
|
||||
<n-icon :component="CheckCircleOutlined" />
|
||||
<h2>{{ t('success') }}</h2>
|
||||
<p v-if="completed.type === 'role'">
|
||||
{{ t('roleSuccess', { role: completed.role, email: completed.user_email }) }}
|
||||
</p>
|
||||
<p v-else-if="completed.type === 'send_balance'">
|
||||
{{ t('balanceSuccess', { amount: completed.amount, address: completed.address }) }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<p>{{ t('addressSuccess') }}</p>
|
||||
<AddressCredentialContent class="credential-result" :address="completed.address"
|
||||
:jwt="completed.jwt" :address-password="completed.password" />
|
||||
<n-button type="primary" block @click="useRedeemedAddress">
|
||||
{{ t('openAddress') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<n-button type="primary" secondary @click="resetRedemption">
|
||||
{{ t('redeemAnother') }}
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="redeem-form">
|
||||
<n-form-item :label="t('codeLabel')" :show-feedback="false">
|
||||
<n-input data-testid="redeem-code-input" v-model:value="code" size="large" :placeholder="t('codePlaceholder')"
|
||||
@keyup.enter="queryCode" />
|
||||
</n-form-item>
|
||||
<n-button v-if="!codeInfo" type="primary" size="large" block :disabled="!code.trim()"
|
||||
@click="queryCode">
|
||||
{{ t('checkCode') }}
|
||||
</n-button>
|
||||
<n-button v-if="redeemCodeUrl" data-testid="redeem-code-link" class="get-code-link"
|
||||
text tag="a" target="_blank" rel="noopener noreferrer" :href="redeemCodeUrl">
|
||||
{{ t('getCode') }}
|
||||
</n-button>
|
||||
|
||||
<div v-if="codeInfo" class="redemption-detail">
|
||||
<n-divider />
|
||||
|
||||
<template v-if="codeInfo.redeem_type === 'role'">
|
||||
<div class="detail-heading">
|
||||
<n-icon :component="RedeemOutlined" />
|
||||
<div>
|
||||
<h2>{{ t('roleTitle') }}</h2>
|
||||
<p>{{ t('roleDescription', { role: codeInfo.value }) }}</p>
|
||||
</div>
|
||||
<n-tag :type="codeStatusType" :bordered="false">
|
||||
{{ t(codeInfo.status) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<n-button v-if="codeInfo.status === 'unused' && !redeemStarted" data-testid="redeem-now" type="primary" block
|
||||
@click="redeemStarted = true">
|
||||
{{ t('redeemNow') }}
|
||||
</n-button>
|
||||
<template v-else-if="codeInfo.status === 'unused'">
|
||||
<n-form-item :label="t('userEmail')">
|
||||
<n-input data-testid="redeem-user-email" v-model:value="targetUserEmail"
|
||||
:placeholder="t('userEmailPlaceholder')" />
|
||||
</n-form-item>
|
||||
<n-button type="primary" block :disabled="!targetUserEmail.trim()" @click="redeem">
|
||||
{{ t('confirmRole') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<n-button v-else-if="codeInfo.status === 'redeemed'" data-testid="redeem-result"
|
||||
type="primary" block @click="showRedeemResult">
|
||||
{{ t('viewResult') }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="codeInfo.redeem_type === 'send_balance'">
|
||||
<div class="detail-heading">
|
||||
<n-icon :component="SendOutlined" />
|
||||
<div>
|
||||
<h2>{{ t('balanceTitle') }}</h2>
|
||||
<p>{{ t('balanceDescription', { amount: codeInfo.value }) }}</p>
|
||||
</div>
|
||||
<n-tag :type="codeStatusType" :bordered="false">
|
||||
{{ t(codeInfo.status) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<n-button v-if="codeInfo.status === 'unused' && !redeemStarted" data-testid="redeem-now" type="primary" block
|
||||
@click="redeemStarted = true">
|
||||
{{ t('redeemNow') }}
|
||||
</n-button>
|
||||
<template v-else-if="codeInfo.status === 'unused'">
|
||||
<n-form-item :label="t('targetAddress')">
|
||||
<n-input data-testid="redeem-target-address" v-model:value="targetAddress"
|
||||
:placeholder="t('targetAddress')" />
|
||||
</n-form-item>
|
||||
<n-button type="primary" block :disabled="!targetAddress.trim()" @click="redeem">
|
||||
{{ t('confirmBalance') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<n-button v-else-if="codeInfo.status === 'redeemed'" data-testid="redeem-result"
|
||||
type="primary" block @click="showRedeemResult">
|
||||
{{ t('viewResult') }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="detail-heading">
|
||||
<n-icon :component="AlternateEmailOutlined" />
|
||||
<div>
|
||||
<h2>{{ t('prefixTitle') }}</h2>
|
||||
<p>{{ codeInfo.value
|
||||
? t('prefixDescription', { prefix: codeInfo.value })
|
||||
: t('noPrefixDescription') }}</p>
|
||||
</div>
|
||||
<n-tag :type="codeStatusType" :bordered="false">
|
||||
{{ t(codeInfo.status) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<n-button v-if="codeInfo.status === 'redeemed'" data-testid="redeem-result" type="primary" block
|
||||
@click="showRedeemResult">
|
||||
{{ t('viewResult') }}
|
||||
</n-button>
|
||||
<n-form v-else-if="codeInfo.status === 'unused'">
|
||||
<n-form-item :label="t('address')">
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="codeInfo.value">{{ codeInfo.value }}</n-input-group-label>
|
||||
<n-input v-if="!openSettings.disableCustomAddressName" data-testid="redeem-address-name"
|
||||
v-model:value="emailName"
|
||||
:minlength="addressNameMinLength" :maxlength="addressNameMaxLength" />
|
||||
<n-input v-else :value="t('autoName')" disabled />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :options="domainsOptions"
|
||||
:consistent-menu-width="false" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-button v-if="!openSettings.disableCustomAddressName" size="small" @click="generateName">
|
||||
{{ t('randomName') }}
|
||||
</n-button>
|
||||
<n-form-item v-if="canUseRandomSubdomain" class="subdomain-options">
|
||||
<n-radio-group v-model:value="subdomainMode">
|
||||
<n-space vertical>
|
||||
<n-radio value="normal">{{ t('normalDomain') }}</n-radio>
|
||||
<n-radio value="random">{{ t('randomSubdomain') }}</n-radio>
|
||||
<n-radio value="custom">{{ t('customSubdomain') }}</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<n-input-group v-if="subdomainMode === 'custom'">
|
||||
<n-input v-model:value="customSubdomain" />
|
||||
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-button type="primary" block
|
||||
:disabled="!emailDomain || (subdomainMode === 'custom' && !customSubdomain.trim())"
|
||||
@click="createAddress">
|
||||
{{ t('createAddress') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redeem-page {
|
||||
min-height: 70vh;
|
||||
padding: 24px 18px 56px;
|
||||
}
|
||||
|
||||
.redeem-shell {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.redeem-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.heading-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.heading-icon {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
color: #18a058;
|
||||
font-size: 24px;
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.heading-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.type-section {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.type-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.type-card .n-icon {
|
||||
flex: 0 0 auto;
|
||||
color: #18a058;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.type-card span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-card.active {
|
||||
color: #18a058;
|
||||
border-color: rgba(24, 160, 88, 0.55);
|
||||
background: rgba(24, 160, 88, 0.08);
|
||||
}
|
||||
|
||||
.redeem-card > :deep(.n-card__content) > .n-divider {
|
||||
margin: 22px 0;
|
||||
}
|
||||
|
||||
.redeem-form > :deep(.n-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.redeem-form > .n-button {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.redeem-form > .get-code-link {
|
||||
width: max-content;
|
||||
margin: 12px auto 0;
|
||||
}
|
||||
|
||||
.redemption-detail :deep(.n-button),
|
||||
.success-state > .n-button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.detail-heading > .n-icon {
|
||||
flex: 0 0 auto;
|
||||
color: #18a058;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.detail-heading > .n-tag {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.detail-heading p,
|
||||
.success-state p {
|
||||
margin: 0;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.detail-heading h2,
|
||||
.success-state h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.subdomain-options :deep(.n-form-item-blank) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.success-state {
|
||||
padding: 24px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-state > .n-icon {
|
||||
color: #18a058;
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
.credential-result {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.redeem-page {
|
||||
padding: 16px 12px 48px;
|
||||
}
|
||||
|
||||
.heading-copy h1 {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.type-card {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 10px 5px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,12 +8,14 @@ import UserSettingsPage from './user/UserSettings.vue';
|
||||
import UserBar from './user/UserBar.vue';
|
||||
import BindAddress from './user/BindAddress.vue';
|
||||
import UserMailBox from './user/UserMailBox.vue';
|
||||
import UserSendBox from './user/UserSendBox.vue';
|
||||
|
||||
const {
|
||||
userTab, globalTabplacement, userSettings
|
||||
userTab, globalTabplacement, userSettings, openSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.User')
|
||||
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
|
||||
|
||||
</script>
|
||||
|
||||
@@ -27,6 +29,12 @@ const { t } = useScopedI18n('views.User')
|
||||
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
||||
<UserMailBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="user_sendbox" :tab="userMailT('sendbox')">
|
||||
<UserSendBox mode="sendbox" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
|
||||
<UserSendBox mode="send_mail" @sent="userTab = 'user_sendbox'" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettingsPage />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -265,7 +265,7 @@ const columns = computed(() => [
|
||||
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
title: t('emailAddress'),
|
||||
key: "name",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
|
||||
@@ -479,11 +479,11 @@ onMounted(async () => {
|
||||
<div style="margin-top: 10px;">
|
||||
<AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
|
||||
:jwt="curEmailCredential" />
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')">
|
||||
<p>{{ t('deleteTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
|
||||
{{ t('deleteAccount') }}
|
||||
{{ t('deleteMailbox') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
@@ -23,6 +23,40 @@ const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const addressRegex = computed(() => {
|
||||
try {
|
||||
if (openSettings.value.addressRegex) {
|
||||
return new RegExp(openSettings.value.addressRegex, 'g');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
|
||||
}
|
||||
return /[^a-z0-9]/g;
|
||||
});
|
||||
|
||||
const generateNameLoading = ref(false);
|
||||
const generateName = async () => {
|
||||
try {
|
||||
generateNameLoading.value = true;
|
||||
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||
emailName.value = faker.internet.email()
|
||||
.split('@')[0]
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(addressRegex.value, '')
|
||||
.toLowerCase();
|
||||
// support maxAddressLen
|
||||
if (emailName.value.length > openSettings.value.maxAddressLen) {
|
||||
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
generateNameLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false
|
||||
@@ -81,6 +115,11 @@ onMounted(async () => {
|
||||
<n-switch v-model:value="enablePrefix" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('address')">
|
||||
<n-spin :show="generateNameLoading" style="width: 100%;">
|
||||
<div>
|
||||
<n-button @click="generateName" style="margin-bottom: 10px;">
|
||||
{{ t('generateName') }}
|
||||
</n-button>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
@@ -90,6 +129,8 @@ onMounted(async () => {
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<div style="width: 100%;">
|
||||
@@ -111,7 +152,7 @@ onMounted(async () => {
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading"
|
||||
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
|
||||
{{ t('creatNewEmail') }}
|
||||
{{ t('createEmailAddress') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
<script setup>
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { NButton, NEllipsis, NPopconfirm, NTag } from 'naive-ui'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import { api } from '../../api'
|
||||
import { useGlobalState } from '../../store'
|
||||
|
||||
const MAX_BATCH_SIZE = 500
|
||||
const MAX_EXPORT_SIZE = 10000
|
||||
|
||||
const { loading, openSettings } = useGlobalState()
|
||||
const { t } = useScopedI18n('views.admin.RedeemCodes')
|
||||
const typeOptions = computed(() => [
|
||||
{ label: t('typeRole'), value: 'role' },
|
||||
{ label: t('typeBalance'), value: 'send_balance' },
|
||||
{ label: t('typeAddress'), value: 'address_prefix_once' },
|
||||
])
|
||||
const message = useMessage()
|
||||
const data = ref([])
|
||||
const roleOptions = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const query = ref('')
|
||||
const selectedType = ref('role')
|
||||
const showEditor = ref(false)
|
||||
const showExport = ref(false)
|
||||
const editorMode = ref('create')
|
||||
const exportType = ref('role')
|
||||
const exportLimit = ref(1000)
|
||||
const form = ref({})
|
||||
const prefixMaxLength = computed(() => Math.max((openSettings.value.maxAddressLen || 30) - 1, 0))
|
||||
let latestFetchRequest = 0
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
id: null,
|
||||
type: selectedType.value,
|
||||
count: 1,
|
||||
role: '',
|
||||
amount: 100,
|
||||
prefix: '',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
const parseJson = (value) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value || '{}')
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const parseDate = (value) => {
|
||||
if (!value) return null
|
||||
const normalized = /(?:z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`
|
||||
const date = new Date(normalized)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const requestId = ++latestFetchRequest
|
||||
try {
|
||||
const result = await api.fetch(
|
||||
`/admin/redeem_codes?redeem_type=${encodeURIComponent(selectedType.value)}`
|
||||
+ `&limit=${pageSize.value}&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (query.value.trim() ? `&query=${encodeURIComponent(query.value.trim())}` : '')
|
||||
)
|
||||
if (requestId !== latestFetchRequest) return
|
||||
data.value = result.results || []
|
||||
count.value = result.count || 0
|
||||
} catch (error) {
|
||||
if (requestId !== latestFetchRequest) return
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRoleOptions = async () => {
|
||||
try {
|
||||
const [roles, config] = await Promise.all([
|
||||
api.fetch('/admin/user_roles'),
|
||||
api.fetch('/admin/worker/configs'),
|
||||
])
|
||||
roleOptions.value = roles
|
||||
.filter((item) => item.role && item.role !== config.ADMIN_USER_ROLE)
|
||||
.map((item) => ({ label: item.role, value: item.role }))
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const search = async () => {
|
||||
page.value = 1
|
||||
await fetchData()
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
editorMode.value = 'create'
|
||||
resetForm()
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
const openEdit = (row) => {
|
||||
editorMode.value = 'edit'
|
||||
form.value = {
|
||||
id: row.id,
|
||||
type: selectedType.value,
|
||||
count: 1,
|
||||
role: selectedType.value === 'role' ? row.value : '',
|
||||
amount: selectedType.value === 'send_balance' ? Number(row.value) : null,
|
||||
prefix: selectedType.value === 'address_prefix_once' ? row.value : '',
|
||||
enabled: Boolean(row.enabled),
|
||||
expires_at: parseDate(row.expires_at)?.getTime() || null,
|
||||
}
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
const buildValue = () => {
|
||||
if (form.value.type === 'role') return form.value.role.trim()
|
||||
if (form.value.type === 'send_balance') return String(form.value.amount)
|
||||
return form.value.prefix.trim()
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (form.value.type === 'role' && !form.value.role.trim()) {
|
||||
message.error(t('roleRequired'))
|
||||
return
|
||||
}
|
||||
const value = buildValue()
|
||||
if (form.value.type === 'address_prefix_once'
|
||||
&& (!/^[a-z0-9]*$/i.test(value) || value.length > prefixMaxLength.value)
|
||||
) {
|
||||
message.error(t('invalidPrefix', { max: prefixMaxLength.value }))
|
||||
return
|
||||
}
|
||||
if (!form.value.expires_at || form.value.expires_at <= Date.now()) {
|
||||
message.error(t('invalidExpiration'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editorMode.value === 'create') {
|
||||
if (!Number.isInteger(form.value.count)
|
||||
|| form.value.count < 1
|
||||
|| form.value.count > MAX_BATCH_SIZE
|
||||
) {
|
||||
message.error(t('countLimit', { max: MAX_BATCH_SIZE }))
|
||||
return
|
||||
}
|
||||
const result = await api.fetch('/admin/redeem_codes/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
count: form.value.count,
|
||||
redeem_type: form.value.type,
|
||||
value,
|
||||
enabled: form.value.enabled,
|
||||
expires_at: new Date(form.value.expires_at).toISOString(),
|
||||
}),
|
||||
})
|
||||
downloadCreatedCodes(result.codes, {
|
||||
redeem_type: form.value.type,
|
||||
value,
|
||||
enabled: form.value.enabled,
|
||||
expires_at: new Date(form.value.expires_at).toISOString(),
|
||||
})
|
||||
message.success(t('createdResult', result))
|
||||
} else {
|
||||
await api.fetch(`/admin/redeem_codes/${form.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
redeem_type: form.value.type,
|
||||
value,
|
||||
enabled: form.value.enabled,
|
||||
expires_at: new Date(form.value.expires_at).toISOString(),
|
||||
}),
|
||||
})
|
||||
message.success(t('saved'))
|
||||
}
|
||||
showEditor.value = false
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id) => {
|
||||
try {
|
||||
await api.fetch(`/admin/redeem_codes/${id}`, { method: 'DELETE' })
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const copyCode = async (code) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
message.success(t('copied'))
|
||||
} catch {
|
||||
message.error(t('copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const download = (content, filename, type) => {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const csvCell = (value) => {
|
||||
let text = String(value ?? '')
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
const downloadCreatedCodes = (codes, fields) => {
|
||||
if (!Array.isArray(codes) || !codes.length) return
|
||||
const columns = ['code', 'redeem_type', 'value', 'enabled', 'expires_at']
|
||||
const csv = [
|
||||
columns.join(','),
|
||||
...codes.map((code) => columns.map((column) => (
|
||||
csvCell(column === 'code' ? code : fields[column])
|
||||
)).join(',')),
|
||||
].join('\n')
|
||||
download(`\uFEFF${csv}`, `redeem-codes-${fields.redeem_type}.csv`, 'text/csv;charset=utf-8')
|
||||
}
|
||||
|
||||
const formatTime = (value) => parseDate(value)?.toLocaleString() || '-'
|
||||
|
||||
const exportList = async () => {
|
||||
try {
|
||||
const csv = await api.fetch(
|
||||
`/admin/redeem_codes/export?redeem_type=${encodeURIComponent(exportType.value)}`
|
||||
+ `&limit=${exportLimit.value}`
|
||||
)
|
||||
download(csv, `redeem-codes-${exportType.value}.csv`, 'text/csv;charset=utf-8')
|
||||
showExport.value = false
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const commonColumns = computed(() => [
|
||||
{
|
||||
title: t('code'),
|
||||
key: 'code',
|
||||
width: 180,
|
||||
render: (row) => h(NEllipsis, { style: 'max-width: 160px' }, { default: () => row.code }),
|
||||
},
|
||||
])
|
||||
|
||||
const businessColumns = computed(() => {
|
||||
if (selectedType.value === 'role') {
|
||||
return [
|
||||
{ title: t('role'), key: 'role', width: 140, render: (row) => row.value || '-' },
|
||||
{
|
||||
title: t('redeemedUser'),
|
||||
key: 'user',
|
||||
width: 240,
|
||||
render: (row) => parseJson(row.result).user_email || '-',
|
||||
},
|
||||
]
|
||||
}
|
||||
if (selectedType.value === 'send_balance') {
|
||||
return [
|
||||
{ title: t('amount'), key: 'amount', width: 140, render: (row) => row.value || '-' },
|
||||
{
|
||||
title: t('targetAddress'),
|
||||
key: 'address',
|
||||
width: 240,
|
||||
render: (row) => parseJson(row.result).address || '-',
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: t('prefix'),
|
||||
key: 'prefix',
|
||||
width: 160,
|
||||
render: (row) => row.value || t('emptyPrefix'),
|
||||
},
|
||||
{
|
||||
title: t('resultAddress'),
|
||||
key: 'address',
|
||||
width: 240,
|
||||
render: (row) => parseJson(row.result).address || '-',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const columns = computed(() => [
|
||||
...commonColumns.value,
|
||||
...businessColumns.value,
|
||||
{
|
||||
title: t('enabled'),
|
||||
key: 'enabled',
|
||||
width: 90,
|
||||
render: (row) => h(NTag, { type: row.enabled ? 'success' : 'default', bordered: false }, {
|
||||
default: () => row.enabled ? t('yes') : t('no'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('expiresAt'),
|
||||
key: 'expires_at',
|
||||
width: 260,
|
||||
render: (row) => {
|
||||
const expiration = parseDate(row.expires_at)
|
||||
const expired = !expiration || expiration.getTime() <= Date.now()
|
||||
return h('div', { class: 'expiration-cell' }, [
|
||||
h(NTag, { type: expired ? 'error' : 'success', size: 'small', bordered: false }, {
|
||||
default: () => expired ? t('expired') : t('valid'),
|
||||
}),
|
||||
h('span', formatTime(row.expires_at)),
|
||||
])
|
||||
},
|
||||
},
|
||||
{ title: t('redeemedAt'), key: 'redeemed_at', width: 180, render: (row) => formatTime(row.redeemed_at) },
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
width: 230,
|
||||
fixed: 'right',
|
||||
render: (row) => h('div', { class: 'actions' }, [
|
||||
h(NButton, { size: 'small', tertiary: true, onClick: () => copyCode(row.code) }, {
|
||||
default: () => t('copy'),
|
||||
}),
|
||||
row.redeemed
|
||||
? null
|
||||
: h(NButton, { size: 'small', tertiary: true, onClick: () => openEdit(row) }, {
|
||||
default: () => t('edit'),
|
||||
}),
|
||||
h(NPopconfirm, { onPositiveClick: () => remove(row.id) }, {
|
||||
trigger: () => h(NButton, { size: 'small', tertiary: true, type: 'error' }, {
|
||||
default: () => t('delete'),
|
||||
}),
|
||||
default: () => t('deleteConfirm'),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
])
|
||||
|
||||
watch([page, pageSize], fetchData)
|
||||
watch(selectedType, async () => {
|
||||
page.value = 1
|
||||
await fetchData()
|
||||
})
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchData(), fetchRoleOptions()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="redeem-admin">
|
||||
<div class="redeem-toolbar">
|
||||
<div class="redeem-filters">
|
||||
<n-input-group class="type-filter">
|
||||
<n-input-group-label>{{ t('typeFilter') }}</n-input-group-label>
|
||||
<n-select data-testid="redeem-admin-filter-type" v-model:value="selectedType"
|
||||
:options="typeOptions" class="type-select" />
|
||||
</n-input-group>
|
||||
<n-input data-testid="redeem-admin-search" v-model:value="query"
|
||||
:placeholder="t('searchPlaceholder')" class="search-input" @keyup.enter="search" />
|
||||
<n-button @click="search">{{ t('search') }}</n-button>
|
||||
</div>
|
||||
<div class="redeem-toolbar-actions">
|
||||
<n-button data-testid="redeem-admin-export" @click="exportType = selectedType; showExport = true">
|
||||
{{ t('export') }}
|
||||
</n-button>
|
||||
<n-button data-testid="redeem-admin-create" type="primary" @click="openCreate">
|
||||
{{ t('batchCreate') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" :scroll-x="1320" />
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
:page-sizes="[20, 50, 100]" show-size-picker />
|
||||
|
||||
<n-modal v-model:show="showEditor" preset="dialog"
|
||||
:title="editorMode === 'create' ? t('batchCreate') : t('edit')">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item v-if="editorMode === 'edit'" :label="t('type')">
|
||||
<n-select v-model:value="form.type" :options="typeOptions" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="editorMode === 'create'" :label="t('generationCount')">
|
||||
<n-input-number data-testid="redeem-admin-count" v-model:value="form.count"
|
||||
:min="1" :max="MAX_BATCH_SIZE" :precision="0" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="form.type === 'role'" :label="t('role')" required>
|
||||
<n-select data-testid="redeem-admin-role" v-model:value="form.role"
|
||||
:options="roleOptions" filterable />
|
||||
</n-form-item>
|
||||
<n-form-item v-else-if="form.type === 'send_balance'" :label="t('amount')">
|
||||
<n-input-number v-model:value="form.amount" :min="1" :max="1000000000" />
|
||||
</n-form-item>
|
||||
<n-form-item v-else :label="t('prefix')">
|
||||
<n-input v-model:value="form.prefix" :maxlength="prefixMaxLength"
|
||||
:placeholder="t('emptyPrefixTip')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('expiresAt')" required>
|
||||
<n-date-picker v-model:value="form.expires_at" type="datetime" clearable />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('enabled')">
|
||||
<n-switch v-model:value="form.enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button :loading="loading" type="primary" @click="save">
|
||||
{{ editorMode === 'create' ? t('generate') : t('save') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showExport" preset="dialog" :title="t('export')">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item :label="t('exportLimit', { max: MAX_EXPORT_SIZE })">
|
||||
<n-input-number v-model:value="exportLimit" :min="1" :max="MAX_EXPORT_SIZE" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button :loading="loading" type="primary" @click="exportList">{{ t('downloadCsv') }}</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redeem-admin {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.redeem-toolbar,
|
||||
.redeem-filters,
|
||||
.redeem-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.redeem-toolbar,
|
||||
.redeem-filters {
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.redeem-toolbar {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.redeem-filters {
|
||||
flex: 1 1 520px;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-filter {
|
||||
flex: 1 1 300px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1 1 180px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.redeem-toolbar-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:deep(.actions) {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
:deep(.expiration-cell) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -2,14 +2,20 @@
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import { api } from '../../api'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
const { autoLoadRemoteImages, isDark } = useGlobalState()
|
||||
|
||||
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
fromName: "",
|
||||
@@ -23,11 +29,18 @@ const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
|
||||
const { t } = useScopedI18n('views.admin.SendMail')
|
||||
|
||||
const contentTypes = [
|
||||
const contentTypes = computed(() => [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
@@ -110,6 +123,7 @@ const send = async () => {
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -146,78 +160,230 @@ const handleCreated = (editor) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input v-model:value="sendMailModel.fromMail" />
|
||||
</n-input-group>
|
||||
<div class="composer-page">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
<n-text depth="3">{{ t('adminComposeTip') }}</n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" required
|
||||
:label-props="{ for: 'admin-send-mail-sender-address' }">
|
||||
<n-input v-model:value="sendMailModel.fromMail"
|
||||
:input-props="{ id: 'admin-send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')"
|
||||
:label-props="{ for: 'admin-send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'admin-send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'admin-send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'admin-send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')"
|
||||
:label-props="{ for: 'admin-send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'admin-send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required
|
||||
:label-props="{ for: 'admin-send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'admin-send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="admin-send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="admin-send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.left {
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container),
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,7 @@ const fetchData = async () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('name'),
|
||||
title: t('emailAddress'),
|
||||
key: "name"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
|
||||
import { NewLabelOutlined, EmailOutlined, RedeemOutlined } from '@vicons/material'
|
||||
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import Turnstile from '../../components/Turnstile.vue'
|
||||
@@ -66,6 +66,7 @@ const initLoginMethod = () => {
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
if (openSettings.value.addressPasswordLoginOnly) loginMethod.value = 'password';
|
||||
if (loginMethod.value === 'password') {
|
||||
// Password login
|
||||
if (!loginAddress.value || !loginPassword.value) {
|
||||
@@ -192,6 +193,10 @@ const newEmail = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openRedeemPage = async () => {
|
||||
await router.push(getRouterPathWithLang('/redeem', locale.value));
|
||||
};
|
||||
|
||||
const addressPrefix = computed(() => {
|
||||
// if user has role, return role prefix
|
||||
if (userSettings.value?.user_role) {
|
||||
@@ -242,6 +247,8 @@ const showNewAddressTab = computed(() => {
|
||||
return openSettings.value.enableUserCreateEmail;
|
||||
});
|
||||
|
||||
watch(() => openSettings.value.addressPasswordLoginOnly, initLoginMethod);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
|
||||
await api.getOpenSettings(message, notification);
|
||||
@@ -279,7 +286,7 @@ onMounted(async () => {
|
||||
v-model:value="loginCfToken" />
|
||||
|
||||
<div class="switch-login-button">
|
||||
<n-button v-if="openSettings?.enableAddressPassword"
|
||||
<n-button v-if="openSettings?.enableAddressPassword && !openSettings.addressPasswordLoginOnly"
|
||||
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
||||
type="info" quaternary size="tiny">
|
||||
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
|
||||
@@ -298,6 +305,16 @@ onMounted(async () => {
|
||||
</template>
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
<div v-if="openSettings.enableRedeemCode" class="redeem-entry-section">
|
||||
<n-divider />
|
||||
<n-text type="info" class="redeem-entry-tip">{{ t('redeemEntryTip') }}</n-text>
|
||||
<n-button data-testid="redeem-entry" block secondary strong @click="openRedeemPage">
|
||||
<template #icon>
|
||||
<n-icon :component="RedeemOutlined" />
|
||||
</template>
|
||||
{{ t('useRedeemCode') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="showNewAddressTab" name="register" :tab="t('getNewEmail')">
|
||||
@@ -350,6 +367,17 @@ onMounted(async () => {
|
||||
</template>
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
<div v-if="openSettings.enableRedeemCode" class="redeem-entry-section">
|
||||
<n-divider />
|
||||
<n-text type="info" class="redeem-entry-tip">{{ t('redeemEntryTip') }}</n-text>
|
||||
<n-button data-testid="redeem-entry-register" block secondary strong
|
||||
@click="openRedeemPage">
|
||||
<template #icon>
|
||||
<n-icon :component="RedeemOutlined" />
|
||||
</template>
|
||||
{{ t('useRedeemCode') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
@@ -375,6 +403,21 @@ onMounted(async () => {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.redeem-entry-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.redeem-entry-section :deep(.n-divider) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.redeem-entry-tip {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.switch-login-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -93,7 +93,7 @@ const changePassword = async () => {
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded class="account-card">
|
||||
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
|
||||
<n-button v-if="!openSettings.addressPasswordLoginOnly" @click="showAddressCredential = true" type="primary" secondary block strong>
|
||||
{{ t('showAddressCredential') }}
|
||||
</n-button>
|
||||
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
|
||||
@@ -113,7 +113,7 @@ const changePassword = async () => {
|
||||
<n-divider v-if="openSettings.enableUserDeleteEmail" />
|
||||
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
|
||||
block strong>
|
||||
{{ t('deleteAccount') }}
|
||||
{{ t('deleteMailbox') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
|
||||
@@ -125,11 +125,11 @@ const changePassword = async () => {
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')">
|
||||
<p>{{ t('deleteAccountConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
|
||||
{{ t('deleteAccount') }}
|
||||
{{ t('deleteMailbox') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
@@ -71,7 +71,7 @@ onMounted(async () => {
|
||||
<template #icon>
|
||||
<n-icon :component="User" />
|
||||
</template>
|
||||
{{ t('userLogin') }}
|
||||
{{ t('userCenter') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
@@ -3,63 +3,39 @@ import { ref, h, computed } from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { NPopconfirm, NButton } from 'naive-ui'
|
||||
import { getCachedAddresses } from '../../utils/local-address-cache'
|
||||
import type { CachedAddress } from '../../utils/local-address-cache'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
const { jwt, openSettings } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useScopedI18n('views.index.LocalAddress')
|
||||
const { t: loginT } = useScopedI18n('views.common.Login')
|
||||
|
||||
const tabValue = ref('address')
|
||||
const localAddressCache = useLocalStorage("LocalAddressCache", []);
|
||||
const localAddressCache = useLocalStorage<(string | CachedAddress)[]>("LocalAddressCache", []);
|
||||
const data = computed(() => {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
return localAddressCache.value.map((curJwt: string) => {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
)
|
||||
)
|
||||
);
|
||||
return getCachedAddresses(localAddressCache.value).map(({ token, address, type }, index) => {
|
||||
const isPasswordLogin = type === 'address_password_login';
|
||||
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||
return {
|
||||
valid: true,
|
||||
address: payload.address,
|
||||
jwt: curJwt
|
||||
address: address
|
||||
? `${address} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||
: t('savedMailbox', { index: index + 1 }),
|
||||
jwt: token
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
address: `invalid jwt [${curJwt}]`,
|
||||
jwt: curJwt
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}).filter(Boolean)
|
||||
})
|
||||
|
||||
const bindAddress = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
const bindAddress = () => {
|
||||
tabValue.value = 'address'
|
||||
message.success(t('bindAddressSuccess'));
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
@@ -96,8 +72,8 @@ const columns = [
|
||||
if (jwt.value === row.jwt) {
|
||||
return;
|
||||
}
|
||||
localAddressCache.value = localAddressCache.value.filter(
|
||||
(curJwt: string) => curJwt !== row.jwt
|
||||
localAddressCache.value = getCachedAddresses(localAddressCache.value).filter(
|
||||
entry => entry.token !== row.jwt
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
@@ -14,15 +18,25 @@ const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
|
||||
|
||||
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
|
||||
const {
|
||||
settings, sendMailModel, indexTab, userSettings,
|
||||
autoLoadRemoteImages, isDark,
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.index.SendMail')
|
||||
|
||||
const contentTypes = [
|
||||
const contentTypes = computed(() => [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
@@ -157,95 +171,292 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded>
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<n-alert type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('requestAccessTip', { address: settings.address }) }}
|
||||
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
|
||||
}}</n-button>
|
||||
</n-alert>
|
||||
<AdminContact />
|
||||
<div class="composer-page" v-if="settings.address">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
<n-text depth="3">{{ settings.address }}</n-text>
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-alert type="info" :show-icon="false" :bordered="false" closable>
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input :value="settings.address" disabled />
|
||||
</n-input-group>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<div class="access-state">
|
||||
<div class="access-copy">
|
||||
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
</div>
|
||||
<div class="admin-contact"><AdminContact /></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||
<n-input :value="settings.address" readonly
|
||||
:input-props="{ id: 'send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-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-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-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'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.access-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-copy p {
|
||||
max-width: 640px;
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.65;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.admin-contact {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 10px;
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__main) {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__extra) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,6 +56,24 @@ const fetchMails = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCurrentMailUnread = async () => {
|
||||
if (!currentMail.value || !openSettings.value.enableMailReadStatus) return
|
||||
const mail = currentMail.value
|
||||
const previousValue = mail.is_unread
|
||||
const isUnread = previousValue !== 1
|
||||
mail.is_unread = isUnread ? 1 : 0
|
||||
try {
|
||||
await api.fetch(`/api/mails/${mail.id}/read`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isUnread }),
|
||||
showLoading: false
|
||||
})
|
||||
message.success(t('readStatusUpdated'))
|
||||
} catch {
|
||||
mail.is_unread = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
// 删除邮件
|
||||
const deleteMail = async () => {
|
||||
if (!currentMail.value) return;
|
||||
@@ -170,7 +188,7 @@ onBeforeUnmount(() => {
|
||||
<SettingsFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('accountSettings') }}
|
||||
{{ t('mailboxSettings') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<div v-if="isFirstPage" style="text-align: center; margin-top: 12px;">
|
||||
@@ -182,7 +200,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 账户设置卡片 -->
|
||||
<n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable
|
||||
@close="showAccountSettingsCard = false" :title="t('accountSettings')">
|
||||
@close="showAccountSettingsCard = false" :title="t('mailboxSettings')">
|
||||
<AccountSettings />
|
||||
</n-card>
|
||||
|
||||
@@ -216,10 +234,15 @@ onBeforeUnmount(() => {
|
||||
<n-empty :description="t('noMails')" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
|
||||
<h3 v-if="currentMail.subject"
|
||||
:class="{ 'mail-title-unread': openSettings.enableMailReadStatus && currentMail.is_unread === 1 }">
|
||||
{{ currentMail.subject }}
|
||||
</h3>
|
||||
<div style="margin-top: 16px;">
|
||||
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
||||
:enableMailReadStatus="openSettings.enableMailReadStatus"
|
||||
:onUpdateMailReadStatus="toggleCurrentMailUnread"
|
||||
:onDelete="deleteMail" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,4 +269,19 @@ onBeforeUnmount(() => {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mail-title-unread {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mail-title-unread::before {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 8px;
|
||||
border-radius: 50%;
|
||||
background: #2080f0;
|
||||
content: '';
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,15 +6,17 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
import { getRouterPathWithLang, hashPassword } from '../../utils'
|
||||
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
const { jwt, openSettings, loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
||||
const { t: credentialT } = useScopedI18n('components.AddressCredentialModal')
|
||||
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
@@ -24,11 +26,61 @@ const showTranferAddress = ref(false)
|
||||
const currentAddress = ref("")
|
||||
const currentAddressId = ref(0)
|
||||
const targetUserEmail = ref('')
|
||||
const showAddressCredential = ref(false)
|
||||
const currentAddressCredential = ref('')
|
||||
const credentialAddress = ref('')
|
||||
const passwordResetAddress = ref(null)
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const isResettingPassword = ref(false)
|
||||
const { t: accountSettingsT } = useScopedI18n('views.index.AccountSettings')
|
||||
|
||||
const clearPasswordResetForm = () => {
|
||||
passwordResetAddress.value = null;
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
}
|
||||
|
||||
const resetBoundAddressPassword = async () => {
|
||||
if (!passwordResetAddress.value || isResettingPassword.value) return;
|
||||
if (!newPassword.value) {
|
||||
message.error(t('newPasswordRequired'));
|
||||
return;
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
message.error(accountSettingsT('passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
isResettingPassword.value = true;
|
||||
try {
|
||||
await api.fetch(`/user_api/address/${passwordResetAddress.value.id}/reset_password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: await hashPassword(newPassword.value) }),
|
||||
});
|
||||
message.success(accountSettingsT('passwordChanged'));
|
||||
clearPasswordResetForm();
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error');
|
||||
} finally {
|
||||
isResettingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const showCredential = async (row) => {
|
||||
try {
|
||||
const { jwt: addressCredential } = await api.fetch(`/user_api/bind_address_jwt/${row.id}`)
|
||||
currentAddressCredential.value = addressCredential
|
||||
credentialAddress.value = row.name
|
||||
showAddressCredential.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
|
||||
const changeMailAddress = async (address_id) => {
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
|
||||
message.success(t('changeMailAddress') + " " + t('success'));
|
||||
if (!res.jwt) {
|
||||
message.error("jwt not found");
|
||||
return;
|
||||
@@ -114,7 +166,7 @@ const fetchData = async () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('name'),
|
||||
title: t('emailAddress'),
|
||||
key: "name"
|
||||
},
|
||||
{
|
||||
@@ -146,6 +198,26 @@ const columns = [
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
!openSettings.value.addressPasswordLoginOnly ? h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
onClick: () => showCredential(row)
|
||||
},
|
||||
{ default: () => credentialT('addressCredential') }
|
||||
) : null,
|
||||
openSettings.value.enableAddressPassword ? h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
passwordResetAddress.value = row;
|
||||
},
|
||||
},
|
||||
{ default: () => t('resetPassword') }
|
||||
) : null,
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => changeMailAddress(row.id)
|
||||
@@ -156,9 +228,9 @@ const columns = [
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
},
|
||||
{ default: () => t('changeMailAddress') }
|
||||
{ default: () => t('openMailbox') }
|
||||
),
|
||||
default: () => `${t('changeMailAddress')}?`
|
||||
default: () => `${t('openMailbox')}?`
|
||||
}
|
||||
),
|
||||
h(NButton,
|
||||
@@ -185,7 +257,7 @@ const columns = [
|
||||
},
|
||||
{ default: () => t('unbindAddress') }
|
||||
),
|
||||
default: () => t('unbindAddressTip')
|
||||
default: () => t(openSettings.value.addressPasswordLoginOnly ? 'unbindPasswordTip' : 'unbindAddressTip')
|
||||
}
|
||||
),
|
||||
])
|
||||
@@ -204,6 +276,25 @@ watch([page, pageSize], async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal :show="!!passwordResetAddress" @update:show="show => { if (!show && !isResettingPassword) clearPasswordResetForm() }"
|
||||
preset="dialog" :title="t('resetPassword')" :mask-closable="!isResettingPassword" :closable="!isResettingPassword">
|
||||
<p>{{ passwordResetAddress?.name }}</p>
|
||||
<p>{{ t('resetPasswordTip') }}</p>
|
||||
<n-form @submit.prevent="resetBoundAddressPassword">
|
||||
<n-form-item :label="accountSettingsT('newPassword')">
|
||||
<n-input v-model:value="newPassword" type="password" show-password-on="click" :disabled="isResettingPassword" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="accountSettingsT('confirmPassword')">
|
||||
<n-input v-model:value="confirmPassword" type="password" show-password-on="click"
|
||||
:disabled="isResettingPassword" @keyup.enter="resetBoundAddressPassword" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button type="warning" :loading="isResettingPassword" @click="resetBoundAddressPassword">{{ t('resetPassword') }}</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
||||
:jwt="currentAddressCredential" />
|
||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||
<span>
|
||||
<p>{{ t("transferAddressTip") }}</p>
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
const settings = ref({
|
||||
address: '',
|
||||
send_balance: 0,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
addressId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
addressOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
addressLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['addressScroll', 'sent', 'update:addressId'])
|
||||
|
||||
const {
|
||||
sendMailModel, userSettings, autoLoadRemoteImages, isDark,
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.index.SendMail')
|
||||
|
||||
const getApiPath = (path) => `/user_api/address/${props.addressId}/${path}`
|
||||
|
||||
const refreshSettings = async () => {
|
||||
settings.value = await api.fetch(getApiPath('settings'))
|
||||
}
|
||||
|
||||
const contentTypes = computed(() => [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const hasSendMailContent = (content, contentType) => {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contentType === 'text') {
|
||||
return normalizeSendMailText(content).length > 0
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = content
|
||||
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
|
||||
|
||||
const plainContent = normalizeSendMailText(container.textContent ?? '')
|
||||
if (plainContent.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
|
||||
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
|
||||
if (!subject) {
|
||||
message.error(t('subjectEmpty'))
|
||||
return
|
||||
}
|
||||
if (!toMail) {
|
||||
message.error(t('toMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
|
||||
message.error(t('contentEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: toMail,
|
||||
subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content,
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.fetch(getApiPath('send_mail'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
emit('sent')
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const requestAccess = async () => {
|
||||
try {
|
||||
await api.fetch(getApiPath('request_send_mail_access'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
)
|
||||
message.success(t("requestSuccess"))
|
||||
await refreshSettings();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ["uploadVideo"]
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
MENU_CONF: {
|
||||
'uploadImage': {
|
||||
async customUpload() {
|
||||
message.error(t('tooLarge'))
|
||||
},
|
||||
maxFileSize: 1 * 1024 * 1024,
|
||||
base64LimitSize: 1 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
await refreshSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-page" v-if="settings.address">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||
<n-select class="address-picker-select" :value="addressId"
|
||||
:options="addressOptions" :loading="addressLoading" filterable
|
||||
@scroll="emit('addressScroll', $event)"
|
||||
@update:value="emit('update:addressId', $event)" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<div class="access-state">
|
||||
<div class="access-copy">
|
||||
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
</div>
|
||||
<div class="admin-contact"><AdminContact /></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.access-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-copy p {
|
||||
max-width: 640px;
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.65;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.admin-contact {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__main) {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__extra) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import { api } from '../../api'
|
||||
import { useGlobalState } from '../../store'
|
||||
import SendBox from '../../components/SendBox.vue'
|
||||
|
||||
const SendMail = defineAsyncComponent(() => import('./SendMail.vue'))
|
||||
|
||||
const ADDRESS_PAGE_SIZE = 100
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'send_mail',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sent'])
|
||||
|
||||
const message = useMessage()
|
||||
const { openSettings } = useGlobalState()
|
||||
const { t } = useScopedI18n('views.user.UserSendBox')
|
||||
const { t: mailboxT } = useScopedI18n('views.user.UserMailBox')
|
||||
|
||||
const selectedAddressId = ref(null)
|
||||
const addressFilter = ref(null)
|
||||
const addressOptions = ref([])
|
||||
const addressCount = ref(0)
|
||||
const addressLoading = ref(false)
|
||||
const sendboxKey = ref(0)
|
||||
|
||||
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
|
||||
const addressFilterOptions = computed(() => addressOptions.value.map((address) => ({
|
||||
label: address.label,
|
||||
value: address.address,
|
||||
})))
|
||||
|
||||
const fetchAddresses = async () => {
|
||||
if (addressLoading.value || (!hasMoreAddresses.value && addressOptions.value.length > 0)) {
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const offset = addressOptions.value.length
|
||||
const { results, count } = await api.fetch(
|
||||
`/user_api/bind_address?limit=${ADDRESS_PAGE_SIZE}&offset=${offset}`
|
||||
)
|
||||
addressOptions.value.push(...results.map((address) => ({
|
||||
label: address.name,
|
||||
value: address.id,
|
||||
address: address.name,
|
||||
})))
|
||||
if (offset === 0) {
|
||||
addressCount.value = count
|
||||
}
|
||||
if (props.mode === 'send_mail' && !selectedAddressId.value && addressOptions.value.length > 0) {
|
||||
selectedAddressId.value = addressOptions.value[0].value
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
} finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddressScroll = async (event) => {
|
||||
const target = event.currentTarget
|
||||
if (!target || target.scrollTop + target.clientHeight < target.scrollHeight - 24) {
|
||||
return
|
||||
}
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
const fetchSendbox = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/user_api/sendbox?limit=${limit}&offset=${offset}`
|
||||
+ (addressFilter.value ? `&address=${encodeURIComponent(addressFilter.value)}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
const deleteSendboxMail = async (mailId) => {
|
||||
await api.fetch(`/user_api/sendbox/${mailId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
const querySendbox = () => {
|
||||
sendboxKey.value = Date.now()
|
||||
}
|
||||
|
||||
watch(addressFilter, querySendbox)
|
||||
|
||||
onMounted(fetchAddresses)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-send-box">
|
||||
<template v-if="mode === 'send_mail'">
|
||||
<n-empty v-if="!selectedAddressId" class="address-empty" :description="t('noAddress')" />
|
||||
<SendMail v-else :key="selectedAddressId" v-model:address-id="selectedAddressId"
|
||||
:address-options="addressOptions" :address-loading="addressLoading"
|
||||
@address-scroll="handleAddressScroll" @sent="emit('sent')" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-input-group>
|
||||
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
|
||||
:loading="addressLoading" :placeholder="mailboxT('addressQueryTip')"
|
||||
@scroll="handleAddressScroll" />
|
||||
<n-button @click="querySendbox" type="primary" tertiary>
|
||||
{{ mailboxT('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div class="filter-spacing"></div>
|
||||
<SendBox :key="sendboxKey" :fetch-mail-data="fetchSendbox" show-e-mail-from
|
||||
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
|
||||
:delete-mail="deleteSendboxMail" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-send-box {
|
||||
padding-top: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filter-spacing {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.address-empty {
|
||||
padding: 72px 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ const API_PATHS = [
|
||||
"/admin/",
|
||||
"/telegram/",
|
||||
"/external/",
|
||||
"/redeem_api/",
|
||||
];
|
||||
|
||||
export async function onRequest(context) {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.11.1",
|
||||
"version": "1.12.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.124.0"
|
||||
"wrangler": "^4.129.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Worker Variables', link: 'worker-vars' },
|
||||
{ text: 'Frontend Variables', link: 'frontend-vars' },
|
||||
{ text: 'Configure Email Sending', link: 'config-send-mail' },
|
||||
]
|
||||
},
|
||||
@@ -164,11 +165,13 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
text: 'Advanced Features',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Mailbox Password Login', link: 'feature/mailbox-password-login' },
|
||||
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
|
||||
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
|
||||
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
|
||||
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
|
||||
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
|
||||
{ text: 'Configure Redemption Codes', link: 'feature/redeem-code' },
|
||||
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -140,6 +140,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Worker 变量说明', link: 'worker-vars' },
|
||||
{ text: '前端变量说明', link: 'frontend-vars' },
|
||||
{ text: '配置发送邮件', link: 'config-send-mail' },
|
||||
]
|
||||
},
|
||||
@@ -164,11 +165,13 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
text: '高级功能',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '邮箱密码登录', link: 'feature/mailbox-password-login' },
|
||||
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
|
||||
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
||||
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
||||
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
|
||||
{ text: '配置其他 worker 增强', link: 'feature/another-worker-enhanced' },
|
||||
{ text: '配置兑换码', link: 'feature/redeem-code' },
|
||||
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ Then go to the repository page `Settings` -> `Secrets and variables` -> `Actions
|
||||
|
||||
| Name | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FRONTEND_ENV` | Frontend configuration file used by the `Deploy Frontend` workflow. Copy the content from `frontend/.env.example`, [and modify it according to this guide](/en/guide/cli/pages.html). For separate frontend/backend deployment that talks to Worker directly, `VITE_API_BASE` should be the backend Worker API root URL, must start with `https://`, and must not include a trailing `/`. When this address is configured incorrectly, common symptoms are the `map` error or `405` API responses |
|
||||
| `FRONTEND_ENV` | Frontend configuration file used by the `Deploy Frontend` workflow. Copy the content from `frontend/.env.example` and modify it according to [Frontend Variables](/en/guide/frontend-vars) |
|
||||
| `FRONTEND_NAME` | The project name you created in Cloudflare Pages, can be created via [UI](https://temp-mail-docs.awsl.uk/en/guide/ui/pages.html) or [Command Line](https://temp-mail-docs.awsl.uk/en/guide/cli/pages.html) |
|
||||
| `FRONTEND_BRANCH` | (Optional) Branch for pages deployment, can be left unconfigured, defaults to `production` |
|
||||
| `PAGE_TOML` | (Optional) Used only by the `Deploy Frontend with page function` workflow. Required when using page functions to forward backend requests. Please copy the content from `pages/wrangler.toml` and modify the `service` field to your worker backend name according to actual situation. This workflow builds the frontend in Pages mode and uses same-origin requests, so it does not read `FRONTEND_ENV` |
|
||||
|
||||
@@ -29,6 +29,12 @@ Change `VITE_API_BASE` to the `worker` `url` created in the previous step. Do no
|
||||
|
||||
For example: `VITE_API_BASE=https://xxx.xxx.workers.dev`
|
||||
|
||||
Set the frontend default language with `VITE_DEFAULT_LANG`. Supported values are `zh`, `en`, `es`, `pt-BR`, `ja`, and `de`; an unset or invalid value falls back to `zh`.
|
||||
|
||||
For example: `VITE_DEFAULT_LANG=en`
|
||||
|
||||
See [Frontend Variables](/en/guide/frontend-vars) for other settings.
|
||||
|
||||
```bash
|
||||
pnpm build --emptyOutDir
|
||||
# The first deployment will prompt you to create a project, for production branch enter production
|
||||
@@ -47,6 +53,8 @@ The first deployment will prompt you to create a project. For the `production` b
|
||||
|
||||
If your worker backend name is not `cloudflare_temp_email`, please modify `pages/wrangler.toml`.
|
||||
|
||||
To set the default language, add `VITE_DEFAULT_LANG=en` to `frontend/.env.pages.local`. Same-origin requests do not require `VITE_API_BASE`; see [Frontend Variables](/en/guide/frontend-vars) for other settings.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user